release: 2026-03-17 (8건 커밋) #4
@ -43,5 +43,42 @@
|
||||
"Read(./**/.env.*)",
|
||||
"Read(./**/secrets/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/scripts/on-commit.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-17",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev"
|
||||
}
|
||||
75
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,75 @@
|
||||
name: Deploy KCG
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ═══ Frontend ═══
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Configure npm registry
|
||||
run: |
|
||||
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > frontend/.npmrc
|
||||
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> frontend/.npmrc
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
env:
|
||||
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
run: |
|
||||
npm ci
|
||||
npx vite build
|
||||
|
||||
- name: Deploy frontend
|
||||
run: |
|
||||
mkdir -p /deploy/kcg
|
||||
rm -rf /deploy/kcg/*
|
||||
cp -r frontend/dist/* /deploy/kcg/
|
||||
|
||||
# ═══ Backend ═══
|
||||
- name: Install JDK 17 + Maven
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq openjdk-17-jdk-headless maven
|
||||
|
||||
- name: Build backend
|
||||
working-directory: backend
|
||||
run: mvn -B clean package -DskipTests
|
||||
|
||||
- name: Deploy backend
|
||||
run: |
|
||||
DEPLOY_DIR=/deploy/kcg-backend
|
||||
mkdir -p $DEPLOY_DIR/backup
|
||||
|
||||
# JAR 백업 (최근 5개 유지)
|
||||
if [ -f $DEPLOY_DIR/kcg.jar ]; then
|
||||
cp $DEPLOY_DIR/kcg.jar $DEPLOY_DIR/backup/kcg-$(date +%Y%m%d%H%M%S).jar
|
||||
ls -t $DEPLOY_DIR/backup/*.jar | tail -n +6 | xargs -r rm
|
||||
fi
|
||||
|
||||
# 서비스 중지 → JAR 교체 → 서비스 시작
|
||||
sudo systemctl stop kcg-backend || true
|
||||
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar
|
||||
sudo systemctl start kcg-backend
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "Waiting for backend to start..."
|
||||
for i in $(seq 1 30); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/auth/me 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" != "000" ]; then
|
||||
echo "Backend is up (HTTP $HTTP_CODE, attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Backend health check failed after 60s"
|
||||
exit 1
|
||||
@ -1,54 +1,88 @@
|
||||
#!/bin/bash
|
||||
#==============================================================================
|
||||
# pre-commit hook (React TypeScript)
|
||||
# TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단
|
||||
# pre-commit hook (모노레포: Frontend + Backend)
|
||||
# Frontend: TypeScript 컴파일 + 린트 검증
|
||||
# Backend: Maven 컴파일 검증
|
||||
# 실패 시 커밋 차단
|
||||
#==============================================================================
|
||||
|
||||
echo "pre-commit: TypeScript 타입 체크 중..."
|
||||
FAILED=0
|
||||
|
||||
# npm 확인
|
||||
if ! command -v npx &>/dev/null; then
|
||||
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
|
||||
exit 0
|
||||
fi
|
||||
#------------------------------------------------------------------------------
|
||||
# Frontend 검증
|
||||
#------------------------------------------------------------------------------
|
||||
if [ -d "frontend" ]; then
|
||||
echo "pre-commit: [Frontend] TypeScript 타입 체크 중..."
|
||||
|
||||
# node_modules 확인
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "경고: node_modules가 없습니다. 'npm install' 실행 후 다시 시도하세요."
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v npx &>/dev/null; then
|
||||
echo "경고: npx가 설치되지 않았습니다. Frontend 검증을 건너뜁니다."
|
||||
elif [ ! -d "frontend/node_modules" ]; then
|
||||
echo "경고: frontend/node_modules가 없습니다. 'cd frontend && npm install' 실행 후 다시 시도하세요."
|
||||
FAILED=1
|
||||
else
|
||||
(cd frontend && npx tsc --noEmit --pretty 2>&1)
|
||||
TSC_RESULT=$?
|
||||
|
||||
# TypeScript 타입 체크
|
||||
npx tsc --noEmit --pretty 2>&1
|
||||
TSC_RESULT=$?
|
||||
if [ $TSC_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ [Frontend] TypeScript 타입 에러! 커밋이 차단되었습니다.║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
FAILED=1
|
||||
else
|
||||
echo "pre-commit: [Frontend] 타입 체크 성공"
|
||||
fi
|
||||
|
||||
if [ $TSC_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
# ESLint 검증
|
||||
if [ -f "frontend/eslint.config.js" ] || [ -f "frontend/eslint.config.mjs" ] || [ -f "frontend/.eslintrc.js" ] || [ -f "frontend/.eslintrc.json" ]; then
|
||||
echo "pre-commit: [Frontend] ESLint 검증 중..."
|
||||
(cd frontend && npx eslint src/ --ext .ts,.tsx --quiet 2>&1)
|
||||
LINT_RESULT=$?
|
||||
|
||||
echo "pre-commit: 타입 체크 성공"
|
||||
|
||||
# ESLint 검증 (설정 파일이 있는 경우만)
|
||||
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
|
||||
echo "pre-commit: ESLint 검증 중..."
|
||||
npx eslint src/ --ext .ts,.tsx --quiet 2>&1
|
||||
LINT_RESULT=$?
|
||||
|
||||
if [ $LINT_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "║ 'npm run lint -- --fix'로 자동 수정을 시도해보세요. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
exit 1
|
||||
if [ $LINT_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ [Frontend] ESLint 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
FAILED=1
|
||||
else
|
||||
echo "pre-commit: [Frontend] ESLint 통과"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "pre-commit: ESLint 통과"
|
||||
fi
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Backend 검증
|
||||
#------------------------------------------------------------------------------
|
||||
if [ -d "backend" ] && [ -f "backend/pom.xml" ]; then
|
||||
echo "pre-commit: [Backend] Maven 컴파일 검증 중..."
|
||||
|
||||
if ! command -v mvn &>/dev/null; then
|
||||
echo "경고: mvn이 설치되지 않았습니다. Backend 검증을 건너뜁니다."
|
||||
else
|
||||
(cd backend && mvn compile -q 2>&1)
|
||||
MVN_RESULT=$?
|
||||
|
||||
if [ $MVN_RESULT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ [Backend] Maven 컴파일 에러! 커밋이 차단되었습니다. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
FAILED=1
|
||||
else
|
||||
echo "pre-commit: [Backend] 컴파일 성공"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# 결과
|
||||
#------------------------------------------------------------------------------
|
||||
if [ $FAILED -ne 0 ]; then
|
||||
echo ""
|
||||
echo "pre-commit: 검증 실패! 에러를 수정한 후 다시 커밋해주세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-commit: 모든 검증 통과"
|
||||
|
||||
53
.gitignore
vendored
@ -29,6 +29,57 @@ coverage/
|
||||
.prettiercache
|
||||
*.tsbuildinfo
|
||||
|
||||
# === Claude Code (개인 설정) ===
|
||||
# === Claude Code ===
|
||||
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
|
||||
!.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/CLAUDE.local.md
|
||||
# Team workflow (managed by /sync-team-workflow)
|
||||
.claude/rules/
|
||||
.claude/agents/
|
||||
.claude/skills/push/
|
||||
.claude/skills/mr/
|
||||
.claude/skills/create-mr/
|
||||
.claude/skills/release/
|
||||
.claude/skills/version/
|
||||
.claude/skills/fix-issue/
|
||||
.claude/scripts/
|
||||
|
||||
# 프로젝트 기존 항목
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.pdf
|
||||
|
||||
# === Frontend ===
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
|
||||
# === Backend ===
|
||||
backend/target/
|
||||
backend/.env
|
||||
backend/src/main/resources/application-local.yml
|
||||
backend/src/main/resources/application-prod.yml
|
||||
|
||||
# === Prediction ===
|
||||
prediction/__pycache__/
|
||||
prediction/venv/
|
||||
prediction/.env
|
||||
|
||||
102
CLAUDE.md
@ -1,48 +1,90 @@
|
||||
# 프로젝트 개요
|
||||
|
||||
- **타입**: React + TypeScript + Vite
|
||||
- **타입**: 모노레포 (Frontend + Backend + Prediction)
|
||||
- **Frontend**: React + TypeScript + Vite
|
||||
- **Backend**: Spring Boot 3.2.5 + Java 17 + PostgreSQL
|
||||
- **Prediction**: FastAPI (Python)
|
||||
- **Node.js**: `.node-version` 참조
|
||||
- **패키지 매니저**: npm
|
||||
- **빌드 도구**: Vite
|
||||
- **Java**: `backend/.sdkmanrc` 참조
|
||||
- **패키지 매니저**: npm (frontend), Maven (backend), pip (prediction)
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
# 의존성 설치
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# 개발 서버
|
||||
npm run dev
|
||||
```
|
||||
|
||||
# 빌드
|
||||
npm run build
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
# application-local.yml 설정 필요 (application-local.yml.example 참조)
|
||||
cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||
```
|
||||
|
||||
# 테스트
|
||||
npm run test
|
||||
### Database
|
||||
```bash
|
||||
psql -U postgres -f database/init.sql
|
||||
psql -U postgres -d kcgdb -f database/migration/001_initial_schema.sql
|
||||
```
|
||||
|
||||
# 린트
|
||||
npm run lint
|
||||
### Prediction
|
||||
```bash
|
||||
cd prediction
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
# 포맷팅
|
||||
npm run format
|
||||
### 린트/검증
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm run lint
|
||||
|
||||
# Backend
|
||||
cd backend && mvn compile
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # 정적 리소스 (이미지, 폰트 등)
|
||||
├── components/ # 공통 UI 컴포넌트
|
||||
│ ├── common/ # 범용 컴포넌트 (Button, Input 등)
|
||||
│ └── layout/ # 레이아웃 컴포넌트 (Header, Sidebar 등)
|
||||
├── hooks/ # 커스텀 훅
|
||||
├── pages/ # 페이지 컴포넌트 (라우팅 단위)
|
||||
├── services/ # API 호출 로직
|
||||
├── store/ # 상태 관리 (Context, Zustand 등)
|
||||
├── types/ # TypeScript 타입 정의
|
||||
├── utils/ # 유틸리티 함수
|
||||
├── App.tsx
|
||||
└── main.tsx
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── components/
|
||||
│ ├── hooks/
|
||||
│ ├── pages/
|
||||
│ ├── services/
|
||||
│ ├── store/
|
||||
│ ├── types/
|
||||
│ ├── utils/
|
||||
│ ├── App.tsx
|
||||
│ └── main.tsx
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
|
||||
backend/
|
||||
├── src/main/java/gc/mda/kcg/
|
||||
│ ├── config/ # 설정 (CORS, Security, Properties)
|
||||
│ ├── auth/ # 인증 (Google OAuth + JWT)
|
||||
│ ├── domain/ # 도메인 (event, news, osint, aircraft)
|
||||
│ └── collector/ # 데이터 수집기 (GDELT, GoogleNews, CENTCOM)
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml
|
||||
│ └── application-*.yml.example
|
||||
└── pom.xml
|
||||
|
||||
database/
|
||||
├── init.sql
|
||||
└── migration/
|
||||
|
||||
prediction/
|
||||
├── main.py
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 팀 규칙
|
||||
@ -55,6 +97,6 @@ src/
|
||||
|
||||
## 의존성 관리
|
||||
|
||||
- Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
|
||||
- 새 의존성 추가: `npm install 패키지명`
|
||||
- devDependency: `npm install -D 패키지명`
|
||||
- Frontend: Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
|
||||
- Backend: Maven Central (pom.xml)
|
||||
- Prediction: pip (requirements.txt)
|
||||
|
||||
74
README.md
@ -1,3 +1,73 @@
|
||||
# kcg-monitoring
|
||||
# React + TypeScript + Vite
|
||||
|
||||
KCG 모니터링 대시보드
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
1
backend/.sdkmanrc
Normal file
@ -0,0 +1 @@
|
||||
java=17.0.18-amzn
|
||||
106
backend/pom.xml
Normal file
@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>gc.mda</groupId>
|
||||
<artifactId>kcg</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>kcg</name>
|
||||
<description>KCG Monitoring Dashboard Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot Starters -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Google API Client -->
|
||||
<dependency>
|
||||
<groupId>com.google.api-client</groupId>
|
||||
<artifactId>google-api-client</artifactId>
|
||||
<version>2.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>kcg</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
14
backend/src/main/java/gc/mda/kcg/KcgApplication.java
Normal file
@ -0,0 +1,14 @@
|
||||
package gc.mda.kcg;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class KcgApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(KcgApplication.class, args);
|
||||
}
|
||||
}
|
||||
77
backend/src/main/java/gc/mda/kcg/auth/AuthController.java
Normal file
@ -0,0 +1,77 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import gc.mda.kcg.auth.dto.AuthResponse;
|
||||
import gc.mda.kcg.auth.dto.GoogleAuthRequest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private static final String JWT_COOKIE_NAME = "kcg_token";
|
||||
|
||||
private final AuthService authService;
|
||||
private final JwtProvider jwtProvider;
|
||||
|
||||
/**
|
||||
* Google OAuth2 id_token 검증 후 JWT 쿠키 발급
|
||||
*/
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<AuthResponse> googleLogin(
|
||||
@Valid @RequestBody GoogleAuthRequest request,
|
||||
HttpServletResponse response) {
|
||||
|
||||
AuthResponse authResponse = authService.authenticateWithGoogle(request.getCredential());
|
||||
String token = jwtProvider.generateToken(authResponse.getEmail(), authResponse.getName());
|
||||
|
||||
Cookie cookie = new Cookie(JWT_COOKIE_NAME, token);
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(false);
|
||||
cookie.setPath("/");
|
||||
cookie.setMaxAge((int) (jwtProvider.getExpirationMs() / 1000));
|
||||
response.addCookie(cookie);
|
||||
|
||||
return ResponseEntity.ok(authResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 쿠키에서 현재 사용자 정보 반환
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<AuthResponse> me(
|
||||
@CookieValue(name = JWT_COOKIE_NAME, required = false) String token) {
|
||||
|
||||
if (token == null || token.isBlank()) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
AuthResponse authResponse = authService.getUserFromToken(token);
|
||||
return ResponseEntity.ok(authResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 쿠키 삭제 (로그아웃)
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletResponse response) {
|
||||
Cookie cookie = new Cookie(JWT_COOKIE_NAME, "");
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(false);
|
||||
cookie.setPath("/");
|
||||
cookie.setMaxAge(0);
|
||||
response.addCookie(cookie);
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
70
backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java
Normal file
@ -0,0 +1,70 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String JWT_COOKIE_NAME = "kcg_token";
|
||||
private static final String AUTH_PATH_PREFIX = "/api/auth/";
|
||||
|
||||
private final JwtProvider jwtProvider;
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.startsWith(AUTH_PATH_PREFIX);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String token = extractTokenFromCookies(request);
|
||||
|
||||
if (token == null || token.isBlank()) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.getWriter().write("{\"error\":\"인증이 필요합니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Claims claims = jwtProvider.validateToken(token);
|
||||
request.setAttribute("userEmail", claims.getSubject());
|
||||
request.setAttribute("userName", claims.get("name", String.class));
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("인증 실패: {}", e.getMessage());
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.getWriter().write("{\"error\":\"유효하지 않은 토큰입니다.\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTokenFromCookies(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(cookies)
|
||||
.filter(c -> JWT_COOKIE_NAME.equals(c.getName()))
|
||||
.findFirst()
|
||||
.map(Cookie::getValue)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
130
backend/src/main/java/gc/mda/kcg/auth/AuthService.java
Normal file
@ -0,0 +1,130 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||
import com.google.api.client.json.gson.GsonFactory;
|
||||
import gc.mda.kcg.auth.dto.AuthResponse;
|
||||
import gc.mda.kcg.auth.entity.LoginHistory;
|
||||
import gc.mda.kcg.auth.entity.User;
|
||||
import gc.mda.kcg.auth.repository.LoginHistoryRepository;
|
||||
import gc.mda.kcg.auth.repository.UserRepository;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final JwtProvider jwtProvider;
|
||||
private final UserRepository userRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
|
||||
/**
|
||||
* Google id_token 검증 후 사용자 upsert 및 로그인 이력 기록
|
||||
*/
|
||||
@Transactional
|
||||
public AuthResponse authenticateWithGoogle(String credential) {
|
||||
GoogleIdToken idToken = verifyGoogleToken(credential);
|
||||
GoogleIdToken.Payload payload = idToken.getPayload();
|
||||
|
||||
String email = payload.getEmail();
|
||||
String name = (String) payload.get("name");
|
||||
String picture = (String) payload.get("picture");
|
||||
|
||||
validateEmailDomain(email);
|
||||
|
||||
User user = upsertUser(email, name, picture);
|
||||
recordLoginHistory(user);
|
||||
|
||||
return AuthResponse.builder()
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.picture(user.getPicture())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 정보 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public AuthResponse getUserFromToken(String token) {
|
||||
Claims claims = jwtProvider.validateToken(token);
|
||||
String email = claims.getSubject();
|
||||
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + email));
|
||||
|
||||
return AuthResponse.builder()
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.picture(user.getPicture())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GoogleIdToken verifyGoogleToken(String credential) {
|
||||
try {
|
||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
.setAudience(Collections.singletonList(appProperties.getGoogle().getClientId()))
|
||||
.build();
|
||||
|
||||
GoogleIdToken idToken = verifier.verify(credential);
|
||||
if (idToken == null) {
|
||||
throw new IllegalArgumentException("유효하지 않은 Google 토큰입니다.");
|
||||
}
|
||||
return idToken;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Google 토큰 검증 실패", e);
|
||||
throw new IllegalArgumentException("Google 토큰 검증에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateEmailDomain(String email) {
|
||||
String allowedDomain = appProperties.getAuth().getAllowedDomain();
|
||||
if (allowedDomain != null && !allowedDomain.isBlank()) {
|
||||
String domain = email.substring(email.indexOf('@') + 1);
|
||||
if (!domain.equals(allowedDomain)) {
|
||||
throw new IllegalArgumentException("허용되지 않은 이메일 도메인입니다: " + domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private User upsertUser(String email, String name, String picture) {
|
||||
return userRepository.findByEmail(email)
|
||||
.map(existing -> {
|
||||
existing.setName(name);
|
||||
existing.setPicture(picture);
|
||||
existing.setLastLoginAt(LocalDateTime.now());
|
||||
return userRepository.save(existing);
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
User newUser = User.builder()
|
||||
.email(email)
|
||||
.name(name)
|
||||
.picture(picture)
|
||||
.lastLoginAt(LocalDateTime.now())
|
||||
.build();
|
||||
return userRepository.save(newUser);
|
||||
});
|
||||
}
|
||||
|
||||
private void recordLoginHistory(User user) {
|
||||
LoginHistory history = LoginHistory.builder()
|
||||
.user(user)
|
||||
.loginAt(LocalDateTime.now())
|
||||
.build();
|
||||
loginHistoryRepository.save(history);
|
||||
}
|
||||
}
|
||||
68
backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java
Normal file
@ -0,0 +1,68 @@
|
||||
package gc.mda.kcg.auth;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
@Getter
|
||||
private final long expirationMs;
|
||||
|
||||
public JwtProvider(AppProperties appProperties) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(
|
||||
appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationMs = appProperties.getJwt().getExpirationMs();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성
|
||||
*/
|
||||
public String generateToken(String email, String name) {
|
||||
Date now = new Date();
|
||||
Date expiry = new Date(now.getTime() + expirationMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(email)
|
||||
.claim("name", name)
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 검증 및 Claims 반환
|
||||
*/
|
||||
public Claims validateToken(String token) {
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (JwtException e) {
|
||||
log.warn("JWT 토큰 검증 실패: {}", e.getMessage());
|
||||
throw new IllegalArgumentException("유효하지 않은 토큰입니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 이메일 추출
|
||||
*/
|
||||
public String getEmailFromToken(String token) {
|
||||
return validateToken(token).getSubject();
|
||||
}
|
||||
}
|
||||
17
backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java
Normal file
@ -0,0 +1,17 @@
|
||||
package gc.mda.kcg.auth.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthResponse {
|
||||
|
||||
private String email;
|
||||
private String name;
|
||||
private String picture;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package gc.mda.kcg.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GoogleAuthRequest {
|
||||
|
||||
@NotBlank(message = "credential은 필수입니다.")
|
||||
private String credential;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package gc.mda.kcg.auth.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "login_history", schema = "kcg")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginHistory {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "login_at", nullable = false)
|
||||
@Builder.Default
|
||||
private LocalDateTime loginAt = LocalDateTime.now();
|
||||
}
|
||||
44
backend/src/main/java/gc/mda/kcg/auth/entity/User.java
Normal file
@ -0,0 +1,44 @@
|
||||
package gc.mda.kcg.auth.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String picture;
|
||||
|
||||
@Column(name = "last_login_at")
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@Builder.Default
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package gc.mda.kcg.auth.repository;
|
||||
|
||||
import gc.mda.kcg.auth.entity.LoginHistory;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.auth.repository;
|
||||
|
||||
import gc.mda.kcg.auth.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.collector;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 기사 분류기 (정규식 기반)
|
||||
* TODO: 프론트엔드 classifyArticle() 로직 이식
|
||||
*/
|
||||
@Component
|
||||
public class ArticleClassifier {
|
||||
|
||||
private static final Map<String, Pattern> CATEGORY_PATTERNS = Map.of(
|
||||
"military", Pattern.compile("(?i)(strike|attack|military|weapon|missile|drone|bomb|combat|war|force)"),
|
||||
"political", Pattern.compile("(?i)(sanction|diplomat|negotiat|treaty|nuclear|iran|deal|policy)"),
|
||||
"intelligence", Pattern.compile("(?i)(intel|spy|surveillance|intercept|sigint|osint|recon)")
|
||||
);
|
||||
|
||||
/**
|
||||
* 기사 제목/본문을 분석하여 카테고리를 반환
|
||||
*
|
||||
* @param title 기사 제목
|
||||
* @param content 기사 본문
|
||||
* @return 분류된 카테고리 (military, political, intelligence, general)
|
||||
*/
|
||||
public String classifyArticle(String title, String content) {
|
||||
String text = (title + " " + content).toLowerCase();
|
||||
|
||||
for (Map.Entry<String, Pattern> entry : CATEGORY_PATTERNS.entrySet()) {
|
||||
if (entry.getValue().matcher(text).find()) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.collector;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* CENTCOM 보도자료 수집기
|
||||
* TODO: CENTCOM 웹사이트에서 보도자료 수집 구현
|
||||
*/
|
||||
@Component
|
||||
public class CentcomCollector {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.collector;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* GDELT 데이터 수집기
|
||||
* TODO: GDELT API를 통한 이벤트/뉴스 데이터 수집 구현
|
||||
*/
|
||||
@Component
|
||||
public class GdeltCollector {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.collector;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Google News RSS 수집기
|
||||
* TODO: Google News RSS 피드를 통한 뉴스 데이터 수집 구현
|
||||
*/
|
||||
@Component
|
||||
public class GoogleNewsCollector {
|
||||
}
|
||||
36
backend/src/main/java/gc/mda/kcg/config/AppProperties.java
Normal file
@ -0,0 +1,36 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public class AppProperties {
|
||||
|
||||
private Jwt jwt = new Jwt();
|
||||
private Google google = new Google();
|
||||
private Auth auth = new Auth();
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Jwt {
|
||||
private String secret;
|
||||
private long expirationMs;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Google {
|
||||
private String clientId;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Auth {
|
||||
private String allowedDomain;
|
||||
}
|
||||
}
|
||||
19
backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java
Normal file
@ -0,0 +1,19 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import gc.mda.kcg.auth.AuthFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<AuthFilter> authFilterRegistration(AuthFilter authFilter) {
|
||||
FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(authFilter);
|
||||
registration.addUrlPatterns("/api/*");
|
||||
registration.setOrder(1);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
18
backend/src/main/java/gc/mda/kcg/config/WebConfig.java
Normal file
@ -0,0 +1,18 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:5173")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 항공기 도메인 (향후 구현)
|
||||
*/
|
||||
package gc.mda.kcg.domain.aircraft;
|
||||
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 이벤트 도메인 (향후 구현)
|
||||
*/
|
||||
package gc.mda.kcg.domain.event;
|
||||
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 뉴스 도메인 (향후 구현)
|
||||
*/
|
||||
package gc.mda.kcg.domain.news;
|
||||
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* OSINT 도메인 (향후 구현)
|
||||
*/
|
||||
package gc.mda.kcg.domain.osint;
|
||||
13
backend/src/main/resources/application-local.yml.example
Normal file
@ -0,0 +1,13 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
|
||||
username: kcg_user
|
||||
password: kcg_pass
|
||||
app:
|
||||
jwt:
|
||||
secret: local-dev-secret-key-32chars-minimum!!
|
||||
expiration-ms: 86400000
|
||||
google:
|
||||
client-id: YOUR_GOOGLE_CLIENT_ID
|
||||
auth:
|
||||
allowed-domain: gcsc.co.kr
|
||||
13
backend/src/main/resources/application-prod.yml.example
Normal file
@ -0,0 +1,13 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
|
||||
google:
|
||||
client-id: ${GOOGLE_CLIENT_ID}
|
||||
auth:
|
||||
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||
11
backend/src/main/resources/application.yml
Normal file
@ -0,0 +1,11 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: kcg
|
||||
server:
|
||||
port: 8080
|
||||
2
database/init.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- KCG 데이터베이스 초기화
|
||||
CREATE SCHEMA IF NOT EXISTS kcg;
|
||||
83
database/migration/001_initial_schema.sql
Normal file
@ -0,0 +1,83 @@
|
||||
-- 001: 초기 스키마 생성
|
||||
-- events, news, osint, users, login_history
|
||||
|
||||
SET search_path TO kcg;
|
||||
|
||||
-- 이벤트 테이블 (GDELT 등 이벤트 데이터)
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(64) UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
source VARCHAR(128),
|
||||
source_url TEXT,
|
||||
category VARCHAR(64),
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events (timestamp);
|
||||
|
||||
-- 뉴스 테이블
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
source VARCHAR(128),
|
||||
source_url TEXT UNIQUE,
|
||||
category VARCHAR(64),
|
||||
language VARCHAR(8),
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_news_timestamp ON news (timestamp);
|
||||
|
||||
-- OSINT 테이블
|
||||
CREATE TABLE IF NOT EXISTS osint (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
source VARCHAR(128),
|
||||
source_url TEXT UNIQUE,
|
||||
category VARCHAR(64),
|
||||
credibility SMALLINT,
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
raw_data JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_osint_timestamp ON osint (timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_osint_category ON osint (category);
|
||||
|
||||
-- 사용자 테이블
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
picture TEXT,
|
||||
last_login_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
-- 로그인 이력 테이블
|
||||
CREATE TABLE IF NOT EXISTS login_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
login_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
failure_reason VARCHAR(100)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_user_id ON login_history (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_history_login_at ON login_history (login_at);
|
||||
25
deploy/kcg-backend.service
Normal file
@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=KCG Monitoring Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/deploy/kcg-backend
|
||||
ExecStart=/usr/lib/jvm/java-17-openjdk-17.0.18.0.8-1.el9.x86_64/bin/java \
|
||||
-Xms2g -Xmx4g \
|
||||
-Dspring.profiles.active=prod \
|
||||
-Dspring.config.additional-location=file:/deploy/kcg-backend/ \
|
||||
-jar /deploy/kcg-backend/kcg.jar
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=30
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=kcg-backend
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
107
deploy/nginx-kcg.conf
Normal file
@ -0,0 +1,107 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name kcg.gc-si.dev;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/gitea.gc-si.dev/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/gitea.gc-si.dev/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# ── Frontend SPA ──
|
||||
root /deploy/kcg;
|
||||
# Static cache
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ── Backend API (direct) ──
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ── Backend API (dev prefix 호환) ──
|
||||
location /api/kcg/ {
|
||||
rewrite ^/api/kcg/(.*) /api/$1 break;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ── signal-batch 프록시 (선박 위치 API) ──
|
||||
location /signal-batch/ {
|
||||
proxy_pass https://wing.gc-si.dev/signal-batch/;
|
||||
proxy_set_header Host wing.gc-si.dev;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ── 선박 이미지 프록시 ──
|
||||
location /shipimg/ {
|
||||
proxy_pass https://wing.gc-si.dev/shipimg/;
|
||||
proxy_set_header Host wing.gc-si.dev;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
# ── 외부 API 프록시 (프론트엔드 CORS 우회) ──
|
||||
location /api/airplaneslive/ {
|
||||
proxy_pass https://api.airplanes.live/;
|
||||
proxy_set_header Host api.airplanes.live;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location /api/opensky/ {
|
||||
proxy_pass https://opensky-network.org/;
|
||||
proxy_set_header Host opensky-network.org;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location /api/celestrak/ {
|
||||
proxy_pass https://celestrak.org/;
|
||||
proxy_set_header Host celestrak.org;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header User-Agent "Mozilla/5.0 (compatible; KCG-Monitor/1.0)";
|
||||
}
|
||||
|
||||
location /api/gdelt/ {
|
||||
proxy_pass https://api.gdeltproject.org/;
|
||||
proxy_set_header Host api.gdeltproject.org;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location /api/rss/ {
|
||||
proxy_pass https://news.google.com/;
|
||||
proxy_set_header Host news.google.com;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location /api/ais/ {
|
||||
proxy_pass https://aisapi.maritime.spglobal.com/;
|
||||
proxy_set_header Host aisapi.maritime.spglobal.com;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
# gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name kcg.gc-si.dev;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
31
docs/RELEASE-NOTES.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Release Notes
|
||||
|
||||
이 문서는 [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/) 형식을 따릅니다.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-17]
|
||||
|
||||
### 추가
|
||||
- 프론트엔드 모노레포 이관 (`frontend/` 폴더 구조)
|
||||
- signal-batch API 연동 (한국 선박 실시간 위치 데이터)
|
||||
- Tailwind CSS 4 + CSS 변수 테마 시스템 (dark/light)
|
||||
- i18next 다국어 지원 (ko/en) — 28개 컴포넌트 적용
|
||||
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 접이식 범례)
|
||||
- Google OAuth 로그인 + DEV LOGIN 인증 우회 (개발 모드)
|
||||
- 선박 이미지 탭 전환 UI (signal-batch / MarineTraffic)
|
||||
- 백엔드 Spring Boot 3.2 스켈레톤 (Java 17)
|
||||
- Google OAuth + JWT 인증 API (`@gcsc.co.kr` 도메인 제한)
|
||||
- 데이터 수집기 placeholder (GDELT, Google News, CENTCOM)
|
||||
- PostgreSQL 스키마 (events, news, osint, users, login_history)
|
||||
- Python FastAPI 분석서버 placeholder
|
||||
- Gitea Actions CI/CD 파이프라인 (main merge 시 자동 배포)
|
||||
- nginx 설정 (SPA + API 프록시 + 외부 API CORS 프록시)
|
||||
- systemd 서비스 (kcg-backend, JDK 17, 2~4GB 힙)
|
||||
|
||||
### 변경
|
||||
- 외부 API 호출 CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
|
||||
- App.css 하드코딩 색상 → CSS 변수 토큰 전환 (테마 반응)
|
||||
- 선박 분류 체계 AIS shipTy 파싱 개선
|
||||
- 한국 선박 데이터 폴링 주기 15초 → 4분
|
||||
- 범례 카운트 MT 분류 기준으로 동기화
|
||||
34
frontend/eslint.config.js
Normal file
@ -0,0 +1,34 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// React Compiler rules — too strict for ref-in-useMemo patterns, disable for now
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iran-airstrike-replay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4915
frontend/package-lock.json
generated
Normal file
44
frontend/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "kcg-monitoring",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rollup/rollup-darwin-arm64": "^4.59.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.8.18",
|
||||
"leaflet": "^1.9.4",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"satellite.js": "^6.0.2",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/ships/440034000.jpg
Normal file
|
After Width: | Height: | 크기: 113 KiB |
BIN
frontend/public/ships/440150000.jpg
Normal file
|
After Width: | Height: | 크기: 112 KiB |
BIN
frontend/public/ships/440272000.jpg
Normal file
|
After Width: | Height: | 크기: 86 KiB |
BIN
frontend/public/ships/440272000.png
Normal file
|
After Width: | Height: | 크기: 1.2 MiB |
BIN
frontend/public/ships/440274000.jpg
Normal file
|
After Width: | Height: | 크기: 70 KiB |
BIN
frontend/public/ships/440323000.jpg
Normal file
|
After Width: | Height: | 크기: 98 KiB |
BIN
frontend/public/ships/440384000.jpg
Normal file
|
After Width: | Height: | 크기: 107 KiB |
BIN
frontend/public/ships/440880000.jpg
Normal file
|
After Width: | Height: | 크기: 114 KiB |
BIN
frontend/public/ships/441046000.jpg
Normal file
|
After Width: | Height: | 크기: 112 KiB |
BIN
frontend/public/ships/441345000.jpg
Normal file
|
After Width: | Height: | 크기: 112 KiB |
BIN
frontend/public/ships/441345000.png
Normal file
|
After Width: | Height: | 크기: 1.3 MiB |
BIN
frontend/public/ships/441353000.jpg
Normal file
|
After Width: | Height: | 크기: 123 KiB |
BIN
frontend/public/ships/441393000.jpg
Normal file
|
After Width: | Height: | 크기: 226 KiB |
BIN
frontend/public/ships/441423000.jpg
Normal file
|
After Width: | Height: | 크기: 119 KiB |
BIN
frontend/public/ships/441548000.jpg
Normal file
|
After Width: | Height: | 크기: 146 KiB |
BIN
frontend/public/ships/441708000.png
Normal file
|
After Width: | Height: | 크기: 2.4 MiB |
BIN
frontend/public/ships/441866000.jpg
Normal file
|
After Width: | Height: | 크기: 123 KiB |
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | 크기: 1.5 KiB |
1990
frontend/src/App.css
Normal file
1203
frontend/src/App.tsx
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | 크기: 4.0 KiB |
273
frontend/src/components/AircraftLayer.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Aircraft, AircraftCategory } from '../types';
|
||||
|
||||
interface Props {
|
||||
aircraft: Aircraft[];
|
||||
militaryOnly: boolean;
|
||||
}
|
||||
|
||||
// ═══ tar1090 / Airplanes.live style SVG icons ═══
|
||||
const SHAPES: Record<string, { viewBox: string; w: number; h: number; path: string }> = {
|
||||
airliner: {
|
||||
viewBox: '-1 -2 34 34', w: 24, h: 24,
|
||||
path: 'M16 1c-.17 0-.67.58-.9 1.03-.6 1.21-.6 1.15-.65 5.2-.04 2.97-.08 3.77-.18 3.9-.15.17-1.82 1.1-1.98 1.1-.08 0-.1-.25-.05-.83.03-.5.01-.92-.05-1.08-.1-.25-.13-.26-.71-.26-.82 0-.86.07-.78 1.5.03.6.08 1.17.11 1.25.05.12-.02.2-.25.33l-8 4.2c-.2.2-.18.1-.19 1.29 3.9-1.2 3.71-1.21 3.93-1.21.06 0 .1 0 .13.14.08.3.28.3.28-.04 0-.25.03-.27 1.16-.6.65-.2 1.22-.35 1.28-.35.05 0 .12.04.15.17.07.3.27.27.27-.08 0-.25.01-.27.7-.47.68-.1.98-.09 1.47-.1.18 0 .22 0 .26.18.06.34.22.35.27-.01.04-.2.1-.17 1.06-.14l1.07.02.05 4.2c.05 3.84.07 4.28.26 5.09.11.49.2.99.2 1.11 0 .19-.31.43-1.93 1.5l-1.93 1.26v1.02l4.13-.95.63 1.54c.05.07.12.09.19.09s.14-.02.19-.09l.63-1.54 4.13.95V29.3l-1.93-1.27c-1.62-1.06-1.93-1.3-1.93-1.49 0-.12.09-.62.2-1.11.19-.81.2-1.25.26-5.09l.05-4.2 1.07-.02c.96-.03 1.02-.05 1.06.14.05.36.21.35.27 0 .04-.17.08-.16.26-.16.49 0 .8-.02 1.48.1.68.2.69.21.69.46 0 .35.2.38.27.08.03-.13.1-.17.15-.17.06 0 .63.15 1.28.34 1.13.34 1.16.36 1.16.61 0 .35.2.34.28.04.03-.13.07-.14.13-.14.22 0 .03 0 3.93 1.2-.01-1.18.02-1.07-.19-1.27l-8-4.21c-.23-.12-.3-.21-.25-.33.03-.08.08-.65.11-1.25.08-1.43.04-1.5-.78-1.5-.58 0-.61.01-.71.26-.06.16-.08.58-.05 1.08.04.58.03.83-.05.83-.16 0-1.83-.93-1.98-1.1-.1-.13-.14-.93-.18-3.9-.05-4.05-.05-3.99-.65-5.2C16.67 1.58 16.17 1 16 1z',
|
||||
},
|
||||
hi_perf: {
|
||||
viewBox: '-7.8 0 80 80', w: 24, h: 24,
|
||||
path: 'M 30.82,61.32 29.19,54.84 29.06,60.19 27.70,60.70 22.27,60.63 21.68,59.60 l -0.01,-2.71 6.26,-5.52 -0.03,-3.99 -13.35,-0.01 -3e-6,1.15 -1.94,0.00 -0.01,-1.31 0.68,-0.65 L 13.30,37.20 c -0.01,-0.71 0.57,-0.77 0.60,0 l 0.05,1.57 0.28,0.23 0.26,4.09 L 19.90,38.48 c 0,0 -0.04,-1.26 0.20,-1.28 0.16,-0.02 0.20,0.98 0.20,0.98 l 4.40,-3.70 c 0,0 0.02,-1.28 0.20,-1.28 0.14,-0.00 0.20,0.98 0.20,0.98 l 1.80,-1.54 C 27.02,28.77 28.82,25.58 29,21.20 c 0.06,-1.41 0.23,-3.34 0.86,-3.85 0.21,-4.40 1.32,-11.03 2.39,-11.03 1.07,0 2.17,6.64 2.39,11.03 0.63,0.51 0.80,2.45 0.86,3.85 0.18,4.38 1.98,7.57 2.10,11.44 l 1.80,1.54 c 0,0 0.06,-0.99 0.20,-0.98 0.18,0.01 0.20,1.28 0.20,1.28 l 4.40,3.70 c 0,0 0.04,-1.00 0.20,-0.98 0.24,0.03 0.20,1.28 0.20,1.28 l 5.41,4.60 0.26,-4.09 0.28,-0.23 L 50.59,37.20 c 0.03,-0.77 0.61,-0.71 0.60,0 l 0.02,9.37 0.68,0.65 -0.01,1.31 -1.94,-0.00 -3e-6,-1.15 -13.35,0.01 -0.03,3.99 6.26,5.52 L 42.81,59.60 42.22,60.63 36.79,60.70 35.43,60.19 35.30,54.84 33.67,61.32 Z',
|
||||
},
|
||||
jet_nonSweep: {
|
||||
viewBox: '-2 -2.4 22 22', w: 18, h: 18,
|
||||
path: 'M9,17.09l-3.51.61v-.3c0-.65.11-1,.33-1.09L8.5,15a5.61,5.61,0,0,1-.28-1.32l-.53-.41-.1-.69H7.12l0-.21a7.19,7.19,0,0,1-.15-2.19L.24,9.05V8.84c0-1.1.51-1.15.61-1.15L7.8,7.18V2.88C7.8.64,8.89.3,8.93.28L9,.26l.07,0s1.13.36,1.13,2.6v4.3l7,.51c.09,0,.59.06.59,1.15v.21l-6.69,1.16a7.17,7.17,0,0,1-.15,2.19l0,.21h-.47l-.1.69-.53.41A5.61,5.61,0,0,1,9.5,15l2.74,1.28c.2.07.31.43.31,1.08v.3Z',
|
||||
},
|
||||
heavy_2e: {
|
||||
viewBox: '0 -3.2 64.2 64.2', w: 26, h: 26,
|
||||
path: 'm 31.414,2.728 c -0.314,0.712 -1.296,2.377 -1.534,6.133 l -0.086,13.379 c 0.006,0.400 -0.380,0.888 -0.945,1.252 l -2.631,1.729 c 0.157,-0.904 0.237,-3.403 -0.162,-3.850 l -2.686,0.006 c -0.336,1.065 -0.358,2.518 -0.109,4.088 h 0.434 L 24.057,26.689 8.611,36.852 7.418,38.432 7.381,39.027 8.875,38.166 l 8.295,-2.771 0.072,0.730 0.156,-0.004 0.150,-0.859 3.799,-1.234 0.074,0.727 0.119,0.004 0.117,-0.832 2.182,-0.730 h 1.670 l 0.061,0.822 h 0.176 l 0.062,-0.822 4.018,-0.002 v 13.602 c 0.051,1.559 0.465,3.272 0.826,4.963 l -6.836,5.426 c -0.097,0.802 -0.003,1.372 0.049,1.885 l 7.734,-2.795 0.477,1.973 h 0.232 l 0.477,-1.973 7.736,2.795 c 0.052,-0.513 0.146,-1.083 0.049,-1.885 l -6.836,-5.426 c 0.361,-1.691 0.775,-3.404 0.826,-4.963 V 33.193 l 4.016,0.002 0.062,0.822 h 0.178 L 38.875,33.195 h 1.672 l 2.182,0.730 0.117,0.832 0.119,-0.004 0.072,-0.727 3.799,1.234 0.152,0.859 0.154,0.004 0.072,-0.730 8.297,2.771 1.492,0.861 -0.037,-0.596 -1.191,-1.580 -15.447,-10.162 0.363,-1.225 H 41.125 c 0.248,-1.569 0.225,-3.023 -0.111,-4.088 l -2.686,-0.006 c -0.399,0.447 -0.317,2.945 -0.160,3.850 L 35.535,23.492 C 34.970,23.128 34.584,22.640 34.590,22.240 L 34.504,8.910 C 34.193,4.926 33.369,3.602 32.934,2.722 32.442,1.732 31.894,1.828 31.414,2.728 Z',
|
||||
},
|
||||
helicopter: {
|
||||
viewBox: '-13 -13 90 90', w: 22, h: 22,
|
||||
path: 'm 24.698,60.712 c 0,0 -0.450,2.134 -0.861,2.142 -0.561,0.011 -0.480,-3.836 -0.593,-5.761 -0.064,-1.098 1.381,-1.192 1.481,-0.042 l 5.464,0.007 -0.068,-9.482 -0.104,-1.108 c -2.410,-2.131 -3.028,-3.449 -3.152,-7.083 l -12.460,13.179 c -0.773,0.813 -2.977,0.599 -3.483,-0.428 L 26.920,35.416 26.866,29.159 11.471,14.513 c -0.813,-0.773 -0.599,-2.977 0.428,-3.483 l 14.971,14.428 0.150,-5.614 c -0.042,-1.324 1.075,-4.784 3.391,-5.633 0.686,-0.251 2.131,-0.293 3.033,0.008 2.349,0.783 3.433,4.309 3.391,5.633 l 0.073,4.400 12.573,-12.763 c 0.779,-0.807 2.977,-0.599 3.483,0.428 L 37.054,28.325 37.027,35.027 52.411,49.365 c 0.813,0.773 0.599,2.977 -0.428,3.483 L 36.992,38.359 c -0.124,3.634 -0.742,5.987 -3.152,8.118 l -0.104,1.108 -0.068,9.482 5.321,-0.068 c 0.101,-1.150 1.546,-1.057 1.481,0.042 -0.113,1.925 -0.032,5.772 -0.593,5.761 -0.412,-0.008 -0.861,-2.142 -0.861,-2.142 l -5.387,-0.011 0.085,9.377 -1.094,2.059 -1.386,-0.018 -1.093,-2.049 0.085,-9.377 z',
|
||||
},
|
||||
cessna: {
|
||||
viewBox: '0 -1 32 31', w: 20, h: 20,
|
||||
path: 'M16.36 20.96l2.57.27s.44.05.4.54l-.02.63s-.03.47-.45.54l-2.31.34-.44-.74-.22 1.63-.25-1.62-.38.73-2.35-.35s-.44-.1-.43-.6l-.02-.6s0-.5.48-.5l2.5-.27-.56-5.4-3.64-.1-5.83-1.02h-.45v-2.06s-.07-.37.46-.34l5.8-.17 3.55.12s-.1-2.52.52-2.82l-1.68-.04s-.1-.06 0-.14l1.94-.03s.35-1.18.7 0l1.91.04s.11.05 0 .14l-1.7.02s.62-.09.56 2.82l3.54-.1 5.81.17s.51-.04.48.35l-.01 2.06h-.47l-5.8 1-3.67.11z',
|
||||
},
|
||||
ground: {
|
||||
viewBox: '0 0 24 24', w: 12, h: 12,
|
||||
path: 'M12 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
|
||||
},
|
||||
};
|
||||
|
||||
function getShape(ac: Aircraft) {
|
||||
if (ac.onGround) return SHAPES.ground;
|
||||
switch (ac.category) {
|
||||
case 'fighter': case 'military': return SHAPES.hi_perf;
|
||||
case 'tanker': case 'surveillance': case 'cargo': return SHAPES.heavy_2e;
|
||||
case 'civilian': return SHAPES.airliner;
|
||||
default: return SHAPES.jet_nonSweep;
|
||||
}
|
||||
}
|
||||
|
||||
const ALT_COLORS: [number, string][] = [
|
||||
[0, '#00c000'], [150, '#2AD62A'], [300, '#55EC55'], [600, '#7CFC00'],
|
||||
[1200, '#BFFF00'], [1800, '#FFFF00'], [3000, '#FFD700'], [6000, '#FF8C00'],
|
||||
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
|
||||
];
|
||||
|
||||
const MIL_HEX: Partial<Record<AircraftCategory, string>> = {
|
||||
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
|
||||
};
|
||||
|
||||
function getAltitudeColor(altMeters: number): string {
|
||||
if (altMeters <= 0) return ALT_COLORS[0][1];
|
||||
for (let i = ALT_COLORS.length - 1; i >= 0; i--) {
|
||||
if (altMeters >= ALT_COLORS[i][0]) return ALT_COLORS[i][1];
|
||||
}
|
||||
return ALT_COLORS[0][1];
|
||||
}
|
||||
|
||||
function getAircraftColor(ac: Aircraft): string {
|
||||
const milColor = MIL_HEX[ac.category];
|
||||
if (milColor) return milColor;
|
||||
if (ac.onGround) return '#555555';
|
||||
return getAltitudeColor(ac.altitude);
|
||||
}
|
||||
|
||||
// ═══ Planespotters.net photo API ═══
|
||||
interface PhotoResult { url: string; photographer: string; link: string; }
|
||||
const photoCache = new Map<string, PhotoResult | null>();
|
||||
|
||||
function AircraftPhoto({ hex }: { hex: string }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
||||
photoCache.has(hex) ? photoCache.get(hex) : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (photo !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`https://api.planespotters.net/pub/photos/hex/${hex}`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (data.photos && data.photos.length > 0) {
|
||||
const p = data.photos[0];
|
||||
const result: PhotoResult = {
|
||||
url: p.thumbnail_large?.src || p.thumbnail?.src || '',
|
||||
photographer: p.photographer || '',
|
||||
link: p.link || '',
|
||||
};
|
||||
photoCache.set(hex, result);
|
||||
setPhoto(result);
|
||||
} else {
|
||||
photoCache.set(hex, null);
|
||||
setPhoto(null);
|
||||
}
|
||||
} catch {
|
||||
photoCache.set(hex, null);
|
||||
setPhoto(null);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [hex, photo]);
|
||||
|
||||
if (photo === undefined) {
|
||||
return <div className="text-center p-2 text-kcg-muted text-[10px]">{t('aircraftPopup.loadingPhoto')}</div>;
|
||||
}
|
||||
if (!photo) return null;
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
||||
<img src={photo.url} alt="Aircraft"
|
||||
className="w-full rounded block"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</a>
|
||||
{photo.photographer && (
|
||||
<div className="text-[9px] text-[#999] mt-0.5 text-right">
|
||||
© {photo.photographer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══ Main layer ═══
|
||||
export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
||||
const filtered = useMemo(() => {
|
||||
if (militaryOnly) {
|
||||
return aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown');
|
||||
}
|
||||
return aircraft;
|
||||
}, [aircraft, militaryOnly]);
|
||||
|
||||
// Aircraft trails as GeoJSON
|
||||
const trailData = useMemo(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: filtered
|
||||
.filter(ac => ac.trail && ac.trail.length > 1)
|
||||
.map(ac => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { color: getAircraftColor(ac) },
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: ac.trail!.map(([lat, lng]) => [lng, lat]),
|
||||
},
|
||||
})),
|
||||
}), [filtered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trailData.features.length > 0 && (
|
||||
<Source id="aircraft-trails" type="geojson" data={trailData}>
|
||||
<Layer
|
||||
id="aircraft-trail-lines"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.4,
|
||||
'line-dasharray': [4, 4],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
{filtered.map(ac => (
|
||||
<AircraftMarker key={ac.icao24} ac={ac} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══ Aircraft Marker ═══
|
||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const color = getAircraftColor(ac);
|
||||
const shape = getShape(ac);
|
||||
const size = shape.w;
|
||||
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
|
||||
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
width: size, height: size,
|
||||
transform: `rotate(${ac.heading}deg)`,
|
||||
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
||||
>
|
||||
<svg viewBox={shape.viewBox} width={size} height={size}
|
||||
fill={color} stroke="#000" strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round" opacity={0.95}>
|
||||
<path d={shape.path} />
|
||||
</svg>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<div className="gl-marker-label" style={{ color }}>
|
||||
{ac.callsign || ac.icao24}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
{showPopup && (
|
||||
<Popup longitude={ac.lng} latitude={ac.lat}
|
||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
|
||||
style={{ background: color }}
|
||||
>
|
||||
{t(`aircraftLabel.${ac.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
<AircraftPhoto hex={ac.icao24} />
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<tbody>
|
||||
<tr><td className="text-kcg-muted pr-2">{t('aircraftPopup.hex')}</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
||||
{ac.registration && <tr><td className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
|
||||
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
|
||||
{ac.typecode && (
|
||||
<tr><td className="text-kcg-muted">{t('aircraftPopup.type')}</td>
|
||||
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
||||
)}
|
||||
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
|
||||
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
|
||||
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}°</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1.5 text-[10px] text-right">
|
||||
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
||||
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||
Airplanes.live →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
const a = prev.ac, b = next.ac;
|
||||
return a.icao24 === b.icao24 &&
|
||||
Math.abs(a.lat - b.lat) < 0.001 &&
|
||||
Math.abs(a.lng - b.lng) < 0.001 &&
|
||||
Math.round(a.heading / 10) === Math.round(b.heading / 10) &&
|
||||
a.category === b.category &&
|
||||
Math.round(a.altitude / 500) === Math.round(b.altitude / 500);
|
||||
});
|
||||
138
frontend/src/components/AirportLayer.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import type { Airport } from '../data/airports';
|
||||
|
||||
const US_BASE_ICAOS = new Set([
|
||||
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
|
||||
]);
|
||||
|
||||
function isUSBase(airport: Airport): boolean {
|
||||
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
|
||||
}
|
||||
|
||||
const FLAG_EMOJI: Record<string, string> = {
|
||||
IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}',
|
||||
AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}',
|
||||
BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}',
|
||||
TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}',
|
||||
SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}',
|
||||
DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<Airport['type'], string> = {
|
||||
large: 'International Airport', medium: 'Airport',
|
||||
small: 'Regional Airport', military: 'Military Airbase',
|
||||
};
|
||||
|
||||
interface Props { airports: Airport[]; }
|
||||
|
||||
const TYPE_PRIORITY: Record<Airport['type'], number> = {
|
||||
military: 3, large: 2, medium: 1, small: 0,
|
||||
};
|
||||
|
||||
// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small.
|
||||
function deduplicateByArea(airports: Airport[]): Airport[] {
|
||||
const sorted = [...airports].sort((a, b) => {
|
||||
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
|
||||
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
|
||||
return pb - pa;
|
||||
});
|
||||
const kept: Airport[] = [];
|
||||
for (const ap of sorted) {
|
||||
const tooClose = kept.some(
|
||||
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
|
||||
);
|
||||
if (!tooClose) kept.push(ap);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
export const AirportLayer = memo(function AirportLayer({ airports }: Props) {
|
||||
const filtered = useMemo(() => deduplicateByArea(airports), [airports]);
|
||||
return (
|
||||
<>
|
||||
{filtered.map(ap => (
|
||||
<AirportMarker key={ap.icao} airport={ap} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function AirportMarker({ airport }: { airport: Airport }) {
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const isMil = airport.type === 'military';
|
||||
const isUS = isUSBase(airport);
|
||||
const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b';
|
||||
const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16;
|
||||
const flag = FLAG_EMOJI[airport.country] || '';
|
||||
|
||||
// Single circle with airplane inside (plane shifted down to center in circle)
|
||||
const plane = isMil
|
||||
? <path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z"
|
||||
fill={color} />
|
||||
: <path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z"
|
||||
fill={color} />;
|
||||
const icon = (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size}>
|
||||
<circle cx={12} cy={12} r={10} fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth={2} />
|
||||
{plane}
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker longitude={airport.lng} latitude={airport.lat} anchor="center">
|
||||
<div style={{ width: size, height: size, cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
||||
{icon}
|
||||
</div>
|
||||
</Marker>
|
||||
{showPopup && (
|
||||
<Popup longitude={airport.lng} latitude={airport.lat}
|
||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
|
||||
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e',
|
||||
color: '#fff', padding: '6px 10px', borderRadius: '4px 4px 0 0',
|
||||
margin: '-10px -10px 8px -10px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
|
||||
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
|
||||
<strong style={{ fontSize: 13, flex: 1 }}>{airport.name}</strong>
|
||||
</div>
|
||||
{airport.nameKo && (
|
||||
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
|
||||
)}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{
|
||||
background: color, color: isUS || isMil ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px' }}>
|
||||
{airport.iata && <div><span style={{ color: '#888' }}>IATA : </span><strong>{airport.iata}</strong></div>}
|
||||
<div><span style={{ color: '#888' }}>ICAO : </span><strong>{airport.icao}</strong></div>
|
||||
{airport.city && <div><span style={{ color: '#888' }}>City : </span>{airport.city}</div>}
|
||||
<div><span style={{ color: '#888' }}>Country : </span>{airport.country}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}
|
||||
</div>
|
||||
{airport.iata && (
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${airport.iata.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
frontend/src/components/CctvLayer.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Hls from 'hls.js';
|
||||
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
|
||||
import type { CctvCamera } from '../services/cctv';
|
||||
|
||||
const REGION_COLOR: Record<string, string> = {
|
||||
'제주': '#ff6b6b',
|
||||
'남해': '#ffa94d',
|
||||
'서해': '#69db7c',
|
||||
'동해': '#74c0fc',
|
||||
};
|
||||
|
||||
/** KHOA HLS → vite 프록시 경유 */
|
||||
function toProxyUrl(cam: CctvCamera): string {
|
||||
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
|
||||
}
|
||||
|
||||
export function CctvLayer() {
|
||||
const { t } = useTranslation('ships');
|
||||
const [selected, setSelected] = useState<CctvCamera | null>(null);
|
||||
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{KOREA_CCTV_CAMERAS.map(cam => {
|
||||
const color = REGION_COLOR[cam.region] || '#aaa';
|
||||
return (
|
||||
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
|
||||
<div
|
||||
className="relative cursor-pointer flex flex-col items-center"
|
||||
style={{ filter: `drop-shadow(0 0 2px ${color}88)` }}
|
||||
>
|
||||
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
|
||||
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
|
||||
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
|
||||
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
<div
|
||||
className="text-[6px] text-white mt-0 whitespace-nowrap font-bold tracking-wide"
|
||||
style={{ textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000` }}
|
||||
>
|
||||
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="font-mono text-xs min-w-[200px]">
|
||||
<div
|
||||
className="px-2 py-1 rounded-t font-bold text-[13px] flex items-center gap-1.5 -mx-2.5 -mt-2.5 mb-2 text-black"
|
||||
style={{ background: REGION_COLOR[selected.region] || '#888' }}
|
||||
>
|
||||
<span>📹</span> {selected.name}
|
||||
</div>
|
||||
<div className="flex gap-1 mb-1.5 flex-wrap">
|
||||
<span className="bg-kcg-success text-white px-1.5 py-px rounded text-[10px] font-bold">
|
||||
● {t('cctv.live')}
|
||||
</span>
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
|
||||
style={{ background: REGION_COLOR[selected.region] || '#888' }}
|
||||
>{selected.region}</span>
|
||||
<span className="bg-kcg-border text-kcg-text-secondary px-1.5 py-px rounded text-[10px]">
|
||||
{t(`cctv.type.${selected.type}`, { defaultValue: selected.type })}
|
||||
</span>
|
||||
<span className="bg-kcg-card text-kcg-muted px-1.5 py-px rounded text-[10px]">
|
||||
{t('cctv.khoa')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] flex flex-col gap-0.5">
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setStreamCam(selected); setSelected(null); }}
|
||||
className="inline-flex items-center justify-center gap-1 bg-kcg-accent text-white px-2.5 py-1 rounded text-[11px] font-bold mt-1 border-none cursor-pointer font-mono"
|
||||
>
|
||||
📺 {t('cctv.viewStream')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{/* CCTV HLS Stream Modal */}
|
||||
{streamCam && (
|
||||
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
|
||||
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||
|
||||
const destroyHls = useCallback(() => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const proxied = toProxyUrl(cam);
|
||||
setStatus('loading');
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
destroyHls();
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 30,
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(proxied);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setStatus('playing');
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) setStatus('error');
|
||||
});
|
||||
return () => destroyHls();
|
||||
}
|
||||
|
||||
// Safari 네이티브 HLS
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = proxied;
|
||||
const onLoaded = () => setStatus('playing');
|
||||
const onError = () => setStatus('error');
|
||||
video.addEventListener('loadeddata', onLoaded);
|
||||
video.addEventListener('error', onError);
|
||||
video.play().catch(() => {});
|
||||
return () => {
|
||||
video.removeEventListener('loadeddata', onLoaded);
|
||||
video.removeEventListener('error', onError);
|
||||
};
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
return () => destroyHls();
|
||||
}, [cam, destroyHls]);
|
||||
|
||||
const color = REGION_COLOR[cam.region] || '#888';
|
||||
|
||||
return (
|
||||
/* Backdrop */
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center backdrop-blur-sm"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
{/* Modal */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="w-[640px] max-w-[90vw] bg-kcg-bg rounded-lg overflow-hidden"
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3.5 py-2 bg-kcg-overlay border-b border-[#222]">
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-kcg-text">
|
||||
<span
|
||||
className="text-white px-1.5 py-px rounded text-[9px] font-bold"
|
||||
style={{
|
||||
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
|
||||
}}
|
||||
>
|
||||
● {status === 'playing' ? t('cctv.live') : status === 'loading' ? t('cctv.connecting') : 'ERROR'}
|
||||
</span>
|
||||
<span className="font-bold">📹 {cam.name}</span>
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[9px] font-bold text-black"
|
||||
style={{ background: color }}
|
||||
>{cam.region}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-kcg-border border-none text-white w-6 h-6 rounded cursor-pointer text-sm font-bold flex items-center justify-center"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
<div className="relative w-full bg-black" style={{ aspectRatio: '16/9' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
muted autoPlay playsInline
|
||||
/>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||||
<div className="text-[28px] opacity-40 mb-2">📹</div>
|
||||
<div className="text-[11px] text-kcg-muted font-mono">{t('cctv.connectingEllipsis')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||||
<div className="text-[28px] opacity-40 mb-2">⚠️</div>
|
||||
<div className="text-xs text-kcg-danger font-mono mb-2">{t('cctv.connectionFailed')}</div>
|
||||
<a
|
||||
href={cam.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-kcg-accent font-mono underline"
|
||||
>{t('cctv.viewOnBadatime')}</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'playing' && (
|
||||
<>
|
||||
<div className="absolute top-2.5 left-2.5 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-bold font-mono px-2 py-0.5 rounded bg-black/70 text-white">
|
||||
{cam.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-[rgba(239,68,68,0.3)] text-[#f87171]">
|
||||
● {t('cctv.rec')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-2.5 left-2.5 text-[9px] font-mono px-2 py-0.5 rounded bg-black/70 text-kcg-muted">
|
||||
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="flex items-center justify-between px-3.5 py-1.5 bg-kcg-overlay border-t border-[#222] font-mono text-[9px] text-kcg-dim">
|
||||
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
|
||||
<span>{t('cctv.khoaFull')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/components/CoastGuardLayer.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
|
||||
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
|
||||
|
||||
const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
hq: '#ff6b6b',
|
||||
regional: '#ffa94d',
|
||||
station: '#4dabf7',
|
||||
substation: '#69db7c',
|
||||
vts: '#da77f2',
|
||||
};
|
||||
|
||||
const TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
hq: 24,
|
||||
regional: 20,
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
};
|
||||
|
||||
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
|
||||
function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) {
|
||||
const color = TYPE_COLOR[type];
|
||||
const isVts = type === 'vts';
|
||||
|
||||
if (isVts) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
|
||||
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
|
||||
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
|
||||
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
|
||||
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
|
||||
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
||||
{(type === 'hq' || type === 'regional') && (
|
||||
<circle cx="12" cy="9" r="1" fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoastGuardLayer() {
|
||||
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{COAST_GUARD_FACILITIES.map(f => {
|
||||
const size = TYPE_SIZE[f.type];
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<CoastGuardIcon type={f.type} size={size} />
|
||||
{(f.type === 'hq' || f.type === 'regional') && (
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold text-white">
|
||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'vts' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
|
||||
VTS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="min-w-[200px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||
{selected.name}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
frontend/src/components/DamagedShipLayer.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { damagedShips } from '../data/damagedShips';
|
||||
import type { DamagedShip } from '../data/damagedShips';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
const FLAG_EMOJI: Record<string, string> = {
|
||||
GR: '\u{1F1EC}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', KR: '\u{1F1F0}\u{1F1F7}',
|
||||
IR: '\u{1F1EE}\u{1F1F7}', US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}',
|
||||
PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', CN: '\u{1F1E8}\u{1F1F3}',
|
||||
};
|
||||
|
||||
const DAMAGE_COLORS: Record<DamagedShip['damage'], string> = {
|
||||
sunk: '#ff0000',
|
||||
severe: '#ef4444',
|
||||
moderate: '#f97316',
|
||||
minor: '#eab308',
|
||||
};
|
||||
|
||||
const DAMAGE_LABELS: Record<DamagedShip['damage'], string> = {
|
||||
sunk: '침몰',
|
||||
severe: '중파',
|
||||
moderate: '중손',
|
||||
minor: '경미',
|
||||
};
|
||||
|
||||
const KST_OFFSET = 9 * 3600_000;
|
||||
|
||||
function formatKST(ts: number): string {
|
||||
const d = new Date(ts + KST_OFFSET);
|
||||
return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} KST`;
|
||||
}
|
||||
|
||||
export function DamagedShipLayer({ currentTime }: Props) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const visible = useMemo(
|
||||
() => damagedShips.filter(s => currentTime >= s.damagedAt),
|
||||
[currentTime],
|
||||
);
|
||||
|
||||
const selected = selectedId ? visible.find(s => s.id === selectedId) ?? null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map(ship => {
|
||||
const color = DAMAGE_COLORS[ship.damage];
|
||||
const isSunk = ship.damage === 'sunk';
|
||||
const ageH = (currentTime - ship.damagedAt) / 3600_000;
|
||||
const isRecent = ageH <= 24;
|
||||
const size = isRecent ? 28 : 22;
|
||||
const c = size / 2;
|
||||
|
||||
return (
|
||||
<Marker key={ship.id} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedId(ship.id); }}
|
||||
>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{/* outer ring */}
|
||||
<circle cx={c} cy={c} r={c - 2} fill="none" stroke={color}
|
||||
strokeWidth={isRecent ? 2.5 : 1.5} opacity={isRecent ? 0.9 : 0.5}
|
||||
strokeDasharray={isSunk ? 'none' : '3 2'}
|
||||
/>
|
||||
{/* ship icon (simplified) */}
|
||||
<path
|
||||
d={`M${c} ${c * 0.35} L${c * 1.45} ${c * 1.3} L${c * 1.25} ${c * 1.55} L${c * 0.75} ${c * 1.55} L${c * 0.55} ${c * 1.3} Z`}
|
||||
fill={color} fillOpacity={isRecent ? 0.8 : 0.4}
|
||||
/>
|
||||
{/* X mark for damage */}
|
||||
<line x1={c * 0.55} y1={c * 0.55} x2={c * 1.45} y2={c * 1.45}
|
||||
stroke="#fff" strokeWidth={2} opacity={0.9} />
|
||||
<line x1={c * 1.45} y1={c * 0.55} x2={c * 0.55} y2={c * 1.45}
|
||||
stroke="#fff" strokeWidth={2} opacity={0.9} />
|
||||
{/* inner X in color */}
|
||||
<line x1={c * 0.6} y1={c * 0.6} x2={c * 1.4} y2={c * 1.4}
|
||||
stroke={color} strokeWidth={1.2} />
|
||||
<line x1={c * 1.4} y1={c * 0.6} x2={c * 0.6} y2={c * 1.4}
|
||||
stroke={color} strokeWidth={1.2} />
|
||||
</svg>
|
||||
{/* label */}
|
||||
<div style={{
|
||||
position: 'absolute', top: size, left: '50%', transform: 'translateX(-50%)',
|
||||
whiteSpace: 'nowrap', fontSize: 9, fontWeight: 700,
|
||||
color, textShadow: '0 0 3px #000, 0 0 6px #000',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{isRecent && <span style={{
|
||||
background: color, color: '#000', padding: '0 3px',
|
||||
borderRadius: 2, marginRight: 3, fontSize: 8,
|
||||
}}>NEW</span>}
|
||||
{ship.name}
|
||||
</div>
|
||||
{/* pulse for recent */}
|
||||
{isRecent && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: size, height: size,
|
||||
borderRadius: '50%', border: `2px solid ${color}`,
|
||||
animation: 'damaged-ship-pulse 2s ease-out infinite',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelectedId(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div style={{ minWidth: 260, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: DAMAGE_COLORS[selected.damage], color: '#fff',
|
||||
padding: '6px 10px', borderRadius: '4px 4px 0 0',
|
||||
margin: '-10px -10px 8px -10px',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
{FLAG_EMOJI[selected.flag] && <span style={{ fontSize: 16 }}>{FLAG_EMOJI[selected.flag]}</span>}
|
||||
<strong style={{ flex: 1 }}>{selected.name}</strong>
|
||||
<span style={{
|
||||
background: 'rgba(0,0,0,0.3)', padding: '1px 6px',
|
||||
borderRadius: 3, fontSize: 10,
|
||||
}}>{DAMAGE_LABELS[selected.damage]}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 12px', fontSize: 11 }}>
|
||||
<div><span style={{ color: '#888' }}>선종 : </span>{selected.type}</div>
|
||||
<div><span style={{ color: '#888' }}>국적 : </span>{selected.flag}</div>
|
||||
<div><span style={{ color: '#888' }}>원인 : </span>{selected.cause}</div>
|
||||
<div><span style={{ color: '#888' }}>피격 : </span>{formatKST(selected.damagedAt)}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes damaged-ship-pulse {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/EezLayer.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { KOREA_EEZ_BOUNDARY, KOREA_CHINA_PMZ, NLL_WEST_SEA, NLL_EAST_SEA } from '../services/koreaEez';
|
||||
import type { FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl';
|
||||
|
||||
// Convert [lat, lng][] to GeoJSON [lng, lat][] ring
|
||||
function toRing(coords: [number, number][]): [number, number][] {
|
||||
return coords.map(([lat, lng]) => [lng, lat]);
|
||||
}
|
||||
|
||||
function toLineCoords(coords: [number, number][]): [number, number][] {
|
||||
return coords.map(([lat, lng]) => [lng, lat]);
|
||||
}
|
||||
|
||||
const eezGeoJSON: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
// EEZ 경계 폴리곤
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'eez' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [toRing(KOREA_EEZ_BOUNDARY)],
|
||||
},
|
||||
},
|
||||
// 한중 잠정조치수역
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'pmz' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [toRing(KOREA_CHINA_PMZ)],
|
||||
},
|
||||
},
|
||||
// 서해 NLL
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'nll' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: toLineCoords(NLL_WEST_SEA),
|
||||
},
|
||||
},
|
||||
// 동해 NLL
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'nll' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: toLineCoords(NLL_EAST_SEA),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eezFillStyle: FillLayerSpecification = {
|
||||
id: 'eez-fill',
|
||||
type: 'fill',
|
||||
source: 'eez-source',
|
||||
filter: ['==', ['get', 'type'], 'eez'],
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.06,
|
||||
},
|
||||
};
|
||||
|
||||
const eezLineStyle: LineLayerSpecification = {
|
||||
id: 'eez-line',
|
||||
type: 'line',
|
||||
source: 'eez-source',
|
||||
filter: ['==', ['get', 'type'], 'eez'],
|
||||
paint: {
|
||||
'line-color': '#3b82f6',
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 3],
|
||||
'line-opacity': 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
const pmzFillStyle: FillLayerSpecification = {
|
||||
id: 'pmz-fill',
|
||||
type: 'fill',
|
||||
source: 'eez-source',
|
||||
filter: ['==', ['get', 'type'], 'pmz'],
|
||||
paint: {
|
||||
'fill-color': '#eab308',
|
||||
'fill-opacity': 0.08,
|
||||
},
|
||||
};
|
||||
|
||||
const pmzLineStyle: LineLayerSpecification = {
|
||||
id: 'pmz-line',
|
||||
type: 'line',
|
||||
source: 'eez-source',
|
||||
filter: ['==', ['get', 'type'], 'pmz'],
|
||||
paint: {
|
||||
'line-color': '#eab308',
|
||||
'line-width': 1.2,
|
||||
'line-dasharray': [3, 2],
|
||||
'line-opacity': 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
const nllLineStyle: LineLayerSpecification = {
|
||||
id: 'nll-line',
|
||||
type: 'line',
|
||||
source: 'eez-source',
|
||||
filter: ['==', ['get', 'type'], 'nll'],
|
||||
paint: {
|
||||
'line-color': '#ef4444',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [6, 4],
|
||||
'line-opacity': 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
export function EezLayer() {
|
||||
return (
|
||||
<Source id="eez-source" type="geojson" data={eezGeoJSON}>
|
||||
<Layer {...eezFillStyle} />
|
||||
<Layer {...eezLineStyle} />
|
||||
<Layer {...pmzFillStyle} />
|
||||
<Layer {...pmzLineStyle} />
|
||||
<Layer {...nllLineStyle} />
|
||||
</Source>
|
||||
);
|
||||
}
|
||||
753
frontend/src/components/EventLog.tsx
Normal file
@ -0,0 +1,753 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GeoEvent, Ship } from '../types';
|
||||
import type { OsintItem } from '../services/osint';
|
||||
|
||||
type DashboardTab = 'iran' | 'korea';
|
||||
|
||||
interface Props {
|
||||
events: GeoEvent[];
|
||||
currentTime: number;
|
||||
totalShipCount: number;
|
||||
koreanShips: Ship[];
|
||||
koreanShipsByCategory: Record<string, number>;
|
||||
chineseShips?: Ship[];
|
||||
osintFeed?: OsintItem[];
|
||||
isLive?: boolean;
|
||||
dashboardTab?: DashboardTab;
|
||||
onTabChange?: (tab: DashboardTab) => void;
|
||||
ships?: Ship[];
|
||||
}
|
||||
|
||||
// ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══
|
||||
interface BreakingNews {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
category: 'trump' | 'oil' | 'diplomacy' | 'economy';
|
||||
headline: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime();
|
||||
const HOUR_MS = 3600_000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const _MIN_MS = 60_000;
|
||||
|
||||
const BREAKING_NEWS: BreakingNews[] = [
|
||||
// DAY 1
|
||||
{
|
||||
id: 'bn1', timestamp: T0_NEWS - 11 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "이란 정권 제거 작전 개시"',
|
||||
detail: '백악관 긴급 브리핑. "미국은 이란의 핵위협을 더 이상 용납하지 않겠다."',
|
||||
},
|
||||
{
|
||||
id: 'bn2', timestamp: T0_NEWS - 8 * HOUR_MS,
|
||||
category: 'oil', headline: 'WTI 원유 $140 돌파 — 호르무즈 해협 봉쇄 우려',
|
||||
detail: '브렌트유 $145, 아시아 선물시장 급등. 호르무즈 해협 통과 원유 일일 2,100만 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'bn3', timestamp: T0_NEWS - 3 * HOUR_MS,
|
||||
category: 'oil', headline: '호르무즈 해협 봉쇄 선언 — 유가 40% 급등',
|
||||
detail: 'IRGC 해군 해협 봉쇄. WTI $165, 브렌트 $170. 글로벌 공급망 마비 우려.',
|
||||
},
|
||||
{
|
||||
id: 'bn4', timestamp: T0_NEWS + 2 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "이란은 매우 큰 대가를 치를 것"',
|
||||
detail: '알우데이드 미군 3명 전사 확인 후 성명. "미국 군인에 대한 공격은 10배로 갚겠다."',
|
||||
},
|
||||
{
|
||||
id: 'bn5', timestamp: T0_NEWS + 4 * HOUR_MS,
|
||||
category: 'oil', headline: 'WTI $180 돌파 — 사상 최고가 경신',
|
||||
detail: '이란 보복 공격으로 걸프 원유 수출 완전 중단. S&P 500 -7% 서킷브레이커.',
|
||||
},
|
||||
{
|
||||
id: 'bn6', timestamp: T0_NEWS + 6 * HOUR_MS,
|
||||
category: 'economy', headline: '미 국방부: "2,000명 추가 병력 중동 긴급 배치"',
|
||||
detail: '제82공수사단 신속대응여단 카타르행. 추가 패트리어트 포대 배치.',
|
||||
},
|
||||
{
|
||||
id: 'bn7', timestamp: T0_NEWS + 10 * HOUR_MS,
|
||||
category: 'economy', headline: '한국 비상 에너지 대책 — 전략비축유 방출 검토',
|
||||
detail: '산업부, 유류비 급등 대응 비상대책 발표. 걸프 한국 교민 대피 명령.',
|
||||
},
|
||||
|
||||
// DAY 2
|
||||
{
|
||||
id: 'bn8', timestamp: T0_NEWS + 1 * DAY_MS,
|
||||
category: 'oil', headline: 'WTI $185 — 호르무즈 기뢰 추가 배치',
|
||||
detail: 'IRGC 해협 기뢰 추가 설치. 보험료 1,000% 급등, 유조선 통행 사실상 중단.',
|
||||
},
|
||||
{
|
||||
id: 'bn9', timestamp: T0_NEWS + 1 * DAY_MS + 6 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "이란 석유시설 전면 타격 승인"',
|
||||
detail: '"이란이 해협을 닫으면 우리는 이란의 모든 석유시설을 파괴할 것."',
|
||||
},
|
||||
{
|
||||
id: 'bn10', timestamp: T0_NEWS + 1 * DAY_MS + 10 * HOUR_MS,
|
||||
category: 'economy', headline: 'IEA 긴급 비축유 방출 — 6,000만 배럴',
|
||||
detail: 'IEA 회원국 전략비축유 협조 방출 합의. 미국 3,000만 배럴 선도 방출.',
|
||||
},
|
||||
|
||||
// DAY 3
|
||||
{
|
||||
id: 'bn11', timestamp: T0_NEWS + 2 * DAY_MS,
|
||||
category: 'oil', headline: '호르무즈 유조선 기뢰 접촉 — 원유 유출',
|
||||
detail: '그리스 VLCC "아테나 글로리" 기뢰 접촉. 200만 배럴 유출 위기. WTI $190.',
|
||||
},
|
||||
{
|
||||
id: 'bn12', timestamp: T0_NEWS + 2 * DAY_MS + 6 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "해군에 호르무즈 기뢰 제거 명령"',
|
||||
detail: '"미 해군 소해정 부대 투입. 해협 72시간 내 재개방 목표."',
|
||||
},
|
||||
{
|
||||
id: 'bn13', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS,
|
||||
category: 'economy', headline: '한국 선박 12척 오만만 긴급 대피',
|
||||
detail: '청해부대 호위 하 호르무즈 인근 한국 선박 대피. 해운업계 손실 하루 2,000억원.',
|
||||
},
|
||||
|
||||
// DAY 4
|
||||
{
|
||||
id: 'bn14', timestamp: T0_NEWS + 3 * DAY_MS,
|
||||
category: 'oil', headline: 'WTI $195 — 헤즈볼라 하이파 정유시설 타격',
|
||||
detail: '이스라엘 하이파 정유시설 화재. 중동 전면전 우려 극대화.',
|
||||
},
|
||||
{
|
||||
id: 'bn15', timestamp: T0_NEWS + 3 * DAY_MS + 8 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "디모나 공격은 레드라인 — 핵옵션 배제 안 해"',
|
||||
detail: '디모나 핵시설 인근 피격 후 강경 성명. 세계 핵전쟁 공포 확산.',
|
||||
},
|
||||
|
||||
// DAY 5
|
||||
{
|
||||
id: 'bn16', timestamp: T0_NEWS + 4 * DAY_MS,
|
||||
category: 'economy', headline: '이란 사이버공격 — 이스라엘 전력망 마비',
|
||||
detail: '이란 APT, 이스라엘 전력망 해킹. 텔아비브 일대 12시간 정전.',
|
||||
},
|
||||
{
|
||||
id: 'bn17', timestamp: T0_NEWS + 4 * DAY_MS + 4 * HOUR_MS,
|
||||
category: 'oil', headline: 'WTI $200 돌파 — 사우디 라스타누라 드론 피격',
|
||||
detail: '사우디 최대 석유수출터미널 피격. 글로벌 석유 공급 일 500만 배럴 감소.',
|
||||
},
|
||||
{
|
||||
id: 'bn18', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS,
|
||||
category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 방출 개시',
|
||||
detail: '유류비 급등 대응 비상대책. 비축유 500만 배럴 방출. 주유소 가격 L당 2,800원 돌파.',
|
||||
},
|
||||
|
||||
// DAY 6
|
||||
{
|
||||
id: 'bn19', timestamp: T0_NEWS + 5 * DAY_MS,
|
||||
category: 'oil', headline: '이란 항모전단 공격 — WTI $210',
|
||||
detail: 'IRGC 대함미사일 발사, 이지스 전탄 요격. 해상보험료 역사적 최고치.',
|
||||
},
|
||||
{
|
||||
id: 'bn20', timestamp: T0_NEWS + 5 * DAY_MS + 4 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "이란 해군 완전히 소탕하겠다"',
|
||||
detail: '"페르시아만에서 이란 함정이 하나도 남지 않을 때까지 작전 지속."',
|
||||
},
|
||||
|
||||
// DAY 7
|
||||
{
|
||||
id: 'bn21', timestamp: T0_NEWS + 6 * DAY_MS + 4 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "48시간 최후통첩 — 정권교체 불사"',
|
||||
detail: '"이란이 48시간 내 미사일 발사를 중단하지 않으면 정권 교체 작전을 개시하겠다."',
|
||||
},
|
||||
{
|
||||
id: 'bn22', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS,
|
||||
category: 'oil', headline: 'WTI $195 소폭 하락 — 휴전 기대감',
|
||||
detail: '트럼프 최후통첩 후 휴전 기대감. 그러나 IRGC 거부 성명으로 다시 반등.',
|
||||
},
|
||||
|
||||
// DAY 8
|
||||
{
|
||||
id: 'bn23', timestamp: T0_NEWS + 7 * DAY_MS,
|
||||
category: 'diplomacy', headline: 'ICRC: "중동 인도적 위기 — 이란 의약품 고갈"',
|
||||
detail: '이란 내 의약품·식수 부족 심각. 이스라엘·바레인 민간인 사상자 수천 명.',
|
||||
},
|
||||
{
|
||||
id: 'bn24', timestamp: T0_NEWS + 7 * DAY_MS + 6 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "이란 정보부 본부도 파괴 — 끝까지 간다"',
|
||||
detail: 'B-2 이란 정보부(VAJA) 타격 후 성명. "이란에 남은 건 항복뿐."',
|
||||
},
|
||||
{
|
||||
id: 'bn25', timestamp: T0_NEWS + 7 * DAY_MS + 10 * HOUR_MS,
|
||||
category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국',
|
||||
detail: '군 수송기 투입. 청해부대 한국 선박 호위 지속. 해운업계 일 3,000억원 손실.',
|
||||
},
|
||||
|
||||
// DAY 9
|
||||
{
|
||||
id: 'bn26', timestamp: T0_NEWS + 8 * DAY_MS,
|
||||
category: 'diplomacy', headline: '러시아, 이란에 휴전 수용 비공식 권고',
|
||||
detail: '푸틴, 추가 무기 지원 거부. 이란 고립 심화.',
|
||||
},
|
||||
{
|
||||
id: 'bn27', timestamp: T0_NEWS + 8 * DAY_MS + 4 * HOUR_MS,
|
||||
category: 'oil', headline: '이란 미사일 재고 80% 소진 — WTI $180으로 하락',
|
||||
detail: '미 정보기관 분석 공개. 이란 잔존 이동식 발사대 10기 이하.',
|
||||
},
|
||||
{
|
||||
id: 'bn28', timestamp: T0_NEWS + 8 * DAY_MS + 8 * HOUR_MS,
|
||||
category: 'trump', headline: '트럼프: "나탄즈 완전 파괴 — 이란 핵프로그램 종식"',
|
||||
detail: '"이란의 핵 야망은 영원히 끝났다. 역사가 나를 기억할 것."',
|
||||
},
|
||||
{
|
||||
id: 'bn29', timestamp: T0_NEWS + 8 * DAY_MS + 10 * HOUR_MS,
|
||||
category: 'diplomacy', headline: 'UN 72시간 인도적 휴전 결의안 채택',
|
||||
detail: '안보리 찬성 13, 기권 2. 미국·이란 모두 입장 미정.',
|
||||
},
|
||||
{
|
||||
id: 'bn30', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS,
|
||||
category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분"',
|
||||
detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.',
|
||||
},
|
||||
];
|
||||
|
||||
// ═══ 한국 전용 속보 (리플레이) ═══
|
||||
const BREAKING_NEWS_KR: BreakingNews[] = [
|
||||
// DAY 1
|
||||
{ id: 'kr1', timestamp: T0_NEWS - 6 * HOUR_MS, category: 'economy', headline: '한국 NSC 긴급소집 — 호르무즈 사태 대응 논의', detail: '외교·국방·산업부 장관 참석. 교민 보호·에너지 수급 점검.' },
|
||||
{ id: 'kr2', timestamp: T0_NEWS + 2 * HOUR_MS, category: 'economy', headline: '코스피 -4.2% 급락 — 유가 폭등 충격', detail: '한국 원유 수입의 70% 호르무즈 해협 경유. 정유·항공·운송주 급락.' },
|
||||
{ id: 'kr3', timestamp: T0_NEWS + 6 * HOUR_MS, category: 'oil', headline: '국내 유가 L당 2,200원 돌파 — 주유소 대란 시작', detail: '수도권 주유소 재고 부족 속출. 산업부 긴급 유류 배급 체계 가동 검토.' },
|
||||
{ id: 'kr4', timestamp: T0_NEWS + 10 * HOUR_MS, category: 'economy', headline: '한국 전략비축유 방출 검토 — 산업부 비상대책', detail: '걸프 한국 교민 대피 명령. 청해부대 한국 선박 호위 태세.' },
|
||||
|
||||
// DAY 2
|
||||
{ id: 'kr5', timestamp: T0_NEWS + 1 * DAY_MS, category: 'oil', headline: '한국행 원유 탱커 5척 오만만 대기 — 통행 불가', detail: 'VLCC 5척(250만 배럴) 호르무즈 해협 진입 불가. 정유사 원유 재고 2주분.' },
|
||||
{ id: 'kr6', timestamp: T0_NEWS + 1 * DAY_MS + 8 * HOUR_MS, category: 'economy', headline: '현대·삼성중공업 조선 수주 취소 우려 — 해운보험료 급등', detail: '걸프 향 선박 보험료 1,000% 인상. 해운업계 일 2,000억원 손실.' },
|
||||
|
||||
// DAY 3
|
||||
{ id: 'kr7', timestamp: T0_NEWS + 2 * DAY_MS, category: 'economy', headline: '한국 교민 1,200명 UAE·카타르 대피 중', detail: '외교부 특별기 2대 투입. 청해부대 ROKS 최영함 한국 선박 호위.' },
|
||||
{ id: 'kr8', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS, category: 'oil', headline: '한국 정유사 가동률 70% 감축 — 원유 부족', detail: 'SK에너지·GS칼텍스·에쓰오일 감산. LPG·석유화학 제품 공급 차질.' },
|
||||
|
||||
// DAY 4
|
||||
{ id: 'kr9', timestamp: T0_NEWS + 3 * DAY_MS, category: 'economy', headline: '코스피 -8.5% — 서킷브레이커 발동', detail: '외국인 6조원 순매도. 원/달러 1,550원 돌파. 한국 CDS 급등.' },
|
||||
{ id: 'kr10', timestamp: T0_NEWS + 3 * DAY_MS + 6 * HOUR_MS, category: 'oil', headline: '한국, 미국·캐나다 긴급 원유 도입 협상', detail: '비(非)호르무즈 경유 원유 확보. 미 전략비축유 한국 우선배분 요청.' },
|
||||
|
||||
// DAY 5
|
||||
{ id: 'kr11', timestamp: T0_NEWS + 4 * DAY_MS, category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 500만 배럴 방출', detail: '주유소 가격 L당 2,800원 돌파. 택시·화물차 운행 감축 논의.' },
|
||||
{ id: 'kr12', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한국 외교부, 이란에 교민 안전 보장 요청', detail: '이란 주재 한국대사관 최소 인원 운영. 한국인 체류자 150명 잔류.' },
|
||||
|
||||
// DAY 6
|
||||
{ id: 'kr13', timestamp: T0_NEWS + 5 * DAY_MS, category: 'oil', headline: '한국 LNG 긴급 수입 — 호주·카타르 장기계약 가동', detail: 'LNG 스팟 가격 MMBtu $35 돌파. 가스공사 비축량 2주분.' },
|
||||
{ id: 'kr14', timestamp: T0_NEWS + 5 * DAY_MS + 6 * HOUR_MS, category: 'economy', headline: '한국 해운 3사 호르무즈 회항 — 희망봉 우회', detail: 'HMM·팬오션·대한해운 전 선박 희망봉 우회. 운항 일수 +14일, 비용 +40%.' },
|
||||
|
||||
// DAY 7
|
||||
{ id: 'kr15', timestamp: T0_NEWS + 6 * DAY_MS, category: 'economy', headline: '한국 제조업 PMI 42.1 — 3년 최저', detail: '석유화학·철강·자동차 부품 공급 차질. 수출 전년비 -15% 전망.' },
|
||||
{ id: 'kr16', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한미 정상 긴급통화 — 에너지 안보 협력 강화', detail: '미국, 한국에 전략비축유 500만 배럴 추가 배분. 원유 수송 해군 호위 합의.' },
|
||||
|
||||
// DAY 8
|
||||
{ id: 'kr17', timestamp: T0_NEWS + 7 * DAY_MS, category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국', detail: '군 수송기 C-130J 2대 투입. 청해부대 한국 선박 호위 지속.' },
|
||||
{ id: 'kr18', timestamp: T0_NEWS + 7 * DAY_MS + 8 * HOUR_MS, category: 'oil', headline: '한국 원유 비축 45일분으로 감소 — 경고 수준', detail: 'IEA 권고 90일 대비 절반. 추가 긴축 조치 불가피.' },
|
||||
|
||||
// DAY 9
|
||||
{ id: 'kr19', timestamp: T0_NEWS + 8 * DAY_MS, category: 'diplomacy', headline: '한국, UN 휴전 결의안 공동 발의', detail: '인도적 위기 해소와 호르무즈 재개방을 위한 72시간 휴전 촉구.' },
|
||||
{ id: 'kr20', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS, category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분 목표"', detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.' },
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<GeoEvent['type'], string> = {
|
||||
airstrike: 'STRIKE',
|
||||
explosion: 'EXPLOSION',
|
||||
missile_launch: 'LAUNCH',
|
||||
intercept: 'INTERCEPT',
|
||||
alert: 'ALERT',
|
||||
impact: 'IMPACT',
|
||||
osint: 'OSINT',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||
airstrike: 'var(--kcg-event-airstrike)',
|
||||
explosion: 'var(--kcg-event-explosion)',
|
||||
missile_launch: 'var(--kcg-event-missile)',
|
||||
intercept: 'var(--kcg-event-intercept)',
|
||||
alert: 'var(--kcg-event-alert)',
|
||||
impact: 'var(--kcg-event-impact)',
|
||||
osint: 'var(--kcg-event-osint)',
|
||||
};
|
||||
|
||||
// MarineTraffic-style ship type classification
|
||||
function getShipMTCategory(typecode?: string, category?: string): string {
|
||||
if (!typecode) {
|
||||
if (category === 'tanker') return 'tanker';
|
||||
if (category === 'cargo') return 'cargo';
|
||||
if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military';
|
||||
return 'unspecified';
|
||||
}
|
||||
const code = typecode.toUpperCase();
|
||||
if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker';
|
||||
if (code === 'CONT' || code === 'BULK') return 'cargo';
|
||||
if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military';
|
||||
if (code.startsWith('A1')) return 'tanker';
|
||||
if (code.startsWith('A2') || code.startsWith('A3')) return 'cargo';
|
||||
if (code.startsWith('B')) return 'passenger';
|
||||
if (code.startsWith('C')) return 'fishing';
|
||||
if (code.startsWith('D') || code.startsWith('E')) return 'tug_special';
|
||||
return 'unspecified';
|
||||
}
|
||||
|
||||
// MarineTraffic-style category colors (labels come from i18n)
|
||||
const MT_CATEGORY_COLORS: Record<string, string> = {
|
||||
cargo: '#8bc34a',
|
||||
tanker: '#e91e63',
|
||||
passenger: '#2196f3',
|
||||
high_speed: '#ff9800',
|
||||
tug_special: '#00bcd4',
|
||||
fishing: '#ff5722',
|
||||
pleasure: '#9c27b0',
|
||||
military: '#607d8b',
|
||||
unspecified: '#9e9e9e',
|
||||
};
|
||||
|
||||
const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
|
||||
trump: '\u{1F1FA}\u{1F1F8}',
|
||||
oil: '\u{1F6E2}\u{FE0F}',
|
||||
diplomacy: '\u{1F310}',
|
||||
economy: '\u{1F4CA}',
|
||||
};
|
||||
|
||||
// OSINT category icons (labels come from i18n)
|
||||
const OSINT_CAT_ICONS: Record<string, string> = {
|
||||
military: '\u{1F3AF}',
|
||||
oil: '\u{1F6E2}',
|
||||
diplomacy: '\u{1F310}',
|
||||
shipping: '\u{1F6A2}',
|
||||
nuclear: '\u{2622}',
|
||||
maritime_accident: '\u{1F6A8}',
|
||||
fishing: '\u{1F41F}',
|
||||
maritime_traffic: '\u{1F6A2}',
|
||||
general: '\u{1F4F0}',
|
||||
};
|
||||
|
||||
// OSINT category colors
|
||||
const OSINT_CAT_COLORS: Record<string, string> = {
|
||||
military: '#ef4444',
|
||||
oil: '#f59e0b',
|
||||
diplomacy: '#8b5cf6',
|
||||
shipping: '#06b6d4',
|
||||
nuclear: '#f97316',
|
||||
maritime_accident: '#ef4444',
|
||||
fishing: '#22c55e',
|
||||
maritime_traffic: '#3b82f6',
|
||||
general: '#6b7280',
|
||||
};
|
||||
|
||||
// NEWS category colors
|
||||
const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
|
||||
trump: '#ef4444',
|
||||
oil: '#f59e0b',
|
||||
diplomacy: '#8b5cf6',
|
||||
economy: '#3b82f6',
|
||||
};
|
||||
|
||||
const EMPTY_OSINT: OsintItem[] = [];
|
||||
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
||||
|
||||
function useTimeAgo() {
|
||||
const { t } = useTranslation('common');
|
||||
return (ts: number): string => {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return t('time.justNow');
|
||||
if (mins < 60) return t('time.minutesAgo', { count: mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t('time.hoursAgo', { count: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return t('time.daysAgo', { count: days });
|
||||
};
|
||||
}
|
||||
|
||||
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) {
|
||||
const { t } = useTranslation(['common', 'events', 'ships']);
|
||||
const timeAgo = useTimeAgo();
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
||||
[events, currentTime],
|
||||
);
|
||||
|
||||
const visibleNews = useMemo(
|
||||
() => events.length > 0
|
||||
? BREAKING_NEWS.filter(n => n.timestamp <= currentTime).reverse()
|
||||
: [],
|
||||
[events.length, currentTime],
|
||||
);
|
||||
|
||||
const visibleNewsKR = useMemo(
|
||||
() => events.length > 0
|
||||
? BREAKING_NEWS_KR.filter(n => n.timestamp <= currentTime).reverse()
|
||||
: [],
|
||||
[events.length, currentTime],
|
||||
);
|
||||
|
||||
// Iran-related ships (military + Iranian flag)
|
||||
const _iranMilitaryShips = useMemo(() =>
|
||||
ships.filter(s =>
|
||||
s.flag === 'IR' ||
|
||||
s.category === 'carrier' || s.category === 'destroyer' ||
|
||||
s.category === 'warship' || s.category === 'patrol'
|
||||
).sort((a, b) => {
|
||||
const order: Record<string, number> = { carrier: 0, destroyer: 1, warship: 2, patrol: 3, tanker: 4, cargo: 5, civilian: 6, unknown: 7 };
|
||||
return (order[a.category] ?? 9) - (order[b.category] ?? 9);
|
||||
}),
|
||||
[ships],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="event-log">
|
||||
{/* ═══════════════════════════════════════════════
|
||||
IRAN TAB
|
||||
═══════════════════════════════════════════════ */}
|
||||
{dashboardTab === 'iran' && (
|
||||
<>
|
||||
{/* Breaking News Section (replay) */}
|
||||
{visibleNews.length > 0 && (
|
||||
<div className="breaking-news-section">
|
||||
<div className="breaking-news-header">
|
||||
<span className="breaking-flash">BREAKING</span>
|
||||
<span className="breaking-title">{t('events:news.breakingTitle')}</span>
|
||||
</div>
|
||||
<div className="breaking-news-list">
|
||||
{visibleNews.map(n => {
|
||||
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||
return (
|
||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||
<div className="breaking-news-top">
|
||||
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||
</span>
|
||||
<span className="breaking-news-time">
|
||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="breaking-news-headline">{n.headline}</div>
|
||||
{n.detail && <div className="breaking-news-detail">{n.detail}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Korean Ship Overview (Iran dashboard) */}
|
||||
{koreanShips.length > 0 && (
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header">
|
||||
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||
<span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||
</div>
|
||||
<div className="iran-mil-list">
|
||||
{koreanShips.slice(0, 30).map(s => {
|
||||
const cat = getShipMTCategory(s.typecode, s.category);
|
||||
const mtColor = MT_CATEGORY_COLORS[cat] || '#888';
|
||||
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||
return (
|
||||
<div key={s.mmsi} className="iran-mil-item">
|
||||
<span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||
<span className="iran-mil-name">{s.name}</span>
|
||||
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
|
||||
{mtLabel}
|
||||
</span>
|
||||
{s.speed != null && s.speed > 0.5 ? (
|
||||
<span className="ml-auto text-[9px] text-kcg-success">{s.speed.toFixed(1)}kn</span>
|
||||
) : (
|
||||
<span className="ml-auto text-[9px] text-kcg-danger">{t('ships:status.anchored')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSINT Live Feed (live mode) */}
|
||||
{isLive && osintFeed.length > 0 && (
|
||||
<>
|
||||
<div className="osint-header">
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||
<span className="osint-count">{osintFeed.length}</span>
|
||||
</div>
|
||||
<div className="osint-list">
|
||||
{osintFeed.map(item => {
|
||||
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
||||
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
||||
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="osint-item-top">
|
||||
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||
</span>
|
||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||
</div>
|
||||
<div className="osint-item-title">{item.title}</div>
|
||||
<div className="osint-item-source">{item.source}</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isLive && osintFeed.length === 0 && (
|
||||
<div className="osint-header">
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Log (replay mode) */}
|
||||
{!isLive && (
|
||||
<>
|
||||
<h3>{t('events:log.title')}</h3>
|
||||
<div className="event-list">
|
||||
{visibleEvents.length === 0 && (
|
||||
<div className="event-empty">{t('events:log.noEvents')}</div>
|
||||
)}
|
||||
{visibleEvents.map(e => {
|
||||
const isNew = currentTime - e.timestamp < 86_400_000;
|
||||
return (
|
||||
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid var(--kcg-event-impact)' } : undefined}>
|
||||
<span
|
||||
className="event-tag"
|
||||
style={{ backgroundColor: TYPE_COLORS[e.type] }}
|
||||
>
|
||||
{TYPE_LABELS[e.type]}
|
||||
</span>
|
||||
<div className="event-content">
|
||||
<div className="event-label">
|
||||
{isNew && (
|
||||
<span className="inline-block rounded-sm bg-[var(--kcg-event-impact)] px-1 mr-1 text-[9px] font-bold text-white">{t('events:log.new')}</span>
|
||||
)}
|
||||
{e.label}
|
||||
</div>
|
||||
<div className="event-time">
|
||||
{new Date(e.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} KST
|
||||
</div>
|
||||
{e.description && (
|
||||
<div className="event-desc">{e.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════
|
||||
KOREA TAB
|
||||
═══════════════════════════════════════════════ */}
|
||||
{dashboardTab === 'korea' && (
|
||||
<>
|
||||
{/* 한국 속보 (replay) */}
|
||||
{visibleNewsKR.length > 0 && (
|
||||
<div className="breaking-news-section" style={{ borderLeftColor: 'var(--kcg-accent)' }}>
|
||||
<div className="breaking-news-header">
|
||||
<span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
|
||||
<span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
|
||||
</div>
|
||||
<div className="breaking-news-list">
|
||||
{visibleNewsKR.map(n => {
|
||||
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||
return (
|
||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||
<div className="breaking-news-top">
|
||||
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||
</span>
|
||||
<span className="breaking-news-time">
|
||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="breaking-news-headline">{n.headline}</div>
|
||||
{n.detail && <div className="breaking-news-detail">{n.detail}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 한국 선박 현황 — 선종별 분류 */}
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header">
|
||||
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||
</div>
|
||||
{koreanShips.length > 0 && (() => {
|
||||
const groups: Record<string, Ship[]> = {};
|
||||
for (const s of koreanShips) {
|
||||
const cat = getShipMTCategory(s.typecode, s.category);
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(s);
|
||||
}
|
||||
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
||||
const sorted = order.filter(k => groups[k]?.length);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{sorted.map(cat => {
|
||||
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
||||
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||
const list = groups[cat];
|
||||
const moving = list.filter(s => s.speed > 0.5).length;
|
||||
const anchored = list.length - moving;
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||
background: `${mtColor}0a`,
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 중국 선박 현황 */}
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header">
|
||||
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
|
||||
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
|
||||
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
|
||||
</div>
|
||||
{chineseShips.length > 0 && (() => {
|
||||
const groups: Record<string, Ship[]> = {};
|
||||
for (const s of chineseShips) {
|
||||
const cat = getShipMTCategory(s.typecode, s.category);
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(s);
|
||||
}
|
||||
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
||||
const sorted = order.filter(k => groups[k]?.length);
|
||||
const fishingCount = groups['fishing']?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{fishingCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
|
||||
<span className="text-sm">{'\u{1F6A8}'}</span>
|
||||
<span className="text-[11px] font-bold font-mono text-kcg-danger">
|
||||
{t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map(cat => {
|
||||
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
||||
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
||||
const list = groups[cat];
|
||||
const moving = list.filter(s => s.speed > 0.5).length;
|
||||
const anchored = list.length - moving;
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||
background: `${mtColor}0a`,
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
|
||||
{osintFeed.length > 0 && (
|
||||
<>
|
||||
<div className="osint-header">
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||
<span className="osint-count">{(() => {
|
||||
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
||||
const seen = new Set<string>();
|
||||
return filtered.filter(i => {
|
||||
const key = i.title.replace(/\s+/g, '').slice(0, 30).toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).length;
|
||||
})()}</span>
|
||||
</div>
|
||||
<div className="osint-list">
|
||||
{(() => {
|
||||
const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil');
|
||||
const seen = new Set<string>();
|
||||
return filtered.filter(item => {
|
||||
const key = item.title.replace(/\s+/g, '').slice(0, 30).toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
})().map(item => {
|
||||
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
||||
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
||||
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="osint-item-top">
|
||||
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||
</span>
|
||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||
</div>
|
||||
<div className="osint-item-title">{item.title}</div>
|
||||
<div className="osint-item-source">{item.source}</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{osintFeed.length === 0 && (
|
||||
<div className="osint-header">
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/components/EventStrip.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GeoEvent } from '../types';
|
||||
|
||||
interface Props {
|
||||
events: GeoEvent[];
|
||||
currentTime: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
onEventClick: (event: GeoEvent) => void;
|
||||
}
|
||||
|
||||
const KST_OFFSET = 9 * 3600_000;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
airstrike: '#ef4444',
|
||||
explosion: '#f97316',
|
||||
missile_launch: '#eab308',
|
||||
intercept: '#3b82f6',
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
};
|
||||
|
||||
const TYPE_KEYS: Record<string, string> = {
|
||||
airstrike: 'event.airstrike',
|
||||
explosion: 'event.explosion',
|
||||
missile_launch: 'event.missileLaunch',
|
||||
intercept: 'event.intercept',
|
||||
alert: 'event.alert',
|
||||
impact: 'event.impact',
|
||||
osint: 'event.osint',
|
||||
};
|
||||
|
||||
const SOURCE_KEYS: Record<string, string> = {
|
||||
US: 'source.US',
|
||||
IL: 'source.IL',
|
||||
IR: 'source.IR',
|
||||
proxy: 'source.proxy',
|
||||
};
|
||||
|
||||
interface EventGroup {
|
||||
dateKey: string; // "2026-03-01"
|
||||
dateLabel: string; // "03/01 (토)"
|
||||
events: GeoEvent[];
|
||||
}
|
||||
|
||||
const DAY_NAME_KEYS = [
|
||||
'dayNames.sun', 'dayNames.mon', 'dayNames.tue', 'dayNames.wed',
|
||||
'dayNames.thu', 'dayNames.fri', 'dayNames.sat',
|
||||
];
|
||||
|
||||
export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const map = new Map<string, GeoEvent[]>();
|
||||
|
||||
for (const ev of sorted) {
|
||||
const d = new Date(ev.timestamp + KST_OFFSET);
|
||||
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(ev);
|
||||
}
|
||||
|
||||
const result: EventGroup[] = [];
|
||||
for (const [dateKey, evs] of map) {
|
||||
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
||||
const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
|
||||
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
||||
result.push({ dateKey, dateLabel, events: evs });
|
||||
}
|
||||
return result;
|
||||
}, [events, t]);
|
||||
|
||||
// Auto-open the first group if none selected
|
||||
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
||||
|
||||
const formatTimeKST = (ts: number) => {
|
||||
const d = new Date(ts + KST_OFFSET);
|
||||
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="event-strip">
|
||||
{/* Date tabs */}
|
||||
<div className="es-tabs">
|
||||
<span className="es-label">STRIKES</span>
|
||||
{groups.map(g => {
|
||||
const isActive = effectiveOpen === g.dateKey;
|
||||
const passedCount = g.events.filter(e => e.timestamp <= currentTime).length;
|
||||
return (
|
||||
<button
|
||||
key={g.dateKey}
|
||||
className={`es-tab ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setOpenDate(isActive ? null : g.dateKey)}
|
||||
>
|
||||
<span className="es-tab-date">{g.dateLabel}</span>
|
||||
<span className="es-tab-count">{passedCount}/{g.events.length}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded event list for selected date */}
|
||||
{effectiveOpen && (() => {
|
||||
const group = groups.find(g => g.dateKey === effectiveOpen);
|
||||
if (!group) return null;
|
||||
return (
|
||||
<div className="es-events">
|
||||
{group.events.map(ev => {
|
||||
const isPast = ev.timestamp <= currentTime;
|
||||
const color = TYPE_COLORS[ev.type] || '#888';
|
||||
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
|
||||
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
className={`es-event ${isPast ? 'past' : 'future'}`}
|
||||
style={{ '--dot-color': color } as React.CSSProperties}
|
||||
onClick={() => onEventClick(ev)}
|
||||
title={ev.description || ev.label}
|
||||
>
|
||||
<span className="es-dot" />
|
||||
<span className="es-time">{formatTimeKST(ev.timestamp)}</span>
|
||||
{source && (
|
||||
<span className="es-source" style={{ background: color }}>{source}</span>
|
||||
)}
|
||||
<span className="es-name">{ev.label}</span>
|
||||
<span className="es-type">{typeLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/GlobeMap.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { countryLabelsGeoJSON } from '../data/countryLabels';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types';
|
||||
|
||||
interface Props {
|
||||
events: GeoEvent[];
|
||||
currentTime: number;
|
||||
aircraft: Aircraft[];
|
||||
satellites: SatellitePosition[];
|
||||
ships: Ship[];
|
||||
layers: LayerVisibility;
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
airstrike: '#ef4444',
|
||||
explosion: '#f97316',
|
||||
missile_launch: '#eab308',
|
||||
intercept: '#3b82f6',
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
};
|
||||
|
||||
// Navy flag-based colors for military vessels
|
||||
const NAVY_COLORS: Record<string, string> = {
|
||||
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
|
||||
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261',
|
||||
};
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
carrier: '#ef4444',
|
||||
destroyer: '#f97316',
|
||||
warship: '#fb923c',
|
||||
patrol: '#fbbf24',
|
||||
submarine: '#8b5cf6',
|
||||
tanker: '#22d3ee',
|
||||
cargo: '#94a3b8',
|
||||
civilian: '#64748b',
|
||||
};
|
||||
const MIL_SHIP_CATS = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
|
||||
function getGlobeShipColor(cat: string, flag?: string): string {
|
||||
if (MIL_SHIP_CATS.includes(cat) && flag && NAVY_COLORS[flag]) return NAVY_COLORS[flag];
|
||||
return SHIP_COLORS[cat] || '#64748b';
|
||||
}
|
||||
|
||||
const AC_COLORS: Record<string, string> = {
|
||||
fighter: '#ef4444',
|
||||
bomber: '#dc2626',
|
||||
surveillance: '#f59e0b',
|
||||
tanker: '#22d3ee',
|
||||
transport: '#10b981',
|
||||
cargo: '#6366f1',
|
||||
helicopter: '#a855f7',
|
||||
civilian: '#64748b',
|
||||
unknown: '#475569',
|
||||
};
|
||||
|
||||
export function GlobeMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const markersRef = useRef<maplibregl.Marker[]>([]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'dark-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#0a0a1a' },
|
||||
},
|
||||
{
|
||||
id: 'dark-tiles',
|
||||
type: 'raster',
|
||||
source: 'dark-tiles',
|
||||
},
|
||||
],
|
||||
projection: { type: 'globe' },
|
||||
} as maplibregl.StyleSpecification,
|
||||
center: [44, 31.5],
|
||||
zoom: 3,
|
||||
pitch: 20,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
|
||||
map.addLayer({
|
||||
id: 'country-label-lg', type: 'symbol', source: 'country-labels',
|
||||
filter: ['==', ['get', 'rank'], 1],
|
||||
layout: {
|
||||
'text-field': ['get', 'name'], 'text-size': 14,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false, 'text-padding': 6,
|
||||
},
|
||||
paint: { 'text-color': '#e2e8f0', 'text-halo-color': '#000', 'text-halo-width': 2, 'text-opacity': 0.9 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'country-label-md', type: 'symbol', source: 'country-labels',
|
||||
filter: ['==', ['get', 'rank'], 2],
|
||||
layout: {
|
||||
'text-field': ['get', 'name'], 'text-size': 11,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false, 'text-padding': 4,
|
||||
},
|
||||
paint: { 'text-color': '#94a3b8', 'text-halo-color': '#000', 'text-halo-width': 1.5, 'text-opacity': 0.85 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'country-label-sm', type: 'symbol', source: 'country-labels',
|
||||
filter: ['==', ['get', 'rank'], 3], minzoom: 5,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'], 'text-size': 10,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
'text-allow-overlap': false, 'text-padding': 2,
|
||||
},
|
||||
paint: { 'text-color': '#64748b', 'text-halo-color': '#000', 'text-halo-width': 1, 'text-opacity': 0.75 },
|
||||
});
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update markers — DOM direct manipulation, inline styles intentionally kept
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// Clear old markers
|
||||
for (const m of markersRef.current) m.remove();
|
||||
markersRef.current = [];
|
||||
|
||||
const addMarker = (lng: number, lat: number, color: string, size: number, tooltip: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.borderRadius = '50%';
|
||||
el.style.background = color;
|
||||
el.style.border = `1.5px solid ${color}`;
|
||||
el.style.boxShadow = `0 0 ${size}px ${color}80`;
|
||||
el.style.cursor = 'pointer';
|
||||
el.title = tooltip;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
};
|
||||
|
||||
const addTriangle = (lng: number, lat: number, color: string, size: number, heading: number, tooltip: string) => {
|
||||
const el = document.createElement('div');
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.transform = `rotate(${heading}deg)`;
|
||||
el.style.cursor = 'pointer';
|
||||
el.title = tooltip;
|
||||
el.innerHTML = `<svg viewBox="0 0 10 10" width="${size}" height="${size}">
|
||||
<polygon points="5,0 0,10 10,10" fill="${color}" stroke="#fff" stroke-width="0.5" opacity="0.9"/>
|
||||
</svg>`;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
};
|
||||
|
||||
// Events
|
||||
if (layers.events) {
|
||||
const visible = events.filter(e => e.timestamp <= currentTime);
|
||||
for (const e of visible) {
|
||||
const color = EVENT_COLORS[e.type] || '#888';
|
||||
const size = e.type === 'impact' ? 14 : 8;
|
||||
addMarker(e.lng, e.lat, color, size, `${e.label}\n${new Date(e.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST`);
|
||||
}
|
||||
}
|
||||
|
||||
// Aircraft
|
||||
if (layers.aircraft) {
|
||||
const filtered = layers.militaryOnly
|
||||
? aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown')
|
||||
: aircraft;
|
||||
for (const ac of filtered) {
|
||||
const color = AC_COLORS[ac.category] || '#64748b';
|
||||
addTriangle(ac.lng, ac.lat, color, 10, ac.heading || 0,
|
||||
`${ac.callsign || ac.icao24} [${ac.category}]\nAlt: ${ac.altitude?.toFixed(0) || '?'}ft`);
|
||||
}
|
||||
}
|
||||
|
||||
// Satellites
|
||||
if (layers.satellites) {
|
||||
for (const sat of satellites) {
|
||||
addMarker(sat.lng, sat.lat, '#ef4444', 5, `${sat.name}\nAlt: ${sat.altitude?.toFixed(0)}km`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ships
|
||||
if (layers.ships) {
|
||||
const filtered = layers.militaryOnly
|
||||
? ships.filter(s => !['civilian', 'cargo', 'tanker'].includes(s.category))
|
||||
: ships;
|
||||
for (const s of filtered) {
|
||||
const color = getGlobeShipColor(s.category, s.flag);
|
||||
addTriangle(s.lng, s.lat, color, 10, s.heading || 0,
|
||||
`${s.name} [${s.category}]\n${s.flag || ''}`);
|
||||
}
|
||||
}
|
||||
}, [events, currentTime, aircraft, satellites, ships, layers]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/InfraLayer.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import type { PowerFacility } from '../services/infra';
|
||||
|
||||
// SVG Wind Turbine Icon
|
||||
function WindTurbineIcon({ color, size = 14 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
{/* Tower */}
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke={color} strokeWidth="1.5" />
|
||||
{/* Hub */}
|
||||
<circle cx="12" cy="9" r="1.5" fill={color} />
|
||||
{/* Blade 1 - top */}
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={color} opacity="0.9" />
|
||||
{/* Blade 2 - bottom-right */}
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={color} opacity="0.9" />
|
||||
{/* Blade 3 - bottom-left */}
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={color} opacity="0.9" />
|
||||
{/* Base */}
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke={color} strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
facilities: PowerFacility[];
|
||||
}
|
||||
|
||||
// Source → icon & color
|
||||
const SOURCE_STYLE: Record<string, { icon: string; color: string; label: string }> = {
|
||||
nuclear: { icon: '☢️', color: '#e040fb', label: '원자력' },
|
||||
coal: { icon: '🏭', color: '#795548', label: '석탄' },
|
||||
gas: { icon: '🔥', color: '#ff9800', label: 'LNG' },
|
||||
oil: { icon: '🛢️', color: '#5d4037', label: '석유' },
|
||||
hydro: { icon: '💧', color: '#2196f3', label: '수력' },
|
||||
solar: { icon: '☀️', color: '#ffc107', label: '태양광' },
|
||||
wind: { icon: '🌀', color: '#00bcd4', label: '풍력' },
|
||||
biomass: { icon: '🌿', color: '#4caf50', label: '바이오' },
|
||||
};
|
||||
|
||||
const SUBSTATION_STYLE = { icon: '⚡', color: '#ffeb3b', label: '변전소' };
|
||||
|
||||
function getStyle(f: PowerFacility) {
|
||||
if (f.type === 'substation') return SUBSTATION_STYLE;
|
||||
return SOURCE_STYLE[f.source || ''] || { icon: '⚡', color: '#9e9e9e', label: '발전소' };
|
||||
}
|
||||
|
||||
function formatVoltage(v?: string): string {
|
||||
if (!v) return '';
|
||||
const kv = parseInt(v) / 1000;
|
||||
if (isNaN(kv)) return v;
|
||||
return `${kv}kV`;
|
||||
}
|
||||
|
||||
export function InfraLayer({ facilities }: Props) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const plants = useMemo(() => facilities.filter(f => f.type === 'plant'), [facilities]);
|
||||
const substations = useMemo(() => facilities.filter(f => f.type === 'substation'), [facilities]);
|
||||
|
||||
const selected = selectedId ? facilities.find(f => f.id === selectedId) ?? null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Substations — smaller, show at higher zoom */}
|
||||
{substations.map(f => {
|
||||
const s = getStyle(f);
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
|
||||
<div style={{
|
||||
width: 7, height: 7, borderRadius: 1,
|
||||
background: '#1a1a2e', border: `1px solid ${s.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 4, cursor: 'pointer',
|
||||
}}>
|
||||
<span>{s.icon}</span>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Power plants — larger, always visible */}
|
||||
{plants.map(f => {
|
||||
const s = getStyle(f);
|
||||
const isWind = f.source === 'wind';
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
cursor: 'pointer', pointerEvents: 'auto',
|
||||
}}>
|
||||
{isWind ? (
|
||||
<WindTurbineIcon color={s.color} size={14} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 12, height: 12, borderRadius: 2,
|
||||
background: '#111', border: `1px solid ${s.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, boxShadow: `0 0 3px ${s.color}33`,
|
||||
}}>
|
||||
<span>{s.icon}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: 6, color: s.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 600,
|
||||
}}>
|
||||
{f.name.length > 10 ? f.name.slice(0, 10) + '..' : f.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Popup */}
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelectedId(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
||||
<div style={{
|
||||
background: getStyle(selected).color, color: '#000',
|
||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
||||
margin: '-10px -10px 8px -10px',
|
||||
fontWeight: 700, fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<span>{getStyle(selected).icon}</span>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: getStyle(selected).color, color: '#000',
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{getStyle(selected).label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.type === 'plant' ? '발전소' : '변전소'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{selected.output && (
|
||||
<div><span style={{ color: '#888' }}>출력: </span><strong>{selected.output}</strong></div>
|
||||
)}
|
||||
{selected.voltage && (
|
||||
<div><span style={{ color: '#888' }}>전압: </span><strong>{formatVoltage(selected.voltage)}</strong></div>
|
||||
)}
|
||||
{selected.operator && (
|
||||
<div><span style={{ color: '#888' }}>운영: </span>{selected.operator}</div>
|
||||
)}
|
||||
{selected.source && (
|
||||
<div><span style={{ color: '#888' }}>연료: </span>{selected.source}</div>
|
||||
)}
|
||||
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/KoreaAirportLayer.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { KOREAN_AIRPORTS } from '../services/airports';
|
||||
import type { KoreanAirport } from '../services/airports';
|
||||
|
||||
export function KoreaAirportLayer() {
|
||||
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{KOREAN_AIRPORTS.map(ap => {
|
||||
const isIntl = ap.intl;
|
||||
const color = isIntl ? '#a78bfa' : '#7c8aaa';
|
||||
const size = isIntl ? 20 : 16;
|
||||
return (
|
||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
|
||||
fill={color} stroke="#fff" strokeWidth="0.3" />
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 6,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
|
||||
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="260px" className="gl-popup">
|
||||
<div className="min-w-[180px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: selected.intl ? '#a78bfa' : '#7c8aaa',
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
{selected.intl && (
|
||||
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.id} / {selected.icao}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
321
frontend/src/components/KoreaMap.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SubmarineCableLayer } from './SubmarineCableLayer';
|
||||
import { CctvLayer } from './CctvLayer';
|
||||
import { KoreaAirportLayer } from './KoreaAirportLayer';
|
||||
import { CoastGuardLayer } from './CoastGuardLayer';
|
||||
import { NavWarningLayer } from './NavWarningLayer';
|
||||
import { OsintMapLayer } from './OsintMapLayer';
|
||||
import { EezLayer } from './EezLayer';
|
||||
import { PiracyLayer } from './PiracyLayer';
|
||||
import { fetchKoreaInfra } from '../services/infra';
|
||||
import type { PowerFacility } from '../services/infra';
|
||||
import type { Ship, Aircraft, SatellitePosition } from '../types';
|
||||
import type { OsintItem } from '../services/osint';
|
||||
import { countryLabelsGeoJSON } from '../data/countryLabels';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
export interface KoreaFiltersState {
|
||||
illegalFishing: boolean;
|
||||
illegalTransship: boolean;
|
||||
darkVessel: boolean;
|
||||
cableWatch: boolean;
|
||||
dokdoWatch: boolean;
|
||||
ferryWatch: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
aircraft: Aircraft[];
|
||||
satellites: SatellitePosition[];
|
||||
layers: Record<string, boolean>;
|
||||
osintFeed: OsintItem[];
|
||||
currentTime: number;
|
||||
koreaFilters: KoreaFiltersState;
|
||||
transshipSuspects: Set<string>;
|
||||
cableWatchSuspects: Set<string>;
|
||||
dokdoWatchSuspects: Set<string>;
|
||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||
}
|
||||
|
||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||
const MAP_STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
'satellite': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© Esri, Maxar',
|
||||
},
|
||||
'carto-dark': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
},
|
||||
'opensea': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0d1f3c' } },
|
||||
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } },
|
||||
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } },
|
||||
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } },
|
||||
],
|
||||
};
|
||||
|
||||
// Korea-centered view
|
||||
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
|
||||
const KOREA_MAP_ZOOM = 6;
|
||||
|
||||
const FILTER_ICON: Record<string, string> = {
|
||||
illegalFishing: '\u{1F6AB}\u{1F41F}',
|
||||
illegalTransship: '\u2693',
|
||||
darkVessel: '\u{1F47B}',
|
||||
cableWatch: '\u{1F50C}',
|
||||
dokdoWatch: '\u{1F3DD}\uFE0F',
|
||||
ferryWatch: '\u{1F6A2}',
|
||||
};
|
||||
|
||||
const FILTER_COLOR: Record<string, string> = {
|
||||
illegalFishing: '#ef4444',
|
||||
illegalTransship: '#f97316',
|
||||
darkVessel: '#8b5cf6',
|
||||
cableWatch: '#00e5ff',
|
||||
dokdoWatch: '#22c55e',
|
||||
ferryWatch: '#2196f3',
|
||||
};
|
||||
|
||||
const FILTER_I18N_KEY: Record<string, string> = {
|
||||
illegalFishing: 'filters.illegalFishingMonitor',
|
||||
illegalTransship: 'filters.illegalTransshipMonitor',
|
||||
darkVessel: 'filters.darkVesselMonitor',
|
||||
cableWatch: 'filters.cableWatchMonitor',
|
||||
dokdoWatch: 'filters.dokdoWatchMonitor',
|
||||
ferryWatch: 'filters.ferryWatchMonitor',
|
||||
};
|
||||
|
||||
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
||||
<Layer
|
||||
id="country-label-lg"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 1]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 15,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 6,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#e2e8f0',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 2,
|
||||
'text-opacity': 0.9,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-md"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 2]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 12,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 4,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#94a3b8',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-sm"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 3]}
|
||||
minzoom={5}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 10,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 2,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#64748b',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1,
|
||||
'text-opacity': 0.75,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
|
||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
<div
|
||||
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
|
||||
style={{
|
||||
background: 'rgba(249,115,22,0.9)', color: '#fff',
|
||||
padding: '1px 5px',
|
||||
border: '1px solid #f97316',
|
||||
textShadow: '0 0 2px #000',
|
||||
}}
|
||||
>
|
||||
{`\u26A0 ${t('korea.transshipSuspect')}`}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Cable watch suspect labels */}
|
||||
{cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`cw-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
<div
|
||||
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
|
||||
style={{
|
||||
background: 'rgba(0,229,255,0.9)', color: '#000',
|
||||
padding: '1px 5px',
|
||||
border: '1px solid #00e5ff',
|
||||
textShadow: '0 0 2px rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
{`\u{1F50C} ${t('korea.cableDanger')}`}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Dokdo watch labels (Japanese vessels) */}
|
||||
{dokdoWatchSuspects.size > 0 && ships.filter(s => dokdoWatchSuspects.has(s.mmsi)).map(s => {
|
||||
const dist = Math.round(Math.hypot(
|
||||
(s.lng - 131.8647) * Math.cos((37.2417 * Math.PI) / 180),
|
||||
s.lat - 37.2417,
|
||||
) * 111);
|
||||
const inTerritorial = dist < 22;
|
||||
return (
|
||||
<Marker key={`dk-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
<div
|
||||
className="rounded-sm text-[9px] font-bold font-mono whitespace-nowrap animate-pulse"
|
||||
style={{
|
||||
background: inTerritorial ? 'rgba(239,68,68,0.95)' : 'rgba(234,179,8,0.9)',
|
||||
color: '#fff',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${inTerritorial ? '#ef4444' : '#eab308'}`,
|
||||
textShadow: '0 0 2px #000',
|
||||
}}
|
||||
>
|
||||
{inTerritorial ? '\u{1F6A8}' : '\u26A0'} {'\u{1F1EF}\u{1F1F5}'} {inTerritorial ? t('korea.dokdoIntrusion') : t('korea.dokdoApproach')} {`${dist}km`}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
{layers.infra && infra.length > 0 && <InfraLayer facilities={infra} />}
|
||||
{layers.satellites && satellites.length > 0 && <SatelliteLayer satellites={satellites} />}
|
||||
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.cables && <SubmarineCableLayer />}
|
||||
{layers.cctv && <CctvLayer />}
|
||||
{layers.airports && <KoreaAirportLayer />}
|
||||
{layers.coastGuard && <CoastGuardLayer />}
|
||||
{layers.navWarning && <NavWarningLayer />}
|
||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||
{layers.eez && <EezLayer />}
|
||||
{layers.piracy && <PiracyLayer />}
|
||||
|
||||
{/* Filter Status Banner */}
|
||||
{(() => {
|
||||
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
|
||||
if (active.length === 0) return null;
|
||||
return (
|
||||
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
|
||||
{active.map(k => {
|
||||
const color = FILTER_COLOR[k];
|
||||
return (
|
||||
<div
|
||||
key={k}
|
||||
className="rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 animate-pulse"
|
||||
style={{
|
||||
background: `${color}22`, border: `1px solid ${color}88`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<span className="text-[13px]">{FILTER_ICON[k]}</span>
|
||||
{t(FILTER_I18N_KEY[k])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="rounded-lg px-3 py-1.5 font-mono text-xs font-bold flex items-center bg-kcg-glass border border-kcg-border-light text-white">
|
||||
{t('korea.detected', { count: ships.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Dokdo alert panel */}
|
||||
{dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
|
||||
<div className="absolute top-2.5 right-[50px] z-20 rounded-lg border border-kcg-danger px-2.5 py-2 font-mono text-[11px] min-w-[220px] max-h-[200px] overflow-y-auto bg-kcg-overlay backdrop-blur-lg shadow-[0_0_20px_rgba(239,68,68,0.3)]">
|
||||
<div className="font-bold text-[10px] text-kcg-danger mb-1.5 tracking-widest flex items-center gap-1">
|
||||
{`\u{1F6A8} ${t('korea.dokdoAlerts')}`}
|
||||
</div>
|
||||
{dokdoAlerts.map((a, i) => (
|
||||
<div key={`${a.mmsi}-${i}`} className="flex flex-col gap-0.5" style={{
|
||||
padding: '4px 0', borderBottom: i < dokdoAlerts.length - 1 ? '1px solid #222' : 'none',
|
||||
}}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-[10px]" style={{ color: a.dist < 22 ? '#ef4444' : '#eab308' }}>
|
||||
{a.dist < 22 ? `\u{1F6A8} ${t('korea.territorialIntrusion')}` : `\u26A0 ${t('korea.approachWarning')}`}
|
||||
</span>
|
||||
<span className="text-kcg-dim text-[9px]">
|
||||
{new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-kcg-text-secondary text-[10px]">
|
||||
{`\u{1F1EF}\u{1F1F5} ${a.name} \u2014 ${t('korea.dokdoDistance', { dist: a.dist })}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
377
frontend/src/components/LayerPanel.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||
const AC_CAT_COLORS: Record<string, string> = {
|
||||
fighter: '#ff4444',
|
||||
military: '#ff6600',
|
||||
surveillance: '#ffcc00',
|
||||
tanker: '#00ccff',
|
||||
cargo: '#a78bfa',
|
||||
civilian: '#FFD700',
|
||||
unknown: '#7CFC00',
|
||||
};
|
||||
|
||||
// Altitude color legend (matches AircraftLayer gradient)
|
||||
const ALT_LEGEND: [string, string][] = [
|
||||
['Ground', '#555555'],
|
||||
['< 2,000ft', '#00c000'],
|
||||
['2,000ft', '#55EC55'],
|
||||
['4,000ft', '#7CFC00'],
|
||||
['6,000ft', '#BFFF00'],
|
||||
['10,000ft', '#FFFF00'],
|
||||
['20,000ft', '#FFD700'],
|
||||
['30,000ft', '#FF8C00'],
|
||||
['40,000ft', '#FF4500'],
|
||||
['50,000ft+', '#BA55D3'],
|
||||
];
|
||||
|
||||
// Military color legend
|
||||
const MIL_LEGEND: [string, string][] = [
|
||||
['Fighter', '#ff4444'],
|
||||
['Military', '#ff6600'],
|
||||
['ISR / Surveillance', '#ffcc00'],
|
||||
['Tanker', '#00ccff'],
|
||||
];
|
||||
|
||||
// Ship MT category color (matches ShipLayer MT_TYPE_COLORS)
|
||||
const MT_CAT_COLORS: Record<string, string> = {
|
||||
cargo: 'var(--kcg-ship-cargo)',
|
||||
tanker: 'var(--kcg-ship-tanker)',
|
||||
passenger: 'var(--kcg-ship-passenger)',
|
||||
fishing: 'var(--kcg-ship-fishing)',
|
||||
military: 'var(--kcg-ship-military)',
|
||||
tug_special: 'var(--kcg-ship-tug)',
|
||||
high_speed: 'var(--kcg-ship-highspeed)',
|
||||
pleasure: 'var(--kcg-ship-pleasure)',
|
||||
other: 'var(--kcg-ship-other)',
|
||||
unspecified: 'var(--kcg-ship-unknown)',
|
||||
unknown: 'var(--kcg-ship-unknown)',
|
||||
};
|
||||
|
||||
// Ship type color legend (MarineTraffic style)
|
||||
const SHIP_TYPE_LEGEND: [string, string][] = [
|
||||
['cargo', 'var(--kcg-ship-cargo)'],
|
||||
['tanker', 'var(--kcg-ship-tanker)'],
|
||||
['passenger', 'var(--kcg-ship-passenger)'],
|
||||
['fishing', 'var(--kcg-ship-fishing)'],
|
||||
['pleasure', 'var(--kcg-ship-pleasure)'],
|
||||
['military', 'var(--kcg-ship-military)'],
|
||||
['tug_special', 'var(--kcg-ship-tug)'],
|
||||
['other', 'var(--kcg-ship-other)'],
|
||||
['unspecified', 'var(--kcg-ship-unknown)'],
|
||||
];
|
||||
|
||||
const AC_CATEGORIES = ['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian', 'unknown'] as const;
|
||||
const MT_CATEGORIES = ['cargo', 'tanker', 'passenger', 'fishing', 'military', 'tug_special', 'high_speed', 'pleasure', 'other', 'unspecified'] as const;
|
||||
|
||||
interface ExtraLayer {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
aircraftByCategory: Record<string, number>;
|
||||
aircraftTotal: number;
|
||||
shipsByMtCategory: Record<string, number>;
|
||||
shipTotal: number;
|
||||
satelliteCount: number;
|
||||
extraLayers?: ExtraLayer[];
|
||||
hiddenAcCategories: Set<string>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
onAcCategoryToggle: (cat: string) => void;
|
||||
onShipCategoryToggle: (cat: string) => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({
|
||||
layers,
|
||||
onToggle,
|
||||
aircraftByCategory,
|
||||
aircraftTotal,
|
||||
shipsByMtCategory,
|
||||
shipTotal,
|
||||
satelliteCount,
|
||||
extraLayers,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
onAcCategoryToggle,
|
||||
onShipCategoryToggle,
|
||||
}: LayerPanelProps) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(['aircraft', 'ships']));
|
||||
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((key: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleLegend = useCallback((key: string) => {
|
||||
setLegendOpen(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) { next.delete(key); } else { next.add(key); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const militaryCount = Object.entries(aircraftByCategory)
|
||||
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
|
||||
.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
||||
return (
|
||||
<div className="layer-panel">
|
||||
<h3>LAYERS</h3>
|
||||
<div className="layer-items">
|
||||
{/* Aircraft tree */}
|
||||
<LayerTreeItem
|
||||
layerKey="aircraft"
|
||||
label={`${t('layers.aircraft')} (${aircraftTotal})`}
|
||||
color="#22d3ee"
|
||||
active={layers.aircraft}
|
||||
expandable
|
||||
isExpanded={expanded.has('aircraft')}
|
||||
onToggle={() => onToggle('aircraft')}
|
||||
onExpand={() => toggleExpand('aircraft')}
|
||||
/>
|
||||
{layers.aircraft && expanded.has('aircraft') && (
|
||||
<div className="layer-tree-children">
|
||||
{AC_CATEGORIES.map(cat => {
|
||||
const count = aircraftByCategory[cat] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={cat}
|
||||
label={t(`ships:aircraft.${cat}`, cat.toUpperCase())}
|
||||
color={AC_CAT_COLORS[cat] || '#888'}
|
||||
count={count}
|
||||
hidden={hiddenAcCategories.has(cat)}
|
||||
onClick={() => onAcCategoryToggle(cat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Altitude legend */}
|
||||
<button
|
||||
type="button"
|
||||
className="legend-toggle"
|
||||
onClick={() => toggleLegend('altitude')}
|
||||
>
|
||||
{legendOpen.has('altitude') ? '\u25BC' : '\u25B6'} {t('legend.altitude')}
|
||||
</button>
|
||||
{legendOpen.has('altitude') && (
|
||||
<div className="legend-content">
|
||||
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||
{ALT_LEGEND.map(([label, color]) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className="text-kcg-text">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Military legend */}
|
||||
<button
|
||||
type="button"
|
||||
className="legend-toggle"
|
||||
onClick={() => toggleLegend('military')}
|
||||
>
|
||||
{legendOpen.has('military') ? '\u25BC' : '\u25B6'} {t('legend.military')}
|
||||
</button>
|
||||
{legendOpen.has('military') && (
|
||||
<div className="legend-content">
|
||||
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||
{MIL_LEGEND.map(([label, color]) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 shrink-0 rounded-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className="text-kcg-text">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ships tree */}
|
||||
<LayerTreeItem
|
||||
layerKey="ships"
|
||||
label={`${t('layers.ships')} (${shipTotal})`}
|
||||
color="#fb923c"
|
||||
active={layers.ships}
|
||||
expandable
|
||||
isExpanded={expanded.has('ships')}
|
||||
onToggle={() => onToggle('ships')}
|
||||
onExpand={() => toggleExpand('ships')}
|
||||
/>
|
||||
{layers.ships && expanded.has('ships') && (
|
||||
<div className="layer-tree-children">
|
||||
{MT_CATEGORIES.map(cat => {
|
||||
const count = shipsByMtCategory[cat] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={cat}
|
||||
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
|
||||
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
|
||||
count={count}
|
||||
hidden={hiddenShipCategories.has(cat)}
|
||||
onClick={() => onShipCategoryToggle(cat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ship type legend */}
|
||||
<button
|
||||
type="button"
|
||||
className="legend-toggle"
|
||||
onClick={() => toggleLegend('shipType')}
|
||||
>
|
||||
{legendOpen.has('shipType') ? '\u25BC' : '\u25B6'} {t('legend.vesselType')}
|
||||
</button>
|
||||
{legendOpen.has('shipType') && (
|
||||
<div className="legend-content">
|
||||
<div className="flex flex-col gap-px text-[9px] opacity-85">
|
||||
{SHIP_TYPE_LEGEND.map(([key, color]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: 0, height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottom: `10px solid ${color}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-kcg-text">{t(`ships:mtType.${key}`, key)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Satellites (simple toggle) */}
|
||||
<LayerTreeItem
|
||||
layerKey="satellites"
|
||||
label={`${t('layers.satellites')} (${satelliteCount})`}
|
||||
color="#ef4444"
|
||||
active={layers.satellites}
|
||||
onToggle={() => onToggle('satellites')}
|
||||
/>
|
||||
|
||||
{/* Extra layers (tab-specific) */}
|
||||
{extraLayers && extraLayers.map(el => (
|
||||
<LayerTreeItem
|
||||
key={el.key}
|
||||
layerKey={el.key}
|
||||
label={el.count != null ? `${el.label} (${el.count})` : el.label}
|
||||
color={el.color}
|
||||
active={layers[el.key] ?? false}
|
||||
onToggle={() => onToggle(el.key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="layer-divider" />
|
||||
|
||||
{/* Military only filter */}
|
||||
<LayerTreeItem
|
||||
layerKey="militaryOnly"
|
||||
label={`${t('layers.militaryOnly')} (${militaryCount})`}
|
||||
color="#f97316"
|
||||
active={layers.militaryOnly ?? false}
|
||||
onToggle={() => onToggle('militaryOnly')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────────── */
|
||||
|
||||
function LayerTreeItem({
|
||||
layerKey,
|
||||
label,
|
||||
color,
|
||||
active,
|
||||
expandable,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onExpand,
|
||||
}: {
|
||||
layerKey: string;
|
||||
label: string;
|
||||
color: string;
|
||||
active: boolean;
|
||||
expandable?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle: () => void;
|
||||
onExpand?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="layer-tree-header" data-layer={layerKey}>
|
||||
{expandable ? (
|
||||
<span
|
||||
className={`layer-tree-arrow ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onExpand?.(); }}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="layer-tree-arrow" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-toggle ${active ? 'active' : ''}`}
|
||||
onClick={onToggle}
|
||||
style={{ padding: 0, gap: '6px' }}
|
||||
>
|
||||
<span
|
||||
className="layer-dot"
|
||||
style={{ backgroundColor: active ? color : '#444' }}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryToggle({
|
||||
label,
|
||||
color,
|
||||
count,
|
||||
hidden,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
hidden: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`category-toggle ${hidden ? 'hidden' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="category-dot" style={{ backgroundColor: color }} />
|
||||
<span className="category-label">{label}</span>
|
||||
<span className="category-count">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/LiveControls.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
historyMinutes: number;
|
||||
onHistoryChange: (minutes: number) => void;
|
||||
aircraftCount: number;
|
||||
shipCount: number;
|
||||
satelliteCount: number;
|
||||
}
|
||||
|
||||
const HISTORY_PRESETS = [
|
||||
{ label: '30M', minutes: 30 },
|
||||
{ label: '1H', minutes: 60 },
|
||||
{ label: '3H', minutes: 180 },
|
||||
{ label: '6H', minutes: 360 },
|
||||
{ label: '12H', minutes: 720 },
|
||||
{ label: '24H', minutes: 1440 },
|
||||
];
|
||||
|
||||
export function LiveControls({
|
||||
currentTime,
|
||||
historyMinutes,
|
||||
onHistoryChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
||||
|
||||
return (
|
||||
<div className="live-controls">
|
||||
<div className="live-indicator">
|
||||
<span className="live-dot" />
|
||||
<span className="live-label">{t('header.live')}</span>
|
||||
</div>
|
||||
|
||||
<div className="live-clock">{kstTime}</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="history-controls">
|
||||
<span className="history-label">{t('time.history')}</span>
|
||||
<div className="history-presets">
|
||||
{HISTORY_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={`history-btn ${historyMinutes === p.minutes ? 'active' : ''}`}
|
||||
onClick={() => onHistoryChange(p.minutes)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/NavWarningLayer.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
|
||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
|
||||
|
||||
const LEVEL_COLOR: Record<NavWarningLevel, string> = {
|
||||
danger: '#ef4444',
|
||||
caution: '#eab308',
|
||||
info: '#3b82f6',
|
||||
};
|
||||
|
||||
const ORG_COLOR: Record<TrainingOrg, string> = {
|
||||
'해군': '#8b5cf6',
|
||||
'해병대': '#22c55e',
|
||||
'공군': '#f97316',
|
||||
'육군': '#ef4444',
|
||||
'해경': '#3b82f6',
|
||||
'국과연': '#eab308',
|
||||
};
|
||||
|
||||
function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) {
|
||||
const color = ORG_COLOR[org];
|
||||
|
||||
if (level === 'danger') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="12" y1="9" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="8" x2="12" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="12" cy="16" r="1" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavWarningLayer() {
|
||||
const [selected, setSelected] = useState<NavWarning | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{NAV_WARNINGS.map(w => {
|
||||
const color = ORG_COLOR[w.org];
|
||||
const size = w.level === 'danger' ? 16 : 14;
|
||||
return (
|
||||
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 4px ${color}88)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<WarningIcon level={w.level} org={w.org} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="whitespace-nowrap font-bold tracking-wide">
|
||||
{w.id}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="min-w-[240px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-xs font-bold text-white">
|
||||
{selected.title}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1.5 block text-[10px] text-kcg-accent underline"
|
||||
>{t('navWarning.khoaLink')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
310
frontend/src/components/OilFacilityLayer.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { OilFacility, OilFacilityType } from '../types';
|
||||
|
||||
interface Props {
|
||||
facilities: OilFacility[];
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<OilFacilityType, string> = {
|
||||
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
|
||||
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
||||
};
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function getTooltipLabel(f: OilFacility): string {
|
||||
if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`;
|
||||
if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`;
|
||||
if (f.reservesBbl) return `${f.reservesBbl}B bbl`;
|
||||
if (f.reservesTcf) return `${f.reservesTcf} Tcf`;
|
||||
if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function getIconSize(f: OilFacility): number {
|
||||
if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; }
|
||||
if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16;
|
||||
if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; }
|
||||
if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; }
|
||||
if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; }
|
||||
return 16;
|
||||
}
|
||||
|
||||
// Shared damage overlay (X mark + circle)
|
||||
function DamageOverlay() {
|
||||
return (
|
||||
<>
|
||||
<line x1={4} y1={4} x2={32} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
|
||||
<line x1={32} y1={4} x2={4} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
|
||||
<circle cx={18} cy={18} r={15} fill="none" stroke="#ff0000" strokeWidth={1.5} opacity={0.4} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// SVG icon renderers (JSX versions)
|
||||
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<defs>
|
||||
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.2} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
|
||||
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
|
||||
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
|
||||
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
|
||||
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
|
||||
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
|
||||
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
|
||||
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
|
||||
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
|
||||
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
||||
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
|
||||
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
|
||||
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
|
||||
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
|
||||
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
|
||||
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
|
||||
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
|
||||
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
|
||||
fill={color} opacity={0.85} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
|
||||
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
|
||||
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
|
||||
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
|
||||
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
|
||||
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<circle cx={18} cy={10} r={4} fill="none" stroke={damaged ? '#ff0000' : color} strokeWidth={2} />
|
||||
<line x1={18} y1={14} x2={18} y2={28} stroke={color} strokeWidth={2} />
|
||||
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke={color} strokeWidth={2} />
|
||||
<line x1={18} y1={8} x2={18} y2={6} stroke={color} strokeWidth={2} />
|
||||
<line x1={16} y1={6} x2={20} y2={6} stroke={color} strokeWidth={2.5} />
|
||||
<path d="M6 24 L10 24" stroke={color} strokeWidth={1.5} />
|
||||
<path d="M26 24 L30 24" stroke={color} strokeWidth={1.5} />
|
||||
<polygon points="5,24 8,22 8,26" fill={color} opacity={0.7} />
|
||||
<polygon points="31,24 28,22 28,26" fill={color} opacity={0.7} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill={color} opacity={0.7} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={1} />
|
||||
<rect x={13} y={4} width={10} height={4} rx={1} fill={color} opacity={0.9} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={0.8} />
|
||||
<path d="M11 22 L25 22 L28 30 L8 30Z" fill={color} opacity={0.5} />
|
||||
<circle cx={16} cy={25} r={1.5} fill="#c4b5fd" opacity={0.7} />
|
||||
<circle cx={20} cy={23} r={1} fill="#c4b5fd" opacity={0.6} />
|
||||
<circle cx={18} cy={27} r={1.2} fill="#c4b5fd" opacity={0.5} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||
const sc = damaged ? '#ff0000' : color;
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
|
||||
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
|
||||
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
|
||||
fill={color} opacity={0.3} />
|
||||
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
|
||||
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
|
||||
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
|
||||
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
|
||||
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
|
||||
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
|
||||
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
|
||||
stroke={sc} strokeWidth={1} />
|
||||
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
||||
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
||||
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
|
||||
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
|
||||
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
|
||||
{damaged && <DamageOverlay />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) {
|
||||
const color = TYPE_COLORS[facility.type];
|
||||
const size = getIconSize(facility);
|
||||
switch (facility.type) {
|
||||
case 'refinery': return <RefineryIcon size={size} color={color} damaged={damaged} />;
|
||||
case 'oilfield': return <OilFieldIcon size={size} color={color} damaged={damaged} />;
|
||||
case 'gasfield': return <GasFieldIcon size={size} color={color} damaged={damaged} />;
|
||||
case 'terminal': return <TerminalIcon size={size} color={color} damaged={damaged} />;
|
||||
case 'petrochemical': return <PetrochemIcon size={size} color={color} damaged={damaged} />;
|
||||
case 'desalination': return <DesalIcon size={size} color={color} damaged={damaged} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) {
|
||||
return (
|
||||
<>
|
||||
{facilities.map(f => (
|
||||
<FacilityMarker key={f.id} facility={f} currentTime={currentTime} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const color = TYPE_COLORS[facility.type];
|
||||
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
|
||||
const isPlanned = !!facility.planned && !isDamaged;
|
||||
const stat = getTooltipLabel(facility);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
|
||||
<div className="relative">
|
||||
{/* Planned strike targeting ring */}
|
||||
{isPlanned && (
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
|
||||
style={{
|
||||
border: '2px dashed #ff6600',
|
||||
animation: 'planned-pulse 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* Crosshair lines */}
|
||||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||
</div>
|
||||
)}
|
||||
<div className="cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
||||
<FacilityIconSvg facility={facility} damaged={isDamaged} />
|
||||
</div>
|
||||
<div className="gl-marker-label text-[8px]" style={{
|
||||
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
|
||||
}}>
|
||||
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
|
||||
{stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
{showPopup && (
|
||||
<Popup longitude={facility.lng} latitude={facility.lat}
|
||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="min-w-[220px] font-mono text-xs">
|
||||
<div className="flex gap-1 items-center mb-1.5">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
|
||||
style={{ background: color }}
|
||||
>{t(`facility.type.${facility.type}`)}</span>
|
||||
{isDamaged && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
|
||||
{t('facility.damaged')}
|
||||
</span>
|
||||
)}
|
||||
{isPlanned && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
|
||||
{t('facility.plannedStrike')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
|
||||
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
|
||||
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{facility.capacityBpd != null && (
|
||||
<><span className="text-kcg-muted">{t('facility.production')}</span>
|
||||
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
|
||||
)}
|
||||
{facility.capacityMgd != null && (
|
||||
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
|
||||
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
|
||||
)}
|
||||
{facility.capacityMcfd != null && (
|
||||
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
|
||||
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
|
||||
)}
|
||||
{facility.reservesBbl != null && (
|
||||
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
|
||||
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
|
||||
)}
|
||||
{facility.reservesTcf != null && (
|
||||
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
|
||||
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
|
||||
)}
|
||||
{facility.operator && (
|
||||
<><span className="text-kcg-muted">{t('facility.operator')}</span>
|
||||
<span className="text-white">{facility.operator}</span></>
|
||||
)}
|
||||
</div>
|
||||
{facility.description && (
|
||||
<p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
|
||||
)}
|
||||
{isPlanned && facility.plannedLabel && (
|
||||
<div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
|
||||
{facility.plannedLabel}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/OsintMapLayer.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import type { OsintItem } from '../services/osint';
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
maritime_accident: '#ef4444',
|
||||
fishing: '#22c55e',
|
||||
maritime_traffic: '#3b82f6',
|
||||
military: '#f97316',
|
||||
shipping: '#eab308',
|
||||
};
|
||||
|
||||
const CAT_ICON: Record<string, string> = {
|
||||
maritime_accident: '🚨',
|
||||
fishing: '🐟',
|
||||
maritime_traffic: '🚢',
|
||||
military: '🎯',
|
||||
shipping: '🚢',
|
||||
};
|
||||
|
||||
function useTimeAgo() {
|
||||
const { t } = useTranslation();
|
||||
return (ts: number): string => {
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 60) return t('time.minutesAgo', { count: m });
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return t('time.hoursAgo', { count: h });
|
||||
return t('time.daysAgo', { count: Math.floor(h / 24) });
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
osintFeed: OsintItem[];
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
||||
const ONE_HOUR = 3600000;
|
||||
const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffic', 'shipping', 'military']);
|
||||
|
||||
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
||||
const [selected, setSelected] = useState<OsintItem | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = useTimeAgo();
|
||||
|
||||
const geoItems = useMemo(() => osintFeed.filter(
|
||||
(item): item is OsintItem & { lat: number; lng: number } =>
|
||||
item.lat != null && item.lng != null
|
||||
&& MAP_CATEGORIES.has(item.category)
|
||||
&& (currentTime - item.timestamp) < THREE_HOURS
|
||||
), [osintFeed, currentTime]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{geoItems.map(item => {
|
||||
const color = CAT_COLOR[item.category] || '#888';
|
||||
const isRecent = currentTime - item.timestamp < ONE_HOUR;
|
||||
return (
|
||||
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 6px ${color}aa)`,
|
||||
}} className="flex flex-col items-center">
|
||||
<div style={{
|
||||
border: `2px solid ${color}`,
|
||||
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
}} className="flex size-[22px] items-center justify-center rounded-full bg-black/60 text-xs">
|
||||
{CAT_ICON[item.category] || '📰'}
|
||||
</div>
|
||||
{isRecent && (
|
||||
<div style={{
|
||||
fontSize: 5, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px font-bold tracking-wide">
|
||||
NEW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && selected.lat != null && selected.lng != null && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="min-w-[240px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: CAT_COLOR[selected.category] || '#888',
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[11px] font-bold text-white">
|
||||
<span>{CAT_ICON[selected.category] || '📰'}</span>
|
||||
OSINT
|
||||
</div>
|
||||
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||
{selected.title}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: CAT_COLOR[selected.category] || '#888',
|
||||
}} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
|
||||
{selected.category.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
|
||||
{selected.source}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
|
||||
{timeAgo(selected.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{selected.url && (
|
||||
<a
|
||||
href={selected.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-kcg-accent underline"
|
||||
>{t('osintMap.viewOriginal')}</a>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/PiracyLayer.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
|
||||
import type { PiracyZone } from '../services/piracy';
|
||||
|
||||
function SkullIcon({ color, size }: { color: string; size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
||||
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
||||
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PiracyLayer() {
|
||||
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{PIRACY_ZONES.map(zone => {
|
||||
const color = PIRACY_LEVEL_COLOR[zone.level];
|
||||
const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20;
|
||||
return (
|
||||
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
||||
<div style={{
|
||||
cursor: 'pointer',
|
||||
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
||||
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||
}} className="flex flex-col items-center">
|
||||
<SkullIcon color={color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 7, color,
|
||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
|
||||
{PIRACY_LEVEL_LABEL[zone.level]}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[260px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
|
||||
<span className="text-sm">☠️</span>
|
||||
{selected.nameKo}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<span style={{
|
||||
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.name}
|
||||
</span>
|
||||
{selected.recentIncidents != null && (
|
||||
<span style={{
|
||||
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="text-[10px] leading-snug text-[#999]">
|
||||
{selected.detail}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
170
frontend/src/components/ReplayControls.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onReset: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onRangeChange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
const SPEEDS = [1, 2, 4, 8, 16];
|
||||
|
||||
// Preset ranges relative to T0 (main strike moment)
|
||||
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
|
||||
const HOUR = 3600_000;
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '24H', start: T0 - 12 * HOUR, end: T0 + 12 * HOUR },
|
||||
{ label: '12H', start: T0 - 6 * HOUR, end: T0 + 6 * HOUR },
|
||||
{ label: '6H', start: T0 - 3 * HOUR, end: T0 + 3 * HOUR },
|
||||
{ label: '2H', start: T0 - HOUR, end: T0 + HOUR },
|
||||
{ label: '30M', start: T0 - 15 * 60_000, end: T0 + 15 * 60_000 },
|
||||
];
|
||||
|
||||
const KST_OFFSET = 9 * 3600_000; // KST = UTC+9
|
||||
|
||||
function toKSTInput(ts: number): string {
|
||||
// Format as datetime-local value in KST: YYYY-MM-DDTHH:MM
|
||||
const d = new Date(ts + KST_OFFSET);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
function fromKSTInput(val: string): number {
|
||||
// Parse datetime-local as KST → convert to UTC
|
||||
return new Date(val + 'Z').getTime() - KST_OFFSET;
|
||||
}
|
||||
|
||||
export function ReplayControls({
|
||||
isPlaying,
|
||||
speed,
|
||||
startTime,
|
||||
endTime,
|
||||
onPlay,
|
||||
onPause,
|
||||
onReset,
|
||||
onSpeedChange,
|
||||
onRangeChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
|
||||
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
|
||||
|
||||
const handlePreset = useCallback((preset: typeof PRESETS[number]) => {
|
||||
onRangeChange(preset.start, preset.end);
|
||||
setCustomStart(toKSTInput(preset.start));
|
||||
setCustomEnd(toKSTInput(preset.end));
|
||||
}, [onRangeChange]);
|
||||
|
||||
const handleCustomApply = useCallback(() => {
|
||||
const s = fromKSTInput(customStart);
|
||||
const e = fromKSTInput(customEnd);
|
||||
if (s < e) {
|
||||
onRangeChange(s, e);
|
||||
setShowPicker(false);
|
||||
}
|
||||
}, [customStart, customEnd, onRangeChange]);
|
||||
|
||||
// Find which preset is active
|
||||
const activePreset = PRESETS.find(p => p.start === startTime && p.end === endTime);
|
||||
|
||||
return (
|
||||
<div className="replay-controls">
|
||||
{/* Left: transport controls */}
|
||||
<button className="ctrl-btn" onClick={onReset} title={t('controls.reset')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 4v6h6" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="ctrl-btn play-btn" onClick={isPlaying ? onPause : onPlay}>
|
||||
{isPlaying ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="speed-controls">
|
||||
{SPEEDS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`speed-btn ${speed === s ? 'active' : ''}`}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: range presets + custom picker */}
|
||||
<div className="range-controls">
|
||||
<div className="range-presets">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={`range-btn ${activePreset === p ? 'active' : ''}`}
|
||||
onClick={() => handlePreset(p)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
title={t('controls.customRange')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPicker && (
|
||||
<div className="range-picker">
|
||||
<div className="range-picker-row">
|
||||
<label>
|
||||
<span>{t('controls.from')}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={e => setCustomStart(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('controls.to')}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={e => setCustomEnd(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="range-apply-btn" onClick={handleCustomApply}>
|
||||
{t('controls.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
frontend/src/components/ReplayMap.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { DamagedShipLayer } from './DamagedShipLayer';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { iranOilFacilities } from '../data/oilFacilities';
|
||||
import { middleEastAirports } from '../data/airports';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types';
|
||||
import { countryLabelsGeoJSON } from '../data/countryLabels';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
export interface FlyToTarget {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: GeoEvent[];
|
||||
currentTime: number;
|
||||
aircraft: Aircraft[];
|
||||
satellites: SatellitePosition[];
|
||||
ships: Ship[];
|
||||
layers: LayerVisibility;
|
||||
flyToTarget?: FlyToTarget | null;
|
||||
onFlyToDone?: () => void;
|
||||
initialCenter?: { lng: number; lat: number };
|
||||
initialZoom?: number;
|
||||
}
|
||||
|
||||
// MarineTraffic-style: dark ocean + satellite land + nautical overlay
|
||||
const MAP_STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
'satellite': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© Esri, Maxar',
|
||||
},
|
||||
'carto-dark': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
},
|
||||
'opensea': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0b1526' } },
|
||||
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.45, 'raster-saturation': -0.3, 'raster-contrast': 0.1 } },
|
||||
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.55 } },
|
||||
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.6 } },
|
||||
],
|
||||
};
|
||||
|
||||
const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
airstrike: '#ef4444',
|
||||
explosion: '#f97316',
|
||||
missile_launch: '#eab308',
|
||||
intercept: '#3b82f6',
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
US: '#ef4444',
|
||||
IL: '#22c55e',
|
||||
IR: '#ff0000',
|
||||
proxy: '#f59e0b',
|
||||
};
|
||||
|
||||
function getEventColor(event: GeoEvent): string {
|
||||
if (event.type === 'impact') return '#ff0000';
|
||||
if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source];
|
||||
return EVENT_COLORS[event.type];
|
||||
}
|
||||
|
||||
const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
||||
airstrike: 12,
|
||||
explosion: 10,
|
||||
missile_launch: 8,
|
||||
intercept: 7,
|
||||
alert: 6,
|
||||
impact: 14,
|
||||
osint: 8,
|
||||
};
|
||||
|
||||
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
mapRef.current.flyTo({
|
||||
center: [flyToTarget.lng, flyToTarget.lat],
|
||||
zoom: flyToTarget.zoom ?? 8,
|
||||
duration: 1200,
|
||||
});
|
||||
onFlyToDone?.();
|
||||
}
|
||||
}, [flyToTarget, onFlyToDone]);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => events.filter(e => e.timestamp <= currentTime),
|
||||
[events, currentTime],
|
||||
);
|
||||
|
||||
const impactEvents = useMemo(
|
||||
() => visibleEvents.filter(e => e.type === 'impact'),
|
||||
[visibleEvents],
|
||||
);
|
||||
const otherEvents = useMemo(
|
||||
() => visibleEvents.filter(e => e.type !== 'impact'),
|
||||
[visibleEvents],
|
||||
);
|
||||
|
||||
const newEvents = useMemo(
|
||||
() => visibleEvents.filter(e => {
|
||||
const age = currentTime - e.timestamp;
|
||||
return age >= 0 && age < 600_000;
|
||||
}),
|
||||
[visibleEvents, currentTime],
|
||||
);
|
||||
|
||||
const justActivated = useMemo(
|
||||
() => visibleEvents.filter(e => {
|
||||
const age = currentTime - e.timestamp;
|
||||
return age >= 0 && age < 120_000 &&
|
||||
(e.type === 'airstrike' || e.type === 'impact' || e.type === 'explosion');
|
||||
}),
|
||||
[visibleEvents, currentTime],
|
||||
);
|
||||
|
||||
const trajectoryData = useMemo(() => {
|
||||
const launches = visibleEvents.filter(e => e.type === 'missile_launch');
|
||||
const targets = visibleEvents.filter(e => e.type === 'impact' || e.type === 'airstrike' || e.type === 'explosion');
|
||||
if (launches.length === 0 || targets.length === 0) {
|
||||
return { type: 'FeatureCollection' as const, features: [] as GeoJSON.Feature[] };
|
||||
}
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: launches.map(launch => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [[launch.lng, launch.lat], [targets[0].lng, targets[0].lat]],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [visibleEvents]);
|
||||
|
||||
const selectedEvent = selectedEventId
|
||||
? visibleEvents.find(e => e.id === selectedEventId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
||||
<Layer
|
||||
id="country-label-lg"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 1]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 15,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 6,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#e2e8f0',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 2,
|
||||
'text-opacity': 0.9,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-md"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 2]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 12,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 4,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#94a3b8',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1.5,
|
||||
'text-opacity': 0.85,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-sm"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 3]}
|
||||
minzoom={5}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 10,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
'text-padding': 2,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#64748b',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1,
|
||||
'text-opacity': 0.75,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.events && (
|
||||
<>
|
||||
{trajectoryData.features.length > 0 && (
|
||||
<Source id="trajectories" type="geojson" data={trajectoryData}>
|
||||
<Layer
|
||||
id="trajectory-lines"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#eab308',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.4,
|
||||
'line-dasharray': [8, 4],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{newEvents.map(event => {
|
||||
const color = getEventColor(event);
|
||||
const size = EVENT_RADIUS[event.type] * 5;
|
||||
return (
|
||||
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
|
||||
width: size, height: size,
|
||||
border: `2px solid ${color}`,
|
||||
}} />
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{justActivated.map(event => {
|
||||
const color = getEventColor(event);
|
||||
const size = event.type === 'impact' ? 100 : 70;
|
||||
return (
|
||||
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||
<div className="gl-shockwave rounded-full pointer-events-none" style={{
|
||||
width: size, height: size,
|
||||
border: `3px solid ${color}`,
|
||||
}} />
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{justActivated.map(event => {
|
||||
const color = getEventColor(event);
|
||||
const size = event.type === 'impact' ? 40 : 30;
|
||||
return (
|
||||
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
|
||||
width: size, height: size,
|
||||
background: color,
|
||||
}} />
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{otherEvents.map(event => {
|
||||
const ageMs = currentTime - event.timestamp;
|
||||
const ageHours = ageMs / 3600_000;
|
||||
const DAY_H = 24;
|
||||
const isRecent = ageHours <= DAY_H;
|
||||
const opacity = isRecent
|
||||
? Math.max(0.85, 1 - ageHours * 0.006)
|
||||
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
|
||||
const color = getEventColor(event);
|
||||
const isNew = ageMs >= 0 && ageMs < 600_000;
|
||||
const baseR = EVENT_RADIUS[event.type];
|
||||
const r = isNew ? baseR * 1.3 : isRecent ? baseR * 1.1 : baseR * 0.85;
|
||||
const size = r * 2;
|
||||
|
||||
return (
|
||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||
<div
|
||||
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
|
||||
>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
cx={r} cy={r} r={r - 1}
|
||||
fill={color} fillOpacity={isNew ? 0.9 : isRecent ? opacity * 0.8 : opacity * 0.4}
|
||||
stroke={color} strokeWidth={isNew ? 3 : isRecent ? 2.5 : 1}
|
||||
opacity={isNew ? 1 : opacity}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{impactEvents.map(event => {
|
||||
const ageMs = currentTime - event.timestamp;
|
||||
const ageHours = ageMs / 3600_000;
|
||||
const isRecent = ageHours <= 24;
|
||||
const impactOpacity = isRecent
|
||||
? Math.max(0.8, 1 - ageHours * 0.008)
|
||||
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
|
||||
const s = isRecent ? 22 : 16;
|
||||
const c = s / 2;
|
||||
const sw = isRecent ? 1.5 : 1;
|
||||
return (
|
||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||
<div className="relative cursor-pointer" style={{ opacity: impactOpacity }}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
|
||||
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
|
||||
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||
<circle cx={c} cy={c} r={c * 0.32} fill="none" stroke="#ff0000" strokeWidth={sw * 0.7} opacity={0.9} />
|
||||
<circle cx={c} cy={c} r={c * 0.14} fill="#ff0000" opacity={1} />
|
||||
<line x1={c} y1={0.5} x2={c} y2={c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||
<line x1={c} y1={s - 0.5} x2={c} y2={s - c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||
<line x1={0.5} y1={c} x2={c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||
<line x1={s - 0.5} y1={c} x2={s - c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||
</svg>
|
||||
<div className="gl-impact-label">
|
||||
{isRecent && <span className="gl-new-badge">NEW</span>}
|
||||
{event.label}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedEvent && (
|
||||
<Popup
|
||||
longitude={selectedEvent.lng}
|
||||
latitude={selectedEvent.lat}
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
closeOnClick={false}
|
||||
anchor="bottom"
|
||||
maxWidth="320px"
|
||||
className="gl-popup"
|
||||
>
|
||||
<div className="min-w-[200px] max-w-[320px]">
|
||||
{selectedEvent.source && (
|
||||
<span
|
||||
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||
style={{ background: getEventColor(selectedEvent) }}
|
||||
>
|
||||
{t(`source.${selectedEvent.source}`)}
|
||||
</span>
|
||||
)}
|
||||
{selectedEvent.type === 'impact' && (
|
||||
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||
{t('popup.impactSite')}
|
||||
</div>
|
||||
)}
|
||||
<div><strong>{selectedEvent.label}</strong></div>
|
||||
<span className="text-xs text-kcg-muted">
|
||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||
</span>
|
||||
{selectedEvent.description && (
|
||||
<p className="mt-1.5 mb-0 text-[13px]">{selectedEvent.description}</p>
|
||||
)}
|
||||
{selectedEvent.imageUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={selectedEvent.imageUrl}
|
||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||
className="w-full rounded max-h-[180px] object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{selectedEvent.imageCaption && (
|
||||
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent.type === 'impact' && (
|
||||
<div className="text-[10px] text-kcg-muted mt-1.5">
|
||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.satellites && <SatelliteLayer satellites={satellites} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
|
||||
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
183
frontend/src/components/SatelliteLayer.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { SatellitePosition } from '../types';
|
||||
|
||||
interface Props {
|
||||
satellites: SatellitePosition[];
|
||||
}
|
||||
|
||||
const CAT_COLORS: Record<SatellitePosition['category'], string> = {
|
||||
reconnaissance: '#ef4444',
|
||||
communications: '#3b82f6',
|
||||
navigation: '#22c55e',
|
||||
weather: '#a855f7',
|
||||
other: '#6b7280',
|
||||
};
|
||||
|
||||
const CAT_LABELS: Record<SatellitePosition['category'], string> = {
|
||||
reconnaissance: 'RECON', communications: 'COMMS',
|
||||
navigation: 'NAV', weather: 'WX', other: 'SAT',
|
||||
};
|
||||
|
||||
const SVG_RECON = (
|
||||
<>
|
||||
<rect x={8} y={6} width={8} height={12} rx={1.5} fill="currentColor" opacity={0.9} />
|
||||
<rect x={1} y={8} width={6} height={3} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<rect x={17} y={8} width={6} height={3} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<circle cx={12} cy={16} r={1.8} fill="none" stroke="currentColor" strokeWidth={1} opacity={0.6} />
|
||||
<circle cx={12} cy={16} r={0.6} fill="currentColor" opacity={0.6} />
|
||||
</>
|
||||
);
|
||||
|
||||
const SVG_COMMS = (
|
||||
<>
|
||||
<rect x={9} y={7} width={6} height={10} rx={1} fill="currentColor" opacity={0.9} />
|
||||
<rect x={1} y={9} width={7} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<rect x={16} y={9} width={7} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<path d="M10 4 Q12 2 14 4" fill="none" stroke="currentColor" strokeWidth={1.2} opacity={0.8} />
|
||||
<line x1={12} y1={4} x2={12} y2={7} stroke="currentColor" strokeWidth={0.8} />
|
||||
</>
|
||||
);
|
||||
|
||||
const SVG_NAV = (
|
||||
<>
|
||||
<rect x={9} y={6} width={6} height={12} rx={1} fill="currentColor" opacity={0.9} />
|
||||
<rect x={2} y={8} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<rect x={16} y={8} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<line x1={12} y1={3} x2={12} y2={6} stroke="currentColor" strokeWidth={1} />
|
||||
<circle cx={12} cy={2.5} r={1} fill="currentColor" opacity={0.7} />
|
||||
</>
|
||||
);
|
||||
|
||||
const SVG_WEATHER = (
|
||||
<>
|
||||
<rect x={8} y={7} width={8} height={10} rx={1.5} fill="currentColor" opacity={0.9} />
|
||||
<rect x={1} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<rect x={17} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<circle cx={12} cy={12} r={2.5} fill="none" stroke="currentColor" strokeWidth={0.8} opacity={0.5} />
|
||||
</>
|
||||
);
|
||||
|
||||
const SVG_OTHER = (
|
||||
<>
|
||||
<rect x={9} y={7} width={6} height={10} rx={1} fill="currentColor" opacity={0.9} />
|
||||
<rect x={2} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
<rect x={16} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
|
||||
</>
|
||||
);
|
||||
|
||||
const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
|
||||
reconnaissance: SVG_RECON, communications: SVG_COMMS,
|
||||
navigation: SVG_NAV, weather: SVG_WEATHER, other: SVG_OTHER,
|
||||
};
|
||||
|
||||
export function SatelliteLayer({ satellites }: Props) {
|
||||
const trackData = useMemo(() => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const sat of satellites) {
|
||||
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
|
||||
const color = CAT_COLORS[sat.category];
|
||||
let segment: [number, number][] = [];
|
||||
for (let i = 0; i < sat.groundTrack.length; i++) {
|
||||
const [lat, lng] = sat.groundTrack[i];
|
||||
if (i > 0) {
|
||||
const [, prevLng] = sat.groundTrack[i - 1];
|
||||
if (Math.abs(lng - prevLng) > 180) {
|
||||
if (segment.length > 1) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { color },
|
||||
geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) },
|
||||
});
|
||||
}
|
||||
segment = [];
|
||||
}
|
||||
}
|
||||
segment.push([lat, lng]);
|
||||
}
|
||||
if (segment.length > 1) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { color },
|
||||
geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}, [satellites]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trackData.features.length > 0 && (
|
||||
<Source id="satellite-tracks" type="geojson" data={trackData}>
|
||||
<Layer
|
||||
id="satellite-track-lines"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 1,
|
||||
'line-opacity': 0.25,
|
||||
'line-dasharray': [6, 4],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
{satellites.map(sat => (
|
||||
<SatelliteMarker key={sat.noradId} sat={sat} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const color = CAT_COLORS[sat.category];
|
||||
const svgBody = SVG_MAP[sat.category];
|
||||
const size = 22;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
|
||||
<div className="relative">
|
||||
<div
|
||||
style={{ color }} className="size-[22px] cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
|
||||
{svgBody}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="gl-marker-label" style={{ color, fontSize: 10 }}>
|
||||
{sat.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
{showPopup && (
|
||||
<Popup longitude={sat.lng} latitude={sat.lat}
|
||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="200px" className="gl-popup">
|
||||
<div className="min-w-[180px] font-mono text-xs">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span style={{
|
||||
background: color,
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||
{CAT_LABELS[sat.category]}
|
||||
</span>
|
||||
<strong>{sat.name}</strong>
|
||||
</div>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
<tr><td className="text-kcg-muted">{t('satellite.norad')}</td><td>{sat.noradId}</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('satellite.lat')}</td><td>{sat.lat.toFixed(2)}°</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('satellite.lng')}</td><td>{sat.lng.toFixed(2)}°</td></tr>
|
||||
<tr><td className="text-kcg-muted">{t('satellite.alt')}</td><td>{Math.round(sat.altitude)} km</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
257
frontend/src/components/SatelliteMap.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { AircraftLayer } from './AircraftLayer';
|
||||
import { SatelliteLayer } from './SatelliteLayer';
|
||||
import { ShipLayer } from './ShipLayer';
|
||||
import { DamagedShipLayer } from './DamagedShipLayer';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { iranOilFacilities } from '../data/oilFacilities';
|
||||
import { middleEastAirports } from '../data/airports';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types';
|
||||
import { countryLabelsGeoJSON } from '../data/countryLabels';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
interface Props {
|
||||
events: GeoEvent[];
|
||||
currentTime: number;
|
||||
aircraft: Aircraft[];
|
||||
satellites: SatellitePosition[];
|
||||
ships: Ship[];
|
||||
layers: LayerVisibility;
|
||||
}
|
||||
|
||||
// ESRI World Imagery + ESRI boundaries overlay
|
||||
const SATELLITE_STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
'esri-satellite': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri, Maxar, Earthstar Geographics',
|
||||
maxzoom: 19,
|
||||
},
|
||||
'esri-boundaries': {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#000811' } },
|
||||
{ id: 'satellite', type: 'raster' as const, source: 'esri-satellite' },
|
||||
{ id: 'boundaries', type: 'raster' as const, source: 'esri-boundaries', paint: { 'raster-opacity': 0.65 } },
|
||||
],
|
||||
};
|
||||
|
||||
const EVENT_COLORS: Record<GeoEvent['type'], string> = {
|
||||
airstrike: '#ef4444',
|
||||
explosion: '#f97316',
|
||||
missile_launch: '#eab308',
|
||||
intercept: '#3b82f6',
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
US: '#ef4444',
|
||||
IL: '#22c55e',
|
||||
IR: '#ff0000',
|
||||
proxy: '#f59e0b',
|
||||
};
|
||||
|
||||
function getEventColor(event: GeoEvent): string {
|
||||
if (event.type === 'impact') return '#ff0000';
|
||||
if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source];
|
||||
return EVENT_COLORS[event.type];
|
||||
}
|
||||
|
||||
const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
||||
airstrike: 12,
|
||||
explosion: 10,
|
||||
missile_launch: 8,
|
||||
intercept: 7,
|
||||
alert: 6,
|
||||
impact: 14,
|
||||
osint: 8,
|
||||
};
|
||||
|
||||
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
if (!layers.events) return [];
|
||||
return events.filter(e => e.timestamp <= currentTime);
|
||||
}, [events, currentTime, layers.events]);
|
||||
|
||||
const selectedEvent = useMemo(
|
||||
() => visibleEvents.find(e => e.id === selectedEventId) || null,
|
||||
[visibleEvents, selectedEventId],
|
||||
);
|
||||
|
||||
const countryLabels = useMemo(() => countryLabelsGeoJSON(), []);
|
||||
|
||||
return (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
initialViewState={{
|
||||
longitude: 53.0,
|
||||
latitude: 32.0,
|
||||
zoom: 5.5,
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification}
|
||||
attributionControl={false}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
{/* Korean country labels */}
|
||||
<Source id="country-labels" type="geojson" data={countryLabels}>
|
||||
<Layer
|
||||
id="country-label-lg"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 1]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 15,
|
||||
'text-allow-overlap': false,
|
||||
'text-ignore-placement': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#1a1a2e',
|
||||
'text-halo-color': 'rgba(255,255,255,0.7)',
|
||||
'text-halo-width': 2,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-md"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 2]}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#2a2a3e',
|
||||
'text-halo-color': 'rgba(255,255,255,0.6)',
|
||||
'text-halo-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="country-label-sm"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'rank'], 3]}
|
||||
minzoom={4}
|
||||
layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-size': 10,
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#333350',
|
||||
'text-halo-color': 'rgba(255,255,255,0.5)',
|
||||
'text-halo-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Event markers */}
|
||||
{visibleEvents.map(ev => (
|
||||
<Marker
|
||||
key={ev.id}
|
||||
longitude={ev.lng}
|
||||
latitude={ev.lat}
|
||||
anchor="center"
|
||||
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full cursor-pointer"
|
||||
style={{
|
||||
width: EVENT_RADIUS[ev.type],
|
||||
height: EVENT_RADIUS[ev.type],
|
||||
background: getEventColor(ev),
|
||||
border: '2px solid rgba(255,255,255,0.8)',
|
||||
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup */}
|
||||
{selectedEvent && (
|
||||
<Popup
|
||||
longitude={selectedEvent.lng}
|
||||
latitude={selectedEvent.lat}
|
||||
anchor="bottom"
|
||||
onClose={() => setSelectedEventId(null)}
|
||||
closeOnClick={false}
|
||||
maxWidth="320px"
|
||||
className="event-popup"
|
||||
>
|
||||
<div className="min-w-[200px] max-w-[320px]">
|
||||
{selectedEvent.source && (
|
||||
<span
|
||||
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||
style={{ background: getEventColor(selectedEvent) }}
|
||||
>
|
||||
{t(`source.${selectedEvent.source}`)}
|
||||
</span>
|
||||
)}
|
||||
{selectedEvent.type === 'impact' && (
|
||||
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||
{t('popup.impactSite')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
|
||||
<span className="text-xs text-kcg-muted">
|
||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||
</span>
|
||||
{selectedEvent.description && (
|
||||
<p className="mt-1.5 mb-0 text-[13px] text-kcg-text-secondary">{selectedEvent.description}</p>
|
||||
)}
|
||||
{selectedEvent.imageUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={selectedEvent.imageUrl}
|
||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||
className="w-full rounded max-h-[180px] object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{selectedEvent.imageCaption && (
|
||||
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{/* Overlay layers */}
|
||||
{layers.aircraft && <AircraftLayer aircraft={aircraft} />}
|
||||
{layers.satellites && <SatelliteLayer satellites={satellites} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
|
||||
<DamagedShipLayer currentTime={currentTime} />
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/SensorChart.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import type { SensorLog } from '../types';
|
||||
|
||||
interface Props {
|
||||
data: SensorLog[];
|
||||
currentTime: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleData = useMemo(
|
||||
() => data.filter(d => d.timestamp <= currentTime),
|
||||
[data, currentTime],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
visibleData.map(d => ({
|
||||
...d,
|
||||
time: formatHour(d.timestamp, startTime),
|
||||
})),
|
||||
[visibleData, startTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sensor-chart">
|
||||
<h3>{t('sensor.title')}</h3>
|
||||
<div className="chart-grid">
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.seismicActivity')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<ReferenceLine x={formatHour(currentTime, startTime)} stroke="#fff" strokeDasharray="3 3" />
|
||||
<Line type="monotone" dataKey="seismic" stroke="#ef4444" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.noiseLevelDb')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[990, 1020]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Line type="monotone" dataKey="airPressure" stroke="#3b82f6" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.radiationUsv')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Line type="monotone" dataKey="radiationLevel" stroke="#22c55e" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatHour(timestamp: number, startTime: number): string {
|
||||
const hours = (timestamp - startTime) / 3600_000;
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
return `${h}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
467
frontend/src/components/ShipLayer.tsx
Normal file
@ -0,0 +1,467 @@
|
||||
import { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Ship, ShipCategory } from '../types';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
militaryOnly: boolean;
|
||||
koreanOnly?: boolean;
|
||||
}
|
||||
|
||||
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||
const MT_TYPE_COLORS: Record<string, string> = {
|
||||
cargo: 'var(--kcg-ship-cargo)',
|
||||
tanker: 'var(--kcg-ship-tanker)',
|
||||
passenger: 'var(--kcg-ship-passenger)',
|
||||
fishing: 'var(--kcg-ship-fishing)',
|
||||
pleasure: 'var(--kcg-ship-pleasure)',
|
||||
military: 'var(--kcg-ship-military)',
|
||||
tug_special: 'var(--kcg-ship-tug)',
|
||||
other: 'var(--kcg-ship-other)',
|
||||
unknown: 'var(--kcg-ship-unknown)',
|
||||
};
|
||||
|
||||
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
|
||||
const MT_TYPE_HEX: Record<string, string> = {
|
||||
cargo: '#f0a830',
|
||||
tanker: '#e74c3c',
|
||||
passenger: '#4caf50',
|
||||
fishing: '#42a5f5',
|
||||
pleasure: '#e91e8c',
|
||||
military: '#d32f2f',
|
||||
tug_special: '#2e7d32',
|
||||
other: '#5c6bc0',
|
||||
unknown: '#9e9e9e',
|
||||
};
|
||||
|
||||
// Map our internal ShipCategory + typecode → MT visual type
|
||||
function getMTType(ship: Ship): string {
|
||||
const tc = (ship.typecode || '').toUpperCase();
|
||||
const cat = ship.category;
|
||||
|
||||
// Military first
|
||||
if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military';
|
||||
if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military';
|
||||
|
||||
// Tanker
|
||||
if (cat === 'tanker') return 'tanker';
|
||||
if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker';
|
||||
if (tc.startsWith('A1')) return 'tanker';
|
||||
|
||||
// Cargo
|
||||
if (cat === 'cargo') return 'cargo';
|
||||
if (tc === 'CONT' || tc === 'BULK') return 'cargo';
|
||||
if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo';
|
||||
|
||||
// Passenger
|
||||
if (tc === 'PASS' || tc.startsWith('B')) return 'passenger';
|
||||
|
||||
// Fishing
|
||||
if (tc.startsWith('C')) return 'fishing';
|
||||
|
||||
// Tug / Special
|
||||
if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special';
|
||||
|
||||
// Pleasure
|
||||
if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure';
|
||||
|
||||
if (cat === 'civilian') return 'other';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Legacy navy flag colors (for popup header accent only)
|
||||
const NAVY_COLORS: Record<string, string> = {
|
||||
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
|
||||
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
||||
};
|
||||
|
||||
const FLAG_EMOJI: Record<string, string> = {
|
||||
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
||||
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
||||
AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}',
|
||||
CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}',
|
||||
MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}',
|
||||
BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}',
|
||||
SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}',
|
||||
};
|
||||
|
||||
// icon-size multiplier (symbol layer, base=64px)
|
||||
const SIZE_MAP: Record<ShipCategory, number> = {
|
||||
carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16,
|
||||
tanker: 0.16, cargo: 0.16, civilian: 0.14, unknown: 0.12,
|
||||
};
|
||||
|
||||
const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
|
||||
|
||||
function isMilitary(category: ShipCategory): boolean {
|
||||
return MIL_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
function getShipColor(ship: Ship): string {
|
||||
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
||||
}
|
||||
|
||||
function getShipHex(ship: Ship): string {
|
||||
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
|
||||
}
|
||||
|
||||
// ── Local Korean ship photos ──
|
||||
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
||||
'440034000': '/ships/440034000.jpg',
|
||||
'440150000': '/ships/440150000.jpg',
|
||||
'440272000': '/ships/440272000.jpg',
|
||||
'440274000': '/ships/440274000.jpg',
|
||||
'440323000': '/ships/440323000.jpg',
|
||||
'440384000': '/ships/440384000.jpg',
|
||||
'440880000': '/ships/440880000.jpg',
|
||||
'441046000': '/ships/441046000.jpg',
|
||||
'441345000': '/ships/441345000.jpg',
|
||||
'441353000': '/ships/441353000.jpg',
|
||||
'441393000': '/ships/441393000.jpg',
|
||||
'441423000': '/ships/441423000.jpg',
|
||||
'441548000': '/ships/441548000.jpg',
|
||||
'441708000': '/ships/441708000.png',
|
||||
'441866000': '/ships/441866000.jpg',
|
||||
};
|
||||
|
||||
interface VesselPhotoData { url: string; }
|
||||
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
||||
|
||||
type PhotoSource = 'signal-batch' | 'marinetraffic';
|
||||
|
||||
interface VesselPhotoProps {
|
||||
mmsi: string;
|
||||
imo?: string;
|
||||
shipImagePath?: string | null;
|
||||
}
|
||||
|
||||
function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) {
|
||||
const { t } = useTranslation('ships');
|
||||
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
||||
|
||||
// Determine available tabs
|
||||
const hasSignalBatch = !!shipImagePath;
|
||||
const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic';
|
||||
const [activeTab, setActiveTab] = useState<PhotoSource>(defaultTab);
|
||||
|
||||
// MarineTraffic image state (lazy loaded)
|
||||
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
||||
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'marinetraffic') return;
|
||||
if (mtPhoto !== undefined) return;
|
||||
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
||||
const img = new Image();
|
||||
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
|
||||
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
|
||||
img.src = imgUrl;
|
||||
}, [mmsi, activeTab, mtPhoto]);
|
||||
|
||||
// Resolve current image URL
|
||||
let currentUrl: string | null = null;
|
||||
if (localUrl) {
|
||||
currentUrl = localUrl;
|
||||
} else if (activeTab === 'signal-batch' && shipImagePath) {
|
||||
currentUrl = shipImagePath;
|
||||
} else if (activeTab === 'marinetraffic' && mtPhoto) {
|
||||
currentUrl = mtPhoto.url;
|
||||
}
|
||||
|
||||
// If local photo exists, show it directly without tabs
|
||||
if (localUrl) {
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<img src={localUrl} alt="Vessel"
|
||||
className="w-full rounded block"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
|
||||
{hasSignalBatch && (
|
||||
<div
|
||||
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
||||
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
||||
}`}
|
||||
onClick={() => setActiveTab('signal-batch')}
|
||||
>
|
||||
signal-batch
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
||||
activeTab === 'marinetraffic' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
||||
}`}
|
||||
onClick={() => setActiveTab('marinetraffic')}
|
||||
>
|
||||
MarineTraffic
|
||||
</div>
|
||||
</div>
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt="Vessel"
|
||||
className="w-full rounded block"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
activeTab === 'marinetraffic' && mtPhoto === undefined
|
||||
? <div className="text-center p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCoord(lat: number, lng: number): string {
|
||||
const latDir = lat >= 0 ? 'N' : 'S';
|
||||
const lngDir = lng >= 0 ? 'E' : 'W';
|
||||
return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`;
|
||||
}
|
||||
|
||||
// Create triangle SDF image for MapLibre symbol layer
|
||||
const TRIANGLE_SIZE = 64;
|
||||
|
||||
function ensureTriangleImage(map: maplibregl.Map) {
|
||||
if (map.hasImage('ship-triangle')) return;
|
||||
const s = TRIANGLE_SIZE;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = s;
|
||||
canvas.height = s;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
// Draw upward-pointing triangle (heading 0 = north)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s / 2, 2); // top center
|
||||
ctx.lineTo(s * 0.12, s - 2); // bottom left
|
||||
ctx.lineTo(s / 2, s * 0.62); // inner notch
|
||||
ctx.lineTo(s * 0.88, s - 2); // bottom right
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
const imgData = ctx.getImageData(0, 0, s, s);
|
||||
map.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(imgData.data.buffer) }, { sdf: true });
|
||||
}
|
||||
|
||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||
export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
||||
const { current: map } = useMap();
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = ships;
|
||||
if (koreanOnly) result = result.filter(s => s.flag === 'KR');
|
||||
if (militaryOnly) result = result.filter(s => isMilitary(s.category));
|
||||
return result;
|
||||
}, [ships, militaryOnly, koreanOnly]);
|
||||
|
||||
// Add triangle image to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const m = map.getMap();
|
||||
const addIcon = () => {
|
||||
try { ensureTriangleImage(m); } catch { /* already added */ }
|
||||
setImageReady(true);
|
||||
};
|
||||
if (m.isStyleLoaded()) { addIcon(); }
|
||||
else { m.once('load', addIcon); }
|
||||
return () => { m.off('load', addIcon); };
|
||||
}, [map]);
|
||||
|
||||
// Build GeoJSON for all ships
|
||||
const shipGeoJson = useMemo(() => {
|
||||
const features: GeoJSON.Feature[] = filtered.map(ship => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
mmsi: ship.mmsi,
|
||||
color: getShipHex(ship),
|
||||
size: SIZE_MAP[ship.category],
|
||||
isMil: isMilitary(ship.category) ? 1 : 0,
|
||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
||||
heading: ship.heading,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [ship.lng, ship.lat],
|
||||
},
|
||||
}));
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}, [filtered]);
|
||||
|
||||
// Register click and cursor handlers
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const m = map.getMap();
|
||||
const layerId = 'ships-triangles';
|
||||
|
||||
const handleClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (e.features && e.features.length > 0) {
|
||||
const mmsi = e.features[0].properties?.mmsi;
|
||||
if (mmsi) setSelectedMmsi(mmsi);
|
||||
}
|
||||
};
|
||||
const handleEnter = () => { m.getCanvas().style.cursor = 'pointer'; };
|
||||
const handleLeave = () => { m.getCanvas().style.cursor = ''; };
|
||||
|
||||
m.on('click', layerId, handleClick);
|
||||
m.on('mouseenter', layerId, handleEnter);
|
||||
m.on('mouseleave', layerId, handleLeave);
|
||||
|
||||
return () => {
|
||||
m.off('click', layerId, handleClick);
|
||||
m.off('mouseenter', layerId, handleEnter);
|
||||
m.off('mouseleave', layerId, handleLeave);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||
|
||||
// Carrier labels — only a few, so DOM markers are fine
|
||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
||||
|
||||
|
||||
|
||||
if (!imageReady) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Source id="ships-source" type="geojson" data={shipGeoJson}>
|
||||
{/* Korean ship outer ring (circle behind triangle) */}
|
||||
<Layer
|
||||
id="ships-korean-ring"
|
||||
type="circle"
|
||||
filter={['==', ['get', 'isKorean'], 1]}
|
||||
paint={{
|
||||
'circle-radius': ['*', ['get', 'size'], 14],
|
||||
'circle-color': 'transparent',
|
||||
'circle-stroke-color': '#00e5ff',
|
||||
'circle-stroke-width': 1.5,
|
||||
'circle-stroke-opacity': 0.6,
|
||||
}}
|
||||
/>
|
||||
{/* Main ship triangles */}
|
||||
<Layer
|
||||
id="ships-triangles"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': 'ship-triangle',
|
||||
'icon-size': ['get', 'size'],
|
||||
'icon-rotate': ['get', 'heading'],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
}}
|
||||
paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-opacity': 0.9,
|
||||
'icon-halo-color': ['case',
|
||||
['==', ['get', 'isMil'], 1], '#ffffff',
|
||||
'rgba(255,255,255,0.3)',
|
||||
],
|
||||
'icon-halo-width': ['case',
|
||||
['==', ['get', 'isMil'], 1], 1,
|
||||
0.3,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Carrier labels as DOM markers (very few) */}
|
||||
{carriers.map(ship => (
|
||||
<Marker key={`label-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={{ pointerEvents: 'none' }}>
|
||||
<div className="gl-marker-label" style={{ color: getShipColor(ship) }}>
|
||||
{ship.name}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup for selected ship */}
|
||||
{selectedShip && (
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const mtType = getMTType(ship);
|
||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||
const isMil = isMilitary(ship.category);
|
||||
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
|
||||
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
|
||||
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
||||
|
||||
return (
|
||||
<Popup longitude={ship.lng} latitude={ship.lat}
|
||||
onClose={onClose} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
|
||||
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
|
||||
>
|
||||
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
|
||||
<strong className="text-[13px] flex-1">{ship.name}</strong>
|
||||
{navyLabel && (
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
|
||||
style={{ background: navyAccent || color }}
|
||||
>{navyLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
|
||||
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
|
||||
style={{ background: color }}
|
||||
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
|
||||
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
|
||||
{t(`categoryLabel.${ship.category}`)}
|
||||
</span>
|
||||
{ship.typeDesc && (
|
||||
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
|
||||
<div>
|
||||
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
|
||||
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
|
||||
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
|
||||
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
|
||||
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
|
||||
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
|
||||
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}°</div>
|
||||
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}°</div>
|
||||
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
|
||||
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
|
||||
<div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
|
||||
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
|
||||
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-[#999] text-right">
|
||||
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-right">
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
||||
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
});
|
||||
154
frontend/src/components/SubmarineCableLayer.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../services/submarineCable';
|
||||
import type { SubmarineCable } from '../services/submarineCable';
|
||||
|
||||
export function SubmarineCableLayer() {
|
||||
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
|
||||
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
|
||||
|
||||
// Build GeoJSON for all cables
|
||||
const geojson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: KOREA_SUBMARINE_CABLES.map(cable => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: cable.id,
|
||||
name: cable.name,
|
||||
color: cable.color,
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: cable.route,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cable lines */}
|
||||
<Source id="submarine-cables" type="geojson" data={geojson}>
|
||||
<Layer
|
||||
id="submarine-cables-outline"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.25,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="submarine-cables-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 1,
|
||||
'line-opacity': 0.6,
|
||||
'line-dasharray': [4, 3],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Landing points */}
|
||||
{KOREA_LANDING_POINTS.map(pt => (
|
||||
<Marker key={pt.name} longitude={pt.lng} latitude={pt.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedPoint(pt); setSelectedCable(null); }}>
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: '#00e5ff', border: '1.5px solid #fff',
|
||||
boxShadow: '0 0 6px #00e5ff88',
|
||||
cursor: 'pointer',
|
||||
}} />
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Cable name labels along route (midpoint) */}
|
||||
{KOREA_SUBMARINE_CABLES.map(cable => {
|
||||
const mid = cable.route[Math.floor(cable.route.length / 3)];
|
||||
if (!mid) return null;
|
||||
return (
|
||||
<Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
|
||||
<div style={{
|
||||
fontSize: 7, fontFamily: 'monospace', fontWeight: 600,
|
||||
color: cable.color, cursor: 'pointer',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000',
|
||||
whiteSpace: 'nowrap', opacity: 0.8,
|
||||
}}>
|
||||
{cable.name}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Landing point popup */}
|
||||
{selectedPoint && (
|
||||
<Popup longitude={selectedPoint.lng} latitude={selectedPoint.lat}
|
||||
onClose={() => setSelectedPoint(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="260px" className="gl-popup">
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
|
||||
<div style={{
|
||||
background: '#00e5ff', color: '#000',
|
||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
||||
margin: '-10px -10px 8px -10px',
|
||||
fontWeight: 700, fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<span>📡</span> {selectedPoint.name} 해저케이블 기지
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 4 }}>
|
||||
연결 케이블: {selectedPoint.cables.length}개
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{selectedPoint.cables.map(cid => {
|
||||
const c = KOREA_SUBMARINE_CABLES.find(cc => cc.id === cid);
|
||||
if (!c) return null;
|
||||
return (
|
||||
<div key={cid} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: c.color, flexShrink: 0 }} />
|
||||
<span style={{ color: '#ddd' }}>{c.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{/* Cable info popup */}
|
||||
{selectedCable && (
|
||||
<Popup
|
||||
longitude={selectedCable.route[0][0]}
|
||||
latitude={selectedCable.route[0][1]}
|
||||
onClose={() => setSelectedCable(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
||||
<div style={{
|
||||
background: selectedCable.color, color: '#000',
|
||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
||||
margin: '-10px -10px 8px -10px',
|
||||
fontWeight: 700, fontSize: 13,
|
||||
}}>
|
||||
🔌 {selectedCable.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div>
|
||||
<span style={{ color: '#888' }}>경유지: </span>
|
||||
<span style={{ color: '#ddd' }}>{selectedCable.landingPoints.join(' → ')}</span>
|
||||
</div>
|
||||
{selectedCable.rfsYear && (
|
||||
<div><span style={{ color: '#888' }}>개통: </span>{selectedCable.rfsYear}년</div>
|
||||
)}
|
||||
{selectedCable.length && (
|
||||
<div><span style={{ color: '#888' }}>총 길이: </span>{selectedCable.length}</div>
|
||||
)}
|
||||
{selectedCable.owners && (
|
||||
<div><span style={{ color: '#888' }}>운영: </span>{selectedCable.owners}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/TimelineSlider.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GeoEvent } from '../types';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
events: GeoEvent[];
|
||||
onSeek: (time: number) => void;
|
||||
onEventFlyTo?: (event: GeoEvent) => void;
|
||||
}
|
||||
|
||||
const KST_OFFSET = 9 * 3600_000;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
airstrike: '#ef4444',
|
||||
explosion: '#f97316',
|
||||
missile_launch: '#eab308',
|
||||
intercept: '#3b82f6',
|
||||
alert: '#a855f7',
|
||||
impact: '#ff0000',
|
||||
osint: '#06b6d4',
|
||||
};
|
||||
|
||||
const TYPE_I18N_KEYS: Record<string, string> = {
|
||||
airstrike: 'event.airstrike',
|
||||
explosion: 'event.explosion',
|
||||
missile_launch: 'event.missileLaunch',
|
||||
intercept: 'event.intercept',
|
||||
alert: 'event.alert',
|
||||
impact: 'event.impact',
|
||||
osint: 'event.osint',
|
||||
};
|
||||
|
||||
const SOURCE_I18N_KEYS: Record<string, string> = {
|
||||
US: 'source.US',
|
||||
IL: 'source.IL',
|
||||
IR: 'source.IR',
|
||||
proxy: 'source.proxy',
|
||||
};
|
||||
|
||||
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||
|
||||
const eventMarkers = useMemo(() => {
|
||||
return events.map(e => ({
|
||||
id: e.id,
|
||||
position: ((e.timestamp - startTime) / (endTime - startTime)) * 100,
|
||||
type: e.type,
|
||||
label: e.label,
|
||||
}));
|
||||
}, [events, startTime, endTime]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts + KST_OFFSET);
|
||||
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
||||
};
|
||||
|
||||
const formatTimeShort = (ts: number) => {
|
||||
const d = new Date(ts + KST_OFFSET);
|
||||
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
onSeek(startTime + pct * (endTime - startTime));
|
||||
};
|
||||
|
||||
// When a marker is clicked: select it + seek to its time
|
||||
const handleMarkerClick = useCallback((e: React.MouseEvent, ev: GeoEvent) => {
|
||||
e.stopPropagation(); // don't trigger track seek
|
||||
setSelectedId(prev => prev === ev.id ? null : ev.id);
|
||||
onSeek(ev.timestamp);
|
||||
}, [onSeek]);
|
||||
|
||||
// Find events near the selected event (within 30 min)
|
||||
const selectedCluster = useMemo(() => {
|
||||
if (!selectedId) return [];
|
||||
const sel = events.find(e => e.id === selectedId);
|
||||
if (!sel) return [];
|
||||
const WINDOW = 30 * 60_000; // 30 min
|
||||
return events
|
||||
.filter(e => Math.abs(e.timestamp - sel.timestamp) <= WINDOW)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [selectedId, events]);
|
||||
|
||||
const handleEventCardClick = useCallback((ev: GeoEvent) => {
|
||||
onSeek(ev.timestamp);
|
||||
onEventFlyTo?.(ev);
|
||||
}, [onSeek, onEventFlyTo]);
|
||||
|
||||
return (
|
||||
<div className="timeline-slider">
|
||||
<div className="timeline-labels">
|
||||
<span>{formatTime(startTime)}</span>
|
||||
<span className="timeline-current">{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(endTime)}</span>
|
||||
</div>
|
||||
<div className="timeline-track" onClick={handleTrackClick}>
|
||||
<div className="timeline-progress" style={{ width: `${progress}%` }} />
|
||||
<div className="timeline-playhead" style={{ left: `${progress}%` }} />
|
||||
{eventMarkers.map(m => {
|
||||
const ev = events.find(e => e.id === m.id)!;
|
||||
const isSelected = selectedId === m.id;
|
||||
const isInCluster = selectedCluster.some(c => c.id === m.id);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`tl-marker ${isSelected ? 'selected' : ''} ${isInCluster && !isSelected ? 'in-cluster' : ''}`}
|
||||
style={{
|
||||
left: `${m.position}%`,
|
||||
'--marker-color': TYPE_COLORS[m.type] || '#888',
|
||||
} as React.CSSProperties}
|
||||
title={m.label}
|
||||
onClick={(e) => handleMarkerClick(e, ev)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Event detail strip — shown when a marker is selected */}
|
||||
{selectedCluster.length > 0 && (
|
||||
<div className="tl-detail-strip">
|
||||
{selectedCluster.map(ev => {
|
||||
const color = TYPE_COLORS[ev.type] || '#888';
|
||||
const isPast = ev.timestamp <= currentTime;
|
||||
const isActive = ev.id === selectedId;
|
||||
const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
|
||||
const source = sourceKey ? t(sourceKey) : '';
|
||||
const typeKey = TYPE_I18N_KEYS[ev.type];
|
||||
const typeLabel = typeKey ? t(typeKey) : ev.type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
||||
style={{ '--card-color': color } as React.CSSProperties}
|
||||
onClick={() => handleEventCardClick(ev)}
|
||||
title={t('timeline.flyToTooltip')}
|
||||
>
|
||||
<span className="tl-card-dot" />
|
||||
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
||||
{source && (
|
||||
<span className="tl-card-source" style={{ background: color }}>{source}</span>
|
||||
)}
|
||||
<span className="tl-card-name">{ev.label}</span>
|
||||
<span className="tl-card-type">{typeLabel}</span>
|
||||
<svg className="tl-card-goto" viewBox="0 0 16 16" width="10" height="10">
|
||||
<path d="M8 1L14 8L8 15M14 8H1" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/auth/LoginPage.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginPageProps {
|
||||
onGoogleLogin: (credential: string) => Promise<void>;
|
||||
onDevLogin: () => void;
|
||||
}
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
function useGoogleIdentity(onCredential: (credential: string) => void) {
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
const callbackRef = useRef(onCredential);
|
||||
callbackRef.current = onCredential;
|
||||
|
||||
useEffect(() => {
|
||||
if (!GOOGLE_CLIENT_ID) return;
|
||||
|
||||
const scriptId = 'google-gsi-script';
|
||||
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||
|
||||
const initGoogle = () => {
|
||||
const google = (window as unknown as Record<string, unknown>).google as {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: {
|
||||
client_id: string;
|
||||
callback: (response: { credential: string }) => void;
|
||||
}) => void;
|
||||
renderButton: (
|
||||
el: HTMLElement,
|
||||
config: {
|
||||
theme: string;
|
||||
size: string;
|
||||
width: number;
|
||||
text: string;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
if (!google?.accounts?.id || !btnRef.current) return;
|
||||
|
||||
google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: (response: { credential: string }) => {
|
||||
callbackRef.current(response.credential);
|
||||
},
|
||||
});
|
||||
|
||||
google.accounts.id.renderButton(btnRef.current, {
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
width: 300,
|
||||
text: 'signin_with',
|
||||
});
|
||||
};
|
||||
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = initGoogle;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
initGoogle();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return btnRef;
|
||||
}
|
||||
|
||||
const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGoogleCredential = useCallback(
|
||||
(credential: string) => {
|
||||
setError(null);
|
||||
onGoogleLogin(credential).catch(() => {
|
||||
setError(t('auth.loginFailed'));
|
||||
});
|
||||
},
|
||||
[onGoogleLogin, t],
|
||||
);
|
||||
|
||||
const googleBtnRef = useGoogleIdentity(handleGoogleCredential);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--kcg-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl border p-8"
|
||||
style={{
|
||||
backgroundColor: 'var(--kcg-card)',
|
||||
borderColor: 'var(--kcg-border)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-3xl">🛡️</div>
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--kcg-text)' }}
|
||||
>
|
||||
{t('auth.title')}
|
||||
</h1>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--kcg-muted)' }}
|
||||
>
|
||||
{t('auth.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="w-full rounded-lg px-4 py-2 text-center text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--kcg-danger-bg)',
|
||||
color: 'var(--kcg-danger)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Login Button */}
|
||||
{GOOGLE_CLIENT_ID && (
|
||||
<>
|
||||
<div ref={googleBtnRef} />
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--kcg-dim)' }}
|
||||
>
|
||||
{t('auth.domainNotice')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dev Login */}
|
||||
{IS_DEV && (
|
||||
<>
|
||||
<div
|
||||
className="w-full border-t pt-4 text-center"
|
||||
style={{ borderColor: 'var(--kcg-border)' }}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-mono tracking-wider"
|
||||
style={{ color: 'var(--kcg-dim)' }}
|
||||
>
|
||||
{t('auth.devNotice')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDevLogin}
|
||||
className="w-full cursor-pointer rounded-lg border-2 px-4 py-3 text-sm font-bold transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--kcg-danger)',
|
||||
color: 'var(--kcg-danger)',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--kcg-danger-bg)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
{t('auth.devLogin')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
112
frontend/src/data/airports.ts
Normal file
@ -0,0 +1,112 @@
|
||||
// Major airports in the Middle East / Horn of Africa region
|
||||
// Reference: Flightradar24, OurAirports
|
||||
|
||||
export interface Airport {
|
||||
iata: string; // IATA code (e.g. "IKA")
|
||||
icao: string; // ICAO code (e.g. "OIIE")
|
||||
name: string;
|
||||
nameKo?: string; // Korean name
|
||||
lat: number;
|
||||
lng: number;
|
||||
type: 'large' | 'medium' | 'small' | 'military';
|
||||
country: string; // ISO 2-letter
|
||||
city?: string;
|
||||
}
|
||||
|
||||
export const middleEastAirports: Airport[] = [
|
||||
// ── 이란 (Iran) ──
|
||||
{ iata: 'IKA', icao: 'OIIE', name: 'Imam Khomeini Intl', nameKo: '이맘 호메이니 국제공항', lat: 35.4161, lng: 51.1522, type: 'large', country: 'IR', city: 'Tehran' },
|
||||
{ iata: 'THR', icao: 'OIII', name: 'Mehrabad Intl', nameKo: '메흐라바드 국제공항', lat: 35.6892, lng: 51.3134, type: 'large', country: 'IR', city: 'Tehran' },
|
||||
{ iata: 'MHD', icao: 'OIMM', name: 'Mashhad Intl', nameKo: '마슈하드 국제공항', lat: 36.2352, lng: 59.6410, type: 'large', country: 'IR', city: 'Mashhad' },
|
||||
{ iata: 'IFN', icao: 'OIFM', name: 'Isfahan Intl', nameKo: '이스파한 국제공항', lat: 32.7508, lng: 51.8613, type: 'large', country: 'IR', city: 'Isfahan' },
|
||||
{ iata: 'SYZ', icao: 'OISS', name: 'Shiraz Intl', nameKo: '시라즈 국제공항', lat: 29.5392, lng: 52.5899, type: 'large', country: 'IR', city: 'Shiraz' },
|
||||
{ iata: 'TBZ', icao: 'OITT', name: 'Tabriz Intl', nameKo: '타브리즈 국제공항', lat: 38.1339, lng: 46.2350, type: 'medium', country: 'IR', city: 'Tabriz' },
|
||||
{ iata: 'BND', icao: 'OIKB', name: 'Bandar Abbas Intl', nameKo: '반다르 아바스 국제공항', lat: 27.2183, lng: 56.3778, type: 'medium', country: 'IR', city: 'Bandar Abbas' },
|
||||
{ iata: 'AWZ', icao: 'OIAW', name: 'Ahvaz Intl', nameKo: '아흐바즈 국제공항', lat: 31.3374, lng: 48.7620, type: 'medium', country: 'IR', city: 'Ahvaz' },
|
||||
{ iata: 'KIH', icao: 'OIBK', name: 'Kish Island Intl', nameKo: '키시섬 국제공항', lat: 26.5262, lng: 53.9802, type: 'medium', country: 'IR', city: 'Kish Island' },
|
||||
{ iata: 'BUZ', icao: 'OIBB', name: 'Bushehr Airport', nameKo: '부셰르 공항', lat: 28.9448, lng: 50.8346, type: 'medium', country: 'IR', city: 'Bushehr' },
|
||||
{ iata: 'KER', icao: 'OIKK', name: 'Kerman Airport', nameKo: '케르만 공항', lat: 30.2744, lng: 56.9511, type: 'medium', country: 'IR', city: 'Kerman' },
|
||||
{ iata: 'ZAH', icao: 'OIZH', name: 'Zahedan Intl', nameKo: '자헤단 국제공항', lat: 29.4757, lng: 60.9062, type: 'medium', country: 'IR', city: 'Zahedan' },
|
||||
|
||||
// ── 이란 군사기지 (Iran Military) ──
|
||||
{ iata: '', icao: 'OIFH', name: 'Isfahan (Haft) AFB', nameKo: '이스파한 군사기지', lat: 32.5669, lng: 51.6916, type: 'military', country: 'IR', city: 'Isfahan' },
|
||||
{ iata: '', icao: 'OIIA', name: 'Tehran Doshan Tappeh AFB', nameKo: '도샨 타페 공군기지', lat: 35.7030, lng: 51.4750, type: 'military', country: 'IR', city: 'Tehran' },
|
||||
{ iata: '', icao: 'OINR', name: 'Nojeh AFB (Hamadan)', nameKo: '노제 공군기지', lat: 34.8919, lng: 48.2914, type: 'military', country: 'IR', city: 'Hamadan' },
|
||||
{ iata: '', icao: 'OIBJ', name: 'Bandar Abbas (Havadarya) NAS', nameKo: '반다르 아바스 해군항공기지', lat: 27.1583, lng: 56.1725, type: 'military', country: 'IR', city: 'Bandar Abbas' },
|
||||
{ iata: '', icao: 'OICC', name: 'Tabriz (Shahid Fakouri) AFB', nameKo: '타브리즈 공군기지', lat: 38.1500, lng: 46.2500, type: 'military', country: 'IR', city: 'Tabriz' },
|
||||
|
||||
// ── 이라크 (Iraq) ──
|
||||
{ iata: 'BGW', icao: 'ORBI', name: 'Baghdad Intl', nameKo: '바그다드 국제공항', lat: 33.2625, lng: 44.2346, type: 'large', country: 'IQ', city: 'Baghdad' },
|
||||
{ iata: 'BSR', icao: 'ORMM', name: 'Basra Intl', nameKo: '바스라 국제공항', lat: 30.5491, lng: 47.6621, type: 'medium', country: 'IQ', city: 'Basra' },
|
||||
{ iata: 'EBL', icao: 'ORER', name: 'Erbil Intl', nameKo: '에르빌 국제공항', lat: 36.2376, lng: 43.9632, type: 'medium', country: 'IQ', city: 'Erbil' },
|
||||
{ iata: '', icao: 'ORAA', name: 'Al Asad Airbase', nameKo: '알 아사드 공군기지', lat: 33.7856, lng: 42.4412, type: 'military', country: 'IQ', city: 'Anbar' },
|
||||
{ iata: '', icao: 'ORBD', name: 'Balad (Al Bakr) AB', nameKo: '발라드 공군기지', lat: 33.9402, lng: 44.3615, type: 'military', country: 'IQ', city: 'Balad' },
|
||||
|
||||
// ── 이스라엘 (Israel) ──
|
||||
{ iata: 'TLV', icao: 'LLBG', name: 'Ben Gurion Intl', nameKo: '벤 구리온 국제공항', lat: 32.0114, lng: 34.8867, type: 'large', country: 'IL', city: 'Tel Aviv' },
|
||||
{ iata: '', icao: 'LLNV', name: 'Nevatim AFB', nameKo: '네바팀 공군기지', lat: 31.2083, lng: 34.9389, type: 'military', country: 'IL', city: 'Be\'er Sheva' },
|
||||
{ iata: '', icao: 'LLRM', name: 'Ramon AFB', nameKo: '라몬 공군기지', lat: 30.7761, lng: 34.6667, type: 'military', country: 'IL', city: 'Negev' },
|
||||
{ iata: '', icao: 'LLHA', name: 'Hatzerim AFB', nameKo: '하체림 공군기지', lat: 31.2333, lng: 34.6667, type: 'military', country: 'IL', city: 'Be\'er Sheva' },
|
||||
|
||||
// ── UAE ──
|
||||
{ iata: 'DXB', icao: 'OMDB', name: 'Dubai Intl', nameKo: '두바이 국제공항', lat: 25.2528, lng: 55.3644, type: 'large', country: 'AE', city: 'Dubai' },
|
||||
{ iata: 'AUH', icao: 'OMAA', name: 'Abu Dhabi Intl', nameKo: '아부다비 국제공항', lat: 24.4430, lng: 54.6511, type: 'large', country: 'AE', city: 'Abu Dhabi' },
|
||||
{ iata: 'SHJ', icao: 'OMSJ', name: 'Sharjah Intl', nameKo: '샤르자 국제공항', lat: 25.3286, lng: 55.5172, type: 'medium', country: 'AE', city: 'Sharjah' },
|
||||
{ iata: '', icao: 'OMAD', name: 'Al Dhafra AFB', nameKo: '알 다프라 공군기지', lat: 24.2483, lng: 54.5481, type: 'military', country: 'AE', city: 'Abu Dhabi' },
|
||||
|
||||
// ── 사우디아라비아 (Saudi Arabia) ──
|
||||
{ iata: 'RUH', icao: 'OERK', name: 'King Khalid Intl', nameKo: '킹 칼리드 국제공항', lat: 24.9576, lng: 46.6988, type: 'large', country: 'SA', city: 'Riyadh' },
|
||||
{ iata: 'JED', icao: 'OEJN', name: 'King Abdulaziz Intl', nameKo: '킹 압둘아지즈 국제공항', lat: 21.6796, lng: 39.1565, type: 'large', country: 'SA', city: 'Jeddah' },
|
||||
{ iata: 'DMM', icao: 'OEDF', name: 'King Fahd Intl', nameKo: '킹 파흐드 국제공항', lat: 26.4712, lng: 49.7979, type: 'large', country: 'SA', city: 'Dammam' },
|
||||
{ iata: '', icao: 'OEPS', name: 'Prince Sultan AB', nameKo: '프린스 술탄 공군기지', lat: 24.0625, lng: 47.5806, type: 'military', country: 'SA', city: 'Al Kharj' },
|
||||
|
||||
// ── 카타르 (Qatar) ──
|
||||
{ iata: 'DOH', icao: 'OTHH', name: 'Hamad Intl', nameKo: '하마드 국제공항', lat: 25.2731, lng: 51.6081, type: 'large', country: 'QA', city: 'Doha' },
|
||||
{ iata: '', icao: 'OTBH', name: 'Al Udeid AB', nameKo: '알 우데이드 공군기지', lat: 25.1173, lng: 51.3150, type: 'military', country: 'QA', city: 'Doha' },
|
||||
|
||||
// ── 바레인 (Bahrain) ──
|
||||
{ iata: 'BAH', icao: 'OBBI', name: 'Bahrain Intl', nameKo: '바레인 국제공항', lat: 26.2708, lng: 50.6336, type: 'large', country: 'BH', city: 'Manama' },
|
||||
{ iata: '', icao: 'OBBS', name: 'Isa AB (NSA Bahrain)', nameKo: '이사 공군기지', lat: 26.1572, lng: 50.5911, type: 'military', country: 'BH', city: 'Manama' },
|
||||
|
||||
// ── 쿠웨이트 (Kuwait) ──
|
||||
{ iata: 'KWI', icao: 'OKBK', name: 'Kuwait Intl', nameKo: '쿠웨이트 국제공항', lat: 29.2267, lng: 47.9689, type: 'large', country: 'KW', city: 'Kuwait City' },
|
||||
{ iata: '', icao: 'OKAJ', name: 'Ali Al Salem AB', nameKo: '알리 알 살렘 공군기지', lat: 29.3467, lng: 47.5211, type: 'military', country: 'KW', city: 'Kuwait' },
|
||||
|
||||
// ── 오만 (Oman) ──
|
||||
{ iata: 'MCT', icao: 'OOMS', name: 'Muscat Intl', nameKo: '무스카트 국제공항', lat: 23.5933, lng: 58.2844, type: 'large', country: 'OM', city: 'Muscat' },
|
||||
{ iata: '', icao: 'OMTH', name: 'Thumrait AB', nameKo: '튬라이트 공군기지', lat: 17.6660, lng: 54.0246, type: 'military', country: 'OM', city: 'Thumrait' },
|
||||
|
||||
// ── 터키 (Turkey) ──
|
||||
{ iata: 'IST', icao: 'LTFM', name: 'Istanbul Airport', nameKo: '이스탄불 공항', lat: 41.2753, lng: 28.7519, type: 'large', country: 'TR', city: 'Istanbul' },
|
||||
{ iata: 'ESB', icao: 'LTAC', name: 'Ankara Esenboğa', nameKo: '앙카라 에센보아 공항', lat: 40.1281, lng: 32.9951, type: 'large', country: 'TR', city: 'Ankara' },
|
||||
{ iata: 'ADA', icao: 'LTAF', name: 'Adana Şakirpaşa', nameKo: '아다나 공항', lat: 36.9822, lng: 35.2804, type: 'medium', country: 'TR', city: 'Adana' },
|
||||
{ iata: '', icao: 'LTAG', name: 'Incirlik AB', nameKo: '인시를릭 공군기지', lat: 37.0021, lng: 35.4259, type: 'military', country: 'TR', city: 'Adana' },
|
||||
{ iata: 'DYB', icao: 'LTCC', name: 'Diyarbakır Airport', nameKo: '디야르바키르 공항', lat: 37.8940, lng: 40.2010, type: 'medium', country: 'TR', city: 'Diyarbakır' },
|
||||
|
||||
// ── 요르단 (Jordan) ──
|
||||
{ iata: 'AMM', icao: 'OJAI', name: 'Queen Alia Intl', nameKo: '퀸 알리아 국제공항', lat: 31.7226, lng: 35.9932, type: 'large', country: 'JO', city: 'Amman' },
|
||||
|
||||
// ── 레바논 (Lebanon) ──
|
||||
{ iata: 'BEY', icao: 'OLBA', name: 'Beirut–Rafic Hariri Intl', nameKo: '베이루트 국제공항', lat: 33.8209, lng: 35.4884, type: 'large', country: 'LB', city: 'Beirut' },
|
||||
|
||||
// ── 시리아 (Syria) ──
|
||||
{ iata: 'DAM', icao: 'OSDI', name: 'Damascus Intl', nameKo: '다마스쿠스 국제공항', lat: 33.4114, lng: 36.5156, type: 'large', country: 'SY', city: 'Damascus' },
|
||||
|
||||
// ── 이집트 (Egypt) ──
|
||||
{ iata: 'CAI', icao: 'HECA', name: 'Cairo Intl', nameKo: '카이로 국제공항', lat: 30.1219, lng: 31.4056, type: 'large', country: 'EG', city: 'Cairo' },
|
||||
|
||||
// ── 파키스탄 (Pakistan) ──
|
||||
{ iata: 'KHI', icao: 'OPKC', name: 'Jinnah Intl', nameKo: '진나 국제공항', lat: 24.9065, lng: 67.1609, type: 'large', country: 'PK', city: 'Karachi' },
|
||||
|
||||
// ── 지부티 (Djibouti) ──
|
||||
{ iata: 'JIB', icao: 'HDAM', name: 'Djibouti–Ambouli Intl', nameKo: '지부티 국제공항', lat: 11.5473, lng: 43.1595, type: 'medium', country: 'DJ', city: 'Djibouti' },
|
||||
{ iata: '', icao: 'HDCL', name: 'Camp Lemonnier', nameKo: '캠프 르모니에 (미군)', lat: 11.5474, lng: 43.1556, type: 'military', country: 'DJ', city: 'Djibouti' },
|
||||
|
||||
// ── 예멘 (Yemen) ──
|
||||
{ iata: 'ADE', icao: 'OYAA', name: 'Aden Intl', nameKo: '아덴 국제공항', lat: 12.8295, lng: 45.0288, type: 'medium', country: 'YE', city: 'Aden' },
|
||||
{ iata: 'SAH', icao: 'OYSN', name: 'Sana\'a Intl', nameKo: '사나 국제공항', lat: 15.4763, lng: 44.2197, type: 'medium', country: 'YE', city: 'Sana\'a' },
|
||||
|
||||
// ── 소말리아 (Somalia) ──
|
||||
{ iata: 'BSA', icao: 'HCMF', name: 'Bosaso Airport', nameKo: '보사소 공항', lat: 11.2753, lng: 49.1494, type: 'small', country: 'SO', city: 'Bosaso' },
|
||||
{ iata: 'MGQ', icao: 'HCMM', name: 'Aden Abdulle Intl', nameKo: '모가디슈 국제공항', lat: 2.0144, lng: 45.3047, type: 'medium', country: 'SO', city: 'Mogadishu' },
|
||||
];
|
||||
126
frontend/src/data/countryLabels.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// ═══ 한글 국가명 라벨 데이터 ═══
|
||||
// 중동 + 동아시아 지역 국가명 (지도 오버레이용)
|
||||
|
||||
export interface CountryLabel {
|
||||
name: string; // 한글 국가명
|
||||
nameEn: string; // 영문 (참고용)
|
||||
lat: number;
|
||||
lng: number;
|
||||
rank: number; // 1=대국(큰글씨), 2=중간, 3=소국(작은글씨)
|
||||
}
|
||||
|
||||
export const countryLabels: CountryLabel[] = [
|
||||
// ── 중동 · 서아시아 ──
|
||||
{ name: '이란', nameEn: 'Iran', lat: 32.5, lng: 53.7, rank: 1 },
|
||||
{ name: '이라크', nameEn: 'Iraq', lat: 33.2, lng: 43.7, rank: 1 },
|
||||
{ name: '사우디아라비아', nameEn: 'Saudi Arabia', lat: 24.0, lng: 45.0, rank: 1 },
|
||||
{ name: '튀르키예', nameEn: 'Turkey', lat: 39.0, lng: 35.2, rank: 1 },
|
||||
{ name: '이집트', nameEn: 'Egypt', lat: 26.8, lng: 30.8, rank: 1 },
|
||||
{ name: '시리아', nameEn: 'Syria', lat: 35.0, lng: 38.5, rank: 2 },
|
||||
{ name: '요르단', nameEn: 'Jordan', lat: 31.3, lng: 36.5, rank: 2 },
|
||||
{ name: '레바논', nameEn: 'Lebanon', lat: 33.9, lng: 35.9, rank: 3 },
|
||||
{ name: '이스라엘', nameEn: 'Israel', lat: 31.5, lng: 34.8, rank: 3 },
|
||||
{ name: '쿠웨이트', nameEn: 'Kuwait', lat: 29.3, lng: 47.5, rank: 3 },
|
||||
{ name: '바레인', nameEn: 'Bahrain', lat: 26.07, lng: 50.55, rank: 3 },
|
||||
{ name: '카타르', nameEn: 'Qatar', lat: 25.3, lng: 51.2, rank: 3 },
|
||||
{ name: 'UAE', nameEn: 'UAE', lat: 24.0, lng: 54.0, rank: 2 },
|
||||
{ name: '오만', nameEn: 'Oman', lat: 21.5, lng: 57.0, rank: 2 },
|
||||
{ name: '예멘', nameEn: 'Yemen', lat: 15.6, lng: 48.5, rank: 2 },
|
||||
{ name: '아프가니스탄', nameEn: 'Afghanistan', lat: 33.9, lng: 67.7, rank: 1 },
|
||||
{ name: '파키스탄', nameEn: 'Pakistan', lat: 30.4, lng: 69.3, rank: 1 },
|
||||
{ name: '투르크메니스탄', nameEn: 'Turkmenistan', lat: 39.0, lng: 59.6, rank: 2 },
|
||||
{ name: '우즈베키스탄', nameEn: 'Uzbekistan', lat: 41.4, lng: 64.6, rank: 2 },
|
||||
{ name: '아르메니아', nameEn: 'Armenia', lat: 40.1, lng: 44.5, rank: 3 },
|
||||
{ name: '아제르바이잔', nameEn: 'Azerbaijan', lat: 40.4, lng: 49.9, rank: 3 },
|
||||
{ name: '조지아', nameEn: 'Georgia', lat: 42.3, lng: 43.4, rank: 3 },
|
||||
{ name: '수단', nameEn: 'Sudan', lat: 16.0, lng: 30.2, rank: 2 },
|
||||
{ name: '에리트레아', nameEn: 'Eritrea', lat: 15.2, lng: 39.8, rank: 3 },
|
||||
{ name: '에티오피아', nameEn: 'Ethiopia', lat: 9.1, lng: 40.5, rank: 1 },
|
||||
{ name: '소말리아', nameEn: 'Somalia', lat: 5.2, lng: 46.2, rank: 2 },
|
||||
{ name: '지부티', nameEn: 'Djibouti', lat: 11.6, lng: 43.1, rank: 3 },
|
||||
{ name: '리비아', nameEn: 'Libya', lat: 27.0, lng: 17.2, rank: 2 },
|
||||
{ name: '키프로스', nameEn: 'Cyprus', lat: 35.1, lng: 33.4, rank: 3 },
|
||||
|
||||
// ── 페르시아만 해역 라벨 ──
|
||||
{ name: '페르시아만', nameEn: 'Persian Gulf', lat: 27.0, lng: 51.5, rank: 2 },
|
||||
{ name: '호르무즈 해협', nameEn: 'Strait of Hormuz', lat: 26.56, lng: 56.25, rank: 3 },
|
||||
{ name: '오만만', nameEn: 'Gulf of Oman', lat: 24.5, lng: 58.5, rank: 3 },
|
||||
{ name: '홍해', nameEn: 'Red Sea', lat: 20.0, lng: 38.5, rank: 2 },
|
||||
{ name: '아덴만', nameEn: 'Gulf of Aden', lat: 12.5, lng: 47.0, rank: 3 },
|
||||
{ name: '아라비아해', nameEn: 'Arabian Sea', lat: 16.0, lng: 62.0, rank: 2 },
|
||||
{ name: '카스피해', nameEn: 'Caspian Sea', lat: 41.0, lng: 51.0, rank: 2 },
|
||||
{ name: '지중해', nameEn: 'Mediterranean Sea', lat: 35.0, lng: 25.0, rank: 2 },
|
||||
|
||||
// ── 주요 도시 (이란) ──
|
||||
{ name: '테헤란', nameEn: 'Tehran', lat: 35.69, lng: 51.39, rank: 2 },
|
||||
{ name: '이스파한', nameEn: 'Isfahan', lat: 32.65, lng: 51.68, rank: 3 },
|
||||
{ name: '타브리즈', nameEn: 'Tabriz', lat: 38.08, lng: 46.29, rank: 3 },
|
||||
{ name: '시라즈', nameEn: 'Shiraz', lat: 29.59, lng: 52.58, rank: 3 },
|
||||
{ name: '마슈하드', nameEn: 'Mashhad', lat: 36.30, lng: 59.60, rank: 3 },
|
||||
{ name: '반다르아바스', nameEn: 'Bandar Abbas', lat: 27.18, lng: 56.28, rank: 3 },
|
||||
{ name: '부셰르', nameEn: 'Bushehr', lat: 28.97, lng: 50.84, rank: 3 },
|
||||
{ name: '나탄즈', nameEn: 'Natanz', lat: 33.51, lng: 51.92, rank: 3 },
|
||||
{ name: '아바즈', nameEn: 'Ahvaz', lat: 31.32, lng: 48.67, rank: 3 },
|
||||
{ name: '케르만', nameEn: 'Kerman', lat: 30.28, lng: 57.08, rank: 3 },
|
||||
{ name: '케슘섬', nameEn: 'Qeshm Island', lat: 26.85, lng: 55.90, rank: 3 },
|
||||
{ name: '하르그섬', nameEn: 'Kharg Island', lat: 29.24, lng: 50.31, rank: 3 },
|
||||
|
||||
// ── 주요 도시 (중동 기타) ──
|
||||
{ name: '바그다드', nameEn: 'Baghdad', lat: 33.31, lng: 44.37, rank: 2 },
|
||||
{ name: '에르빌', nameEn: 'Erbil', lat: 36.19, lng: 44.01, rank: 3 },
|
||||
{ name: '다마스쿠스', nameEn: 'Damascus', lat: 33.51, lng: 36.28, rank: 3 },
|
||||
{ name: '베이루트', nameEn: 'Beirut', lat: 33.89, lng: 35.50, rank: 3 },
|
||||
{ name: '예루살렘', nameEn: 'Jerusalem', lat: 31.77, lng: 35.23, rank: 3 },
|
||||
{ name: '텔아비브', nameEn: 'Tel Aviv', lat: 32.08, lng: 34.78, rank: 3 },
|
||||
{ name: '리야드', nameEn: 'Riyadh', lat: 24.71, lng: 46.67, rank: 2 },
|
||||
{ name: '두바이', nameEn: 'Dubai', lat: 25.20, lng: 55.27, rank: 3 },
|
||||
{ name: '아부다비', nameEn: 'Abu Dhabi', lat: 24.45, lng: 54.65, rank: 3 },
|
||||
{ name: '도하', nameEn: 'Doha', lat: 25.29, lng: 51.53, rank: 3 },
|
||||
{ name: '앙카라', nameEn: 'Ankara', lat: 39.93, lng: 32.85, rank: 2 },
|
||||
{ name: '인저를릭', nameEn: 'Incirlik AFB', lat: 37.00, lng: 35.43, rank: 3 },
|
||||
{ name: '카이로', nameEn: 'Cairo', lat: 30.04, lng: 31.24, rank: 2 },
|
||||
{ name: '무스카트', nameEn: 'Muscat', lat: 23.59, lng: 58.54, rank: 3 },
|
||||
{ name: '사나', nameEn: 'Sanaa', lat: 15.37, lng: 44.19, rank: 3 },
|
||||
{ name: '카불', nameEn: 'Kabul', lat: 34.53, lng: 69.17, rank: 3 },
|
||||
|
||||
// ── 주요 군사기지/시설 ──
|
||||
{ name: '알우데이드 기지', nameEn: 'Al Udeid AB', lat: 25.12, lng: 51.32, rank: 3 },
|
||||
{ name: '제벨알리 항', nameEn: 'Jebel Ali Port', lat: 25.01, lng: 55.06, rank: 3 },
|
||||
{ name: '디에고가르시아', nameEn: 'Diego Garcia', lat: -7.32, lng: 72.42, rank: 3 },
|
||||
|
||||
// ── 동아시아 ──
|
||||
{ name: '대한민국', nameEn: 'South Korea', lat: 36.0, lng: 127.8, rank: 1 },
|
||||
{ name: '북한', nameEn: 'North Korea', lat: 40.0, lng: 127.0, rank: 1 },
|
||||
{ name: '일본', nameEn: 'Japan', lat: 36.2, lng: 138.3, rank: 1 },
|
||||
{ name: '중국', nameEn: 'China', lat: 35.9, lng: 104.2, rank: 1 },
|
||||
{ name: '대만', nameEn: 'Taiwan', lat: 23.7, lng: 121.0, rank: 2 },
|
||||
{ name: '러시아', nameEn: 'Russia', lat: 55.0, lng: 105.0, rank: 1 },
|
||||
{ name: '몽골', nameEn: 'Mongolia', lat: 46.9, lng: 103.8, rank: 1 },
|
||||
{ name: '필리핀', nameEn: 'Philippines', lat: 12.9, lng: 121.8, rank: 2 },
|
||||
{ name: '베트남', nameEn: 'Vietnam', lat: 14.1, lng: 108.3, rank: 2 },
|
||||
|
||||
// ── 동해/서해/남해 해역 ──
|
||||
{ name: '동해', nameEn: 'East Sea', lat: 38.5, lng: 132.0, rank: 2 },
|
||||
{ name: '서해(황해)', nameEn: 'Yellow Sea', lat: 35.5, lng: 124.0, rank: 2 },
|
||||
{ name: '남해', nameEn: 'South Sea', lat: 33.0, lng: 128.0, rank: 3 },
|
||||
{ name: '동중국해', nameEn: 'East China Sea', lat: 28.0, lng: 126.0, rank: 2 },
|
||||
{ name: '남중국해', nameEn: 'South China Sea', lat: 15.0, lng: 115.0, rank: 2 },
|
||||
{ name: '태평양', nameEn: 'Pacific Ocean', lat: 25.0, lng: 155.0, rank: 1 },
|
||||
|
||||
// ── 인도양/아프리카 동부 ──
|
||||
{ name: '인도', nameEn: 'India', lat: 20.6, lng: 79.0, rank: 1 },
|
||||
{ name: '인도양', nameEn: 'Indian Ocean', lat: 0.0, lng: 75.0, rank: 1 },
|
||||
];
|
||||
|
||||
/** GeoJSON FeatureCollection 변환 */
|
||||
export function countryLabelsGeoJSON(): GeoJSON.FeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: countryLabels.map((c, i) => ({
|
||||
type: 'Feature' as const,
|
||||
id: i,
|
||||
geometry: { type: 'Point' as const, coordinates: [c.lng, c.lat] },
|
||||
properties: { name: c.name, nameEn: c.nameEn, rank: c.rank },
|
||||
})),
|
||||
};
|
||||
}
|
||||
148
frontend/src/data/damagedShips.ts
Normal file
@ -0,0 +1,148 @@
|
||||
// ═══ 피격 선박 데이터 ═══
|
||||
// sampleData.ts의 해상 공격 이벤트와 연동
|
||||
|
||||
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
|
||||
const HOUR = 3600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
export interface DamagedShip {
|
||||
id: string;
|
||||
name: string;
|
||||
flag: string; // 국적 코드
|
||||
type: string; // VLCC, LNG, Container 등
|
||||
lat: number;
|
||||
lng: number;
|
||||
damagedAt: number; // unix ms — 피격 시각
|
||||
cause: string; // 기뢰, 드론, 대함미사일 등
|
||||
damage: 'sunk' | 'severe' | 'moderate' | 'minor';
|
||||
description: string;
|
||||
eventId: string; // 연관 GeoEvent id
|
||||
}
|
||||
|
||||
export const damagedShips: DamagedShip[] = [
|
||||
// DAY 3 — 3월 3일
|
||||
{
|
||||
id: 'ds-1',
|
||||
name: 'ATHENA GLORY',
|
||||
flag: 'GR',
|
||||
type: 'VLCC',
|
||||
lat: 26.5500, lng: 56.3500,
|
||||
damagedAt: T0 + 2 * DAY,
|
||||
cause: '기뢰 접촉',
|
||||
damage: 'severe',
|
||||
description: '그리스 국적 VLCC, 호르무즈 해협 기뢰 접촉. 원유 유출.',
|
||||
eventId: 'd3-sea1',
|
||||
},
|
||||
// DAY 6 — 3월 6일: IRGC 고속정
|
||||
{
|
||||
id: 'ds-2',
|
||||
name: 'IRGC FAST BOAT x4',
|
||||
flag: 'IR',
|
||||
type: 'MILITARY',
|
||||
lat: 26.4000, lng: 56.4000,
|
||||
damagedAt: T0 + 2 * DAY + 3 * HOUR,
|
||||
cause: '미 구축함 함포 교전',
|
||||
damage: 'sunk',
|
||||
description: 'IRGC 고속정 4척, 미 구축함 교전 중 격침.',
|
||||
eventId: 'd3-sea2',
|
||||
},
|
||||
// DAY 11 — 3월 11일: 호르무즈 기뢰 자폭
|
||||
{
|
||||
id: 'ds-3',
|
||||
name: 'IRGC MINESWEEPER',
|
||||
flag: 'IR',
|
||||
type: 'MILITARY',
|
||||
lat: 26.5667, lng: 56.2500,
|
||||
damagedAt: T0 + 10 * DAY + 4 * HOUR,
|
||||
cause: '자체 기뢰 폭발',
|
||||
damage: 'sunk',
|
||||
description: 'IRGC 소해정 1척, 자체 배치 기뢰 폭발로 침몰.',
|
||||
eventId: 'd11-ir1',
|
||||
},
|
||||
// DAY 12 — 3월 12일
|
||||
{
|
||||
id: 'ds-4',
|
||||
name: 'SHOWA MARU',
|
||||
flag: 'JP',
|
||||
type: 'VLCC',
|
||||
lat: 26.3500, lng: 56.5000,
|
||||
damagedAt: T0 + 11 * DAY,
|
||||
cause: '기뢰 접촉',
|
||||
damage: 'severe',
|
||||
description: '일본 국적 VLCC, 호르무즈 해협 기뢰 접촉. 선체 파공, 원유 유출.',
|
||||
eventId: 'd12-sea1',
|
||||
},
|
||||
{
|
||||
id: 'ds-5',
|
||||
name: 'SK INNOVATION',
|
||||
flag: 'KR',
|
||||
type: 'LNG',
|
||||
lat: 26.2000, lng: 56.6000,
|
||||
damagedAt: T0 + 11 * DAY + 2 * HOUR,
|
||||
cause: 'IRGC 자폭드론',
|
||||
damage: 'minor',
|
||||
description: '한국행 LNG 운반선, 드론 2대 피격. 경미 손상.',
|
||||
eventId: 'd12-sea2',
|
||||
},
|
||||
{
|
||||
id: 'ds-6',
|
||||
name: 'ATHENS EXPRESS',
|
||||
flag: 'GR',
|
||||
type: 'CONTAINER',
|
||||
lat: 25.8000, lng: 56.8000,
|
||||
damagedAt: T0 + 11 * DAY + 3 * HOUR,
|
||||
cause: 'IRGC 누르 대함미사일',
|
||||
damage: 'moderate',
|
||||
description: '그리스 컨테이너선, 대함미사일 피격. 화재, 승조원 부상.',
|
||||
eventId: 'd12-sea3',
|
||||
},
|
||||
{
|
||||
id: 'ds-7',
|
||||
name: 'IRGC FAST BOAT x5',
|
||||
flag: 'IR',
|
||||
type: 'MILITARY',
|
||||
lat: 26.6000, lng: 56.1000,
|
||||
damagedAt: T0 + 11 * DAY + 5 * HOUR,
|
||||
cause: '미 구축함 함포/CIWS',
|
||||
damage: 'sunk',
|
||||
description: 'IRGC 고속정 5척, USS 마이클 머피 교전 중 격침.',
|
||||
eventId: 'd12-sea4',
|
||||
},
|
||||
// DAY 12 후반 — 3월 12일 오후
|
||||
{
|
||||
id: 'ds-8',
|
||||
name: 'IRGC MINE LAYER',
|
||||
flag: 'IR',
|
||||
type: 'MILITARY',
|
||||
lat: 26.4500, lng: 56.3500,
|
||||
damagedAt: T0 + 11 * DAY + 14 * HOUR,
|
||||
cause: '자체 기뢰 폭발',
|
||||
damage: 'sunk',
|
||||
description: 'IRGC 기뢰부설정, 자체 배치 기뢰 접촉 폭발로 침몰. 승조원 12명 사망 추정.',
|
||||
eventId: 'd12-sea5',
|
||||
},
|
||||
{
|
||||
id: 'ds-9',
|
||||
name: 'PACIFIC PIONEER',
|
||||
flag: 'PA',
|
||||
type: 'BULK',
|
||||
lat: 26.3000, lng: 56.7000,
|
||||
damagedAt: T0 + 11 * DAY + 16 * HOUR,
|
||||
cause: '부유기뢰 접촉',
|
||||
damage: 'moderate',
|
||||
description: '파나마 국적 벌크선, 오만만 북부 부유기뢰 접촉. 선수 파공, 느린 침수. 오만 해군 구조.',
|
||||
eventId: 'd12-sea6',
|
||||
},
|
||||
{
|
||||
id: 'ds-10',
|
||||
name: 'IRGC FAST BOAT x20+',
|
||||
flag: 'IR',
|
||||
type: 'MILITARY',
|
||||
lat: 26.9800, lng: 56.0800,
|
||||
damagedAt: T0 + 11 * DAY + 14 * HOUR,
|
||||
cause: '미 F/A-18F 공습',
|
||||
damage: 'sunk',
|
||||
description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.',
|
||||
eventId: 'd12-us5',
|
||||
},
|
||||
];
|
||||
105
frontend/src/data/iranBorder.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// Simplified Iran border polygon (GeoJSON)
|
||||
// ~60 points tracing the approximate boundary
|
||||
export const iranBorderGeoJSON: GeoJSON.Feature = {
|
||||
type: 'Feature',
|
||||
properties: { name: 'Iran' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[
|
||||
// Northwest — Turkey/Armenia/Azerbaijan border
|
||||
[44.0, 39.4],
|
||||
[44.8, 39.7],
|
||||
[45.5, 39.0],
|
||||
[46.0, 38.9],
|
||||
[47.0, 39.2],
|
||||
[48.0, 38.8],
|
||||
[48.5, 38.5],
|
||||
[48.9, 38.4],
|
||||
// Caspian Sea coast (south shore)
|
||||
[49.0, 38.4],
|
||||
[49.5, 37.5],
|
||||
[50.0, 37.4],
|
||||
[50.5, 37.0],
|
||||
[51.0, 36.8],
|
||||
[51.5, 36.8],
|
||||
[52.0, 36.9],
|
||||
[53.0, 36.9],
|
||||
[53.9, 37.1],
|
||||
[54.7, 37.3],
|
||||
[55.4, 37.2],
|
||||
[56.0, 37.4],
|
||||
[57.0, 37.4],
|
||||
[57.4, 37.6],
|
||||
// Northeast — Turkmenistan border
|
||||
[58.0, 37.6],
|
||||
[58.8, 37.6],
|
||||
[59.3, 37.5],
|
||||
[60.0, 36.7],
|
||||
[60.5, 36.5],
|
||||
[61.0, 36.6],
|
||||
[61.2, 36.6],
|
||||
// East — Afghanistan border
|
||||
[61.2, 35.6],
|
||||
[61.2, 34.7],
|
||||
[61.0, 34.0],
|
||||
[60.5, 33.7],
|
||||
[60.5, 33.1],
|
||||
[60.8, 32.2],
|
||||
[60.8, 31.5],
|
||||
// Southeast — Pakistan border
|
||||
[61.7, 31.4],
|
||||
[61.8, 30.8],
|
||||
[61.4, 29.8],
|
||||
[60.9, 29.4],
|
||||
[60.6, 28.5],
|
||||
[61.0, 27.2],
|
||||
[62.0, 26.4],
|
||||
[63.2, 25.2],
|
||||
// South coast — Gulf of Oman / Persian Gulf
|
||||
[61.6, 25.2],
|
||||
[60.0, 25.3],
|
||||
[58.5, 25.6],
|
||||
[57.8, 25.7],
|
||||
[57.3, 26.0],
|
||||
[56.4, 26.2],
|
||||
[56.1, 26.0],
|
||||
[55.5, 26.0],
|
||||
[54.8, 26.5],
|
||||
[54.3, 26.5],
|
||||
[53.5, 26.6],
|
||||
[52.5, 27.2],
|
||||
[51.5, 27.9],
|
||||
[50.8, 28.3],
|
||||
[50.5, 28.8],
|
||||
[50.2, 29.1],
|
||||
[50.0, 29.3],
|
||||
[49.5, 29.6],
|
||||
[49.0, 29.8],
|
||||
[48.6, 29.9],
|
||||
// Southwest — Iraq border (Shatt al-Arab and west)
|
||||
[48.4, 30.4],
|
||||
[48.0, 30.5],
|
||||
[47.7, 30.9],
|
||||
[47.6, 31.4],
|
||||
[47.1, 31.6],
|
||||
[46.5, 32.0],
|
||||
[46.1, 32.2],
|
||||
[45.6, 32.9],
|
||||
[45.4, 33.4],
|
||||
[45.5, 33.9],
|
||||
[45.6, 34.2],
|
||||
[45.4, 34.5],
|
||||
[45.2, 35.0],
|
||||
[45.1, 35.4],
|
||||
[45.4, 35.8],
|
||||
[45.1, 36.0],
|
||||
[44.8, 36.4],
|
||||
[44.5, 37.0],
|
||||
[44.3, 37.5],
|
||||
[44.2, 38.0],
|
||||
[44.0, 38.4],
|
||||
[44.0, 39.0],
|
||||
[44.0, 39.4], // close polygon
|
||||
]],
|
||||
},
|
||||
};
|
||||
397
frontend/src/data/oilFacilities.ts
Normal file
@ -0,0 +1,397 @@
|
||||
import type { OilFacility } from '../types';
|
||||
|
||||
// T0 = 이란 보복 공격 기준시각
|
||||
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
|
||||
const HOUR = 3600_000;
|
||||
|
||||
// 이란 주요 석유·가스 시설 데이터
|
||||
// 출처: NIOC, EIA, IEA 공개 데이터 기반
|
||||
export const iranOilFacilities: OilFacility[] = [
|
||||
// ═══ 주요 정유시설 (Refineries) ═══
|
||||
{
|
||||
id: 'ref-abadan',
|
||||
name: 'Abadan Refinery',
|
||||
nameKo: '아바단 정유소',
|
||||
lat: 30.3358, lng: 48.2870,
|
||||
type: 'refinery',
|
||||
capacityBpd: 400_000,
|
||||
operator: 'NIOC',
|
||||
description: '이란 최대·최고(最古) 정유시설. 1912년 건설. 일 40만 배럴 처리.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+17 B-2 정밀폭격 예정 — 이란 최대 정유능력 무력화 목표',
|
||||
},
|
||||
{
|
||||
id: 'ref-isfahan',
|
||||
name: 'Isfahan Refinery',
|
||||
nameKo: '이스파한 정유소',
|
||||
lat: 32.6100, lng: 51.7300,
|
||||
type: 'refinery',
|
||||
capacityBpd: 375_000,
|
||||
operator: 'NIOC',
|
||||
description: '이란 2위 정유소. 일 37.5만 배럴 처리. 중부 이란 핵심 시설.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+18 F-35 편대 공격 예정 — 중부 정유능력 차단',
|
||||
},
|
||||
{
|
||||
id: 'ref-bandarabbas',
|
||||
name: 'Bandar Abbas Refinery (Persian Gulf Star)',
|
||||
nameKo: '반다르아바스 정유소 (페르시안걸프스타르)',
|
||||
lat: 27.1700, lng: 56.2200,
|
||||
type: 'refinery',
|
||||
capacityBpd: 360_000,
|
||||
operator: 'PGPIC',
|
||||
description: '2017년 완공 최신 정유소. 가스 응축액 처리. 일 36만 배럴.',
|
||||
damaged: true,
|
||||
damagedAt: T0 - 3 * HOUR, // IL airstrike il5 at 09:01 UTC
|
||||
},
|
||||
{
|
||||
id: 'ref-tehran',
|
||||
name: 'Tehran Refinery',
|
||||
nameKo: '테헤란 정유소',
|
||||
lat: 35.5700, lng: 51.4100,
|
||||
type: 'refinery',
|
||||
capacityBpd: 250_000,
|
||||
operator: 'NIOC',
|
||||
description: '수도 에너지 공급 핵심. 일 25만 배럴.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+19 테헤란 에너지 고립 작전 — 수도 연료공급 차단',
|
||||
},
|
||||
{
|
||||
id: 'ref-tabriz',
|
||||
name: 'Tabriz Refinery',
|
||||
nameKo: '타브리즈 정유소',
|
||||
lat: 38.0100, lng: 46.2700,
|
||||
type: 'refinery',
|
||||
capacityBpd: 110_000,
|
||||
operator: 'NIOC',
|
||||
description: '북서부 이란 주요 정유소. 일 11만 배럴.',
|
||||
damaged: true,
|
||||
damagedAt: T0 - 6.5 * HOUR, // US airstrike us12 at 05:31 UTC
|
||||
},
|
||||
{
|
||||
id: 'ref-arak',
|
||||
name: 'Arak Refinery (Imam Khomeini)',
|
||||
nameKo: '아라크 정유소 (이맘 호메이니)',
|
||||
lat: 34.0700, lng: 49.7100,
|
||||
type: 'refinery',
|
||||
capacityBpd: 250_000,
|
||||
operator: 'NIOC',
|
||||
description: '중부 이란 전략적 위치. 일 25만 배럴.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+18 F-15E 공격 예정 — 중부 연료보급 거점 파괴',
|
||||
},
|
||||
{
|
||||
id: 'ref-shiraz',
|
||||
name: 'Shiraz Refinery',
|
||||
nameKo: '시라즈 정유소',
|
||||
lat: 29.5500, lng: 52.4800,
|
||||
type: 'refinery',
|
||||
capacityBpd: 60_000,
|
||||
operator: 'NIOC',
|
||||
description: '남부 이란 정유소. 일 6만 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'ref-lavan',
|
||||
name: 'Lavan Refinery',
|
||||
nameKo: '라반 정유소',
|
||||
lat: 26.8100, lng: 53.3500,
|
||||
type: 'refinery',
|
||||
capacityBpd: 60_000,
|
||||
operator: 'NIOC',
|
||||
description: '라반섬 해상 정유소. 일 6만 배럴. 페르시아만 원유 수출.',
|
||||
},
|
||||
|
||||
// ═══ 주요 유전 (Oil Fields) ═══
|
||||
{
|
||||
id: 'oil-ahwaz',
|
||||
name: 'Ahwaz-Asmari Oil Field',
|
||||
nameKo: '아흐바즈-아스마리 유전',
|
||||
lat: 31.3200, lng: 48.6700,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 750_000,
|
||||
reservesBbl: 25.5,
|
||||
operator: 'NIOC',
|
||||
description: '이란 최대 유전. 확인매장량 255억 배럴. 후제스탄 주.',
|
||||
},
|
||||
{
|
||||
id: 'oil-marunfield',
|
||||
name: 'Marun Oil Field',
|
||||
nameKo: '마룬 유전',
|
||||
lat: 31.6500, lng: 49.2000,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 520_000,
|
||||
reservesBbl: 16.0,
|
||||
operator: 'NIOC',
|
||||
description: '이란 2위 유전. 확인매장량 160억 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'oil-gachsaran',
|
||||
name: 'Gachsaran Oil Field',
|
||||
nameKo: '가치사란 유전',
|
||||
lat: 30.3600, lng: 50.8000,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 560_000,
|
||||
reservesBbl: 15.0,
|
||||
operator: 'NIOC',
|
||||
description: '이란 3위 유전. 매장량 150억 배럴. 자그로스 산맥 서남.',
|
||||
},
|
||||
{
|
||||
id: 'oil-agha-jari',
|
||||
name: 'Aghajari Oil Field',
|
||||
nameKo: '아가자리 유전',
|
||||
lat: 30.7500, lng: 49.8300,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 200_000,
|
||||
reservesBbl: 14.0,
|
||||
operator: 'NIOC',
|
||||
description: '역사적 대형 유전. 매장량 140억 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'oil-karangoil',
|
||||
name: 'Karanj Oil Field',
|
||||
nameKo: '카란즈 유전',
|
||||
lat: 31.9500, lng: 49.0500,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 180_000,
|
||||
reservesBbl: 5.0,
|
||||
operator: 'NIOC',
|
||||
description: '후제스탄 주 주요 유전. 매장량 50억 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'oil-yadavaran',
|
||||
name: 'Yadavaran Oil Field',
|
||||
nameKo: '야다바란 유전',
|
||||
lat: 31.0200, lng: 47.8500,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 300_000,
|
||||
reservesBbl: 17.0,
|
||||
operator: 'NIOC / Sinopec',
|
||||
description: '이라크 국경 인근 초대형 유전. 매장량 170억 배럴. 중국 합작.',
|
||||
},
|
||||
{
|
||||
id: 'oil-azadegan',
|
||||
name: 'Azadegan Oil Field',
|
||||
nameKo: '아자데간 유전',
|
||||
lat: 31.5000, lng: 47.6000,
|
||||
type: 'oilfield',
|
||||
capacityBpd: 220_000,
|
||||
reservesBbl: 33.0,
|
||||
operator: 'NIOC',
|
||||
description: '이란 최대 미개발 유전. 매장량 330억 배럴.',
|
||||
},
|
||||
|
||||
// ═══ 가스전 (Gas Fields) ═══
|
||||
{
|
||||
id: 'gas-southpars',
|
||||
name: 'South Pars Gas Field',
|
||||
nameKo: '사우스파르스 가스전',
|
||||
lat: 27.0000, lng: 52.0000,
|
||||
type: 'gasfield',
|
||||
capacityMcfd: 20_000,
|
||||
reservesTcf: 500,
|
||||
operator: 'Pars Oil & Gas Co.',
|
||||
description: '세계 최대 가스전 (카타르 노스돔과 공유). 매장량 500조 입방피트. 이란 가스 수출 핵심.',
|
||||
},
|
||||
{
|
||||
id: 'gas-northpars',
|
||||
name: 'North Pars Gas Field',
|
||||
nameKo: '노스파르스 가스전',
|
||||
lat: 27.5000, lng: 52.5000,
|
||||
type: 'gasfield',
|
||||
capacityMcfd: 2_500,
|
||||
reservesTcf: 50,
|
||||
operator: 'NIOC',
|
||||
description: '페르시아만 해상 가스전. 매장량 50조 입방피트.',
|
||||
},
|
||||
{
|
||||
id: 'gas-kish',
|
||||
name: 'Kish Gas Field',
|
||||
nameKo: '키시 가스전',
|
||||
lat: 26.5500, lng: 53.9800,
|
||||
type: 'gasfield',
|
||||
capacityMcfd: 3_000,
|
||||
reservesTcf: 58,
|
||||
operator: 'NIOC',
|
||||
description: '키시섬 인근 해상 가스전. 매장량 58조 입방피트.',
|
||||
},
|
||||
|
||||
// ═══ 수출 터미널 (Export Terminals) ═══
|
||||
{
|
||||
id: 'term-kharg',
|
||||
name: 'Kharg Island Terminal',
|
||||
nameKo: '하르그섬 수출터미널',
|
||||
lat: 29.2300, lng: 50.3200,
|
||||
type: 'terminal',
|
||||
capacityBpd: 5_000_000,
|
||||
operator: 'NIOC',
|
||||
description: '이란 원유 수출의 90% 처리. 일 500만 배럴 수출 능력. 전략적 최핵심 시설.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+16 최우선 타격 예정 — 이란 원유 수출 90% 차단 목표. B-2·F-35 합동 공격.',
|
||||
},
|
||||
{
|
||||
id: 'term-lavan',
|
||||
name: 'Lavan Island Terminal',
|
||||
nameKo: '라반섬 수출터미널',
|
||||
lat: 26.7900, lng: 53.3600,
|
||||
type: 'terminal',
|
||||
capacityBpd: 200_000,
|
||||
operator: 'NIOC',
|
||||
description: '라반섬 원유 수출터미널. 일 20만 배럴.',
|
||||
},
|
||||
{
|
||||
id: 'term-jask',
|
||||
name: 'Jask Oil Terminal',
|
||||
nameKo: '자스크 수출터미널',
|
||||
lat: 25.6400, lng: 57.7700,
|
||||
type: 'terminal',
|
||||
capacityBpd: 1_000_000,
|
||||
operator: 'NIOC',
|
||||
description: '호르무즈 해협 우회 수출터미널. 2021년 개장. 일 100만 배럴.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+17 우회 수출로 차단 작전 — 오만만 경유 원유수출 봉쇄',
|
||||
},
|
||||
|
||||
// ═══ 석유화학단지 (Petrochemical) ═══
|
||||
{
|
||||
id: 'petro-assaluyeh',
|
||||
name: 'Assaluyeh Petrochemical Complex',
|
||||
nameKo: '아살루예 석유화학단지',
|
||||
lat: 27.4800, lng: 52.6100,
|
||||
type: 'petrochemical',
|
||||
operator: 'NPC',
|
||||
description: '사우스파르스 육상 가스처리 허브. 세계 최대급 석유화학 단지.',
|
||||
planned: true,
|
||||
plannedLabel: 'D+20 가스수출 차단 작전 — 사우스파르스 육상 처리시설 타격',
|
||||
},
|
||||
{
|
||||
id: 'petro-mahshahr',
|
||||
name: 'Mahshahr Petrochemical Zone',
|
||||
nameKo: '마흐샤르 석유화학단지',
|
||||
lat: 30.4600, lng: 49.1700,
|
||||
type: 'petrochemical',
|
||||
operator: 'NPC',
|
||||
description: '반다르 이맘 호메이니 인근 대규모 석유화학 단지.',
|
||||
},
|
||||
|
||||
// ═══ 담수화 시설 (Desalination Plants) — 호르무즈 해협 인근 ═══
|
||||
{
|
||||
id: 'desal-jebel-ali',
|
||||
name: 'Jebel Ali Desalination Plant',
|
||||
nameKo: '제벨알리 담수화시설',
|
||||
lat: 25.0547, lng: 55.0272,
|
||||
type: 'desalination',
|
||||
capacityMgd: 636,
|
||||
operator: 'DEWA',
|
||||
description: '세계 최대 담수화시설. 일 636만 갤런. 두바이 수돗물 98% 공급.',
|
||||
},
|
||||
{
|
||||
id: 'desal-taweelah',
|
||||
name: 'Taweelah Desalination Plant',
|
||||
nameKo: '타위라 담수화시설',
|
||||
lat: 24.6953, lng: 54.7428,
|
||||
type: 'desalination',
|
||||
capacityMgd: 200,
|
||||
operator: 'EWEC / ACWA Power',
|
||||
description: '세계 최대 RO 담수화시설. 일 2억 갤런. 아부다비 핵심 수자원.',
|
||||
},
|
||||
{
|
||||
id: 'desal-fujairah',
|
||||
name: 'Fujairah Desalination Plant',
|
||||
nameKo: '푸자이라 담수화시설',
|
||||
lat: 25.1288, lng: 56.3264,
|
||||
type: 'desalination',
|
||||
capacityMgd: 130,
|
||||
operator: 'FEWA',
|
||||
description: '호르무즈 해협 동측. 일 1.3억 갤런. 동부 에미리트 수자원.',
|
||||
},
|
||||
{
|
||||
id: 'desal-sohar',
|
||||
name: 'Sohar Desalination Plant',
|
||||
nameKo: '소하르 담수화시설',
|
||||
lat: 24.3476, lng: 56.7492,
|
||||
type: 'desalination',
|
||||
capacityMgd: 63,
|
||||
operator: 'Sohar Power / Suez',
|
||||
description: '오만 북부 산업지대 수자원. 일 6,300만 갤런.',
|
||||
},
|
||||
{
|
||||
id: 'desal-barka',
|
||||
name: 'Barka Desalination Plant',
|
||||
nameKo: '바르카 담수화시설',
|
||||
lat: 23.6850, lng: 57.8900,
|
||||
type: 'desalination',
|
||||
capacityMgd: 42,
|
||||
operator: 'Oman Power & Water',
|
||||
description: '오만 수도 무스카트 인근. 일 4,200만 갤런.',
|
||||
},
|
||||
{
|
||||
id: 'desal-ghubrah',
|
||||
name: 'Al Ghubrah Desalination Plant',
|
||||
nameKo: '알구브라 담수화시설',
|
||||
lat: 23.6000, lng: 58.4200,
|
||||
type: 'desalination',
|
||||
capacityMgd: 68,
|
||||
operator: 'PAEW',
|
||||
description: '무스카트 시내 위치. 오만 최대 담수화시설. 일 6,800만 갤런.',
|
||||
},
|
||||
{
|
||||
id: 'desal-ras-al-khair',
|
||||
name: 'Ras Al Khair Desalination Plant',
|
||||
nameKo: '라스 알 카이르 담수화시설',
|
||||
lat: 27.1500, lng: 49.2300,
|
||||
type: 'desalination',
|
||||
capacityMgd: 228,
|
||||
operator: 'SWCC',
|
||||
description: '세계 최대 하이브리드 담수화시설. 사우디 동부 해안. 일 2.28억 갤런.',
|
||||
},
|
||||
{
|
||||
id: 'desal-jubail',
|
||||
name: 'Jubail Desalination Plant',
|
||||
nameKo: '주바일 담수화시설',
|
||||
lat: 26.9598, lng: 49.5687,
|
||||
type: 'desalination',
|
||||
capacityMgd: 400,
|
||||
operator: 'SWCC',
|
||||
description: '사우디 동부 주바일 산업도시. 일 4억 갤런. 리야드까지 송수.',
|
||||
},
|
||||
{
|
||||
id: 'desal-hidd',
|
||||
name: 'Al Hidd Desalination Plant',
|
||||
nameKo: '알 히드 담수화시설',
|
||||
lat: 26.1500, lng: 50.6600,
|
||||
type: 'desalination',
|
||||
capacityMgd: 90,
|
||||
operator: 'EWA Bahrain',
|
||||
description: '바레인 주요 수자원. 일 9,000만 갤런. 국가 물 수요 80% 담당.',
|
||||
},
|
||||
{
|
||||
id: 'desal-ras-laffan',
|
||||
name: 'Ras Laffan Desalination Plant',
|
||||
nameKo: '라스 라판 담수화시설',
|
||||
lat: 25.9140, lng: 51.5260,
|
||||
type: 'desalination',
|
||||
capacityMgd: 63,
|
||||
operator: 'Kahramaa',
|
||||
description: '카타르 북부 LNG 허브 인접. 일 6,300만 갤런.',
|
||||
},
|
||||
{
|
||||
id: 'desal-azzour',
|
||||
name: 'Az-Zour Desalination Plant',
|
||||
nameKo: '아즈주르 담수화시설',
|
||||
lat: 28.7200, lng: 48.3700,
|
||||
type: 'desalination',
|
||||
capacityMgd: 107,
|
||||
operator: 'MEW Kuwait',
|
||||
description: '쿠웨이트 남부. 일 1.07억 갤런. 쿠웨이트시 수자원.',
|
||||
},
|
||||
{
|
||||
id: 'desal-bandarabbas',
|
||||
name: 'Bandar Abbas Desalination',
|
||||
nameKo: '반다르아바스 담수화시설',
|
||||
lat: 27.1800, lng: 56.2700,
|
||||
type: 'desalination',
|
||||
capacityMgd: 18,
|
||||
operator: 'ABFA Iran',
|
||||
description: '이란 호르무즈간 주. 일 1,800만 갤런. 이란 최대 해수담수화.',
|
||||
},
|
||||
];
|
||||
1495
frontend/src/data/sampleData.ts
Normal file
10
frontend/src/env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SPG_API_KEY?: string;
|
||||
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
78
frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
googleLogin,
|
||||
getMe,
|
||||
logout as logoutApi,
|
||||
} from '../services/authApi';
|
||||
import type { AuthUser } from '../services/authApi';
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const DEV_USER: AuthUser = {
|
||||
email: 'dev@gcsc.co.kr',
|
||||
name: 'Developer',
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getMe()
|
||||
.then((user) => {
|
||||
if (!cancelled) {
|
||||
setState({ user, isLoading: false, isAuthenticated: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (credential: string) => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
try {
|
||||
const user = await googleLogin(credential);
|
||||
setState({ user, isLoading: false, isAuthenticated: true });
|
||||
} catch {
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const devLogin = useCallback(() => {
|
||||
setState({ user: DEV_USER, isLoading: false, isAuthenticated: true });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutApi();
|
||||
} finally {
|
||||
setState({ user: null, isLoading: false, isAuthenticated: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
user: state.user,
|
||||
isLoading: state.isLoading,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
login,
|
||||
devLogin,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
36
frontend/src/hooks/useMonitor.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
const TICK_INTERVAL = 1000; // update every 1 second in live mode
|
||||
|
||||
export interface MonitorState {
|
||||
currentTime: number; // always Date.now()
|
||||
historyMinutes: number; // how far back to show (default 60)
|
||||
}
|
||||
|
||||
export function useMonitor() {
|
||||
const [state, setState] = useState<MonitorState>({
|
||||
currentTime: Date.now(),
|
||||
historyMinutes: 60,
|
||||
});
|
||||
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Start ticking immediately
|
||||
useEffect(() => {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setState(prev => ({ ...prev, currentTime: Date.now() }));
|
||||
}, TICK_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current !== null) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setHistoryMinutes = useCallback((minutes: number) => {
|
||||
setState(prev => ({ ...prev, historyMinutes: minutes }));
|
||||
}, []);
|
||||
|
||||
const startTime = state.currentTime - state.historyMinutes * 60_000;
|
||||
const endTime = state.currentTime;
|
||||
|
||||
return { state, startTime, endTime, setHistoryMinutes };
|
||||
}
|
||||