Merge pull request 'feat: 모노레포 전환 + signal-batch 연동 + Tailwind/i18n + 백엔드 스켈레톤' (#2) from feature/monorepo-restructure into develop
@ -43,5 +43,42 @@
|
|||||||
"Read(./**/.env.*)",
|
"Read(./**/.env.*)",
|
||||||
"Read(./**/secrets/**)"
|
"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
|
#!/bin/bash
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# pre-commit hook (React TypeScript)
|
# pre-commit hook (모노레포: Frontend + Backend)
|
||||||
# TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단
|
# Frontend: TypeScript 컴파일 + 린트 검증
|
||||||
|
# Backend: Maven 컴파일 검증
|
||||||
|
# 실패 시 커밋 차단
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
|
||||||
echo "pre-commit: TypeScript 타입 체크 중..."
|
FAILED=0
|
||||||
|
|
||||||
# npm 확인
|
#------------------------------------------------------------------------------
|
||||||
if ! command -v npx &>/dev/null; then
|
# Frontend 검증
|
||||||
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
|
#------------------------------------------------------------------------------
|
||||||
exit 0
|
if [ -d "frontend" ]; then
|
||||||
fi
|
echo "pre-commit: [Frontend] TypeScript 타입 체크 중..."
|
||||||
|
|
||||||
# node_modules 확인
|
if ! command -v npx &>/dev/null; then
|
||||||
if [ ! -d "node_modules" ]; then
|
echo "경고: npx가 설치되지 않았습니다. Frontend 검증을 건너뜁니다."
|
||||||
echo "경고: node_modules가 없습니다. 'npm install' 실행 후 다시 시도하세요."
|
elif [ ! -d "frontend/node_modules" ]; then
|
||||||
exit 1
|
echo "경고: frontend/node_modules가 없습니다. 'cd frontend && npm install' 실행 후 다시 시도하세요."
|
||||||
fi
|
FAILED=1
|
||||||
|
else
|
||||||
|
(cd frontend && npx tsc --noEmit --pretty 2>&1)
|
||||||
|
TSC_RESULT=$?
|
||||||
|
|
||||||
# TypeScript 타입 체크
|
if [ $TSC_RESULT -ne 0 ]; then
|
||||||
npx tsc --noEmit --pretty 2>&1
|
echo ""
|
||||||
TSC_RESULT=$?
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ [Frontend] TypeScript 타입 에러! 커밋이 차단되었습니다.║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
FAILED=1
|
||||||
|
else
|
||||||
|
echo "pre-commit: [Frontend] 타입 체크 성공"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $TSC_RESULT -ne 0 ]; then
|
# ESLint 검증
|
||||||
echo ""
|
if [ -f "frontend/eslint.config.js" ] || [ -f "frontend/eslint.config.mjs" ] || [ -f "frontend/.eslintrc.js" ] || [ -f "frontend/.eslintrc.json" ]; then
|
||||||
echo "╔══════════════════════════════════════════════════════════╗"
|
echo "pre-commit: [Frontend] ESLint 검증 중..."
|
||||||
echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║"
|
(cd frontend && npx eslint src/ --ext .ts,.tsx --quiet 2>&1)
|
||||||
echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║"
|
LINT_RESULT=$?
|
||||||
echo "╚══════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "pre-commit: 타입 체크 성공"
|
if [ $LINT_RESULT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
# ESLint 검증 (설정 파일이 있는 경우만)
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
|
echo "║ [Frontend] ESLint 에러! 커밋이 차단되었습니다. ║"
|
||||||
echo "pre-commit: ESLint 검증 중..."
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
npx eslint src/ --ext .ts,.tsx --quiet 2>&1
|
FAILED=1
|
||||||
LINT_RESULT=$?
|
else
|
||||||
|
echo "pre-commit: [Frontend] ESLint 통과"
|
||||||
if [ $LINT_RESULT -ne 0 ]; then
|
fi
|
||||||
echo ""
|
fi
|
||||||
echo "╔══════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
|
|
||||||
echo "║ 'npm run lint -- --fix'로 자동 수정을 시도해보세요. ║"
|
|
||||||
echo "╚══════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pre-commit: ESLint 통과"
|
|
||||||
fi
|
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: 모든 검증 통과"
|
||||||
|
|||||||
15
.gitignore
vendored
@ -68,3 +68,18 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
*.pdf
|
*.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` 참조
|
- **Node.js**: `.node-version` 참조
|
||||||
- **패키지 매니저**: npm
|
- **Java**: `backend/.sdkmanrc` 참조
|
||||||
- **빌드 도구**: Vite
|
- **패키지 매니저**: npm (frontend), Maven (backend), pip (prediction)
|
||||||
|
|
||||||
## 빌드 및 실행
|
## 빌드 및 실행
|
||||||
|
|
||||||
|
### Frontend
|
||||||
```bash
|
```bash
|
||||||
# 의존성 설치
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 개발 서버
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
# 빌드
|
### Backend
|
||||||
npm run build
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
# 테스트
|
### Database
|
||||||
npm run test
|
```bash
|
||||||
|
psql -U postgres -f database/init.sql
|
||||||
|
psql -U postgres -d kcgdb -f database/migration/001_initial_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
# 린트
|
### Prediction
|
||||||
npm run lint
|
```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/
|
frontend/
|
||||||
├── assets/ # 정적 리소스 (이미지, 폰트 등)
|
├── src/
|
||||||
├── components/ # 공통 UI 컴포넌트
|
│ ├── assets/
|
||||||
│ ├── common/ # 범용 컴포넌트 (Button, Input 등)
|
│ ├── components/
|
||||||
│ └── layout/ # 레이아웃 컴포넌트 (Header, Sidebar 등)
|
│ ├── hooks/
|
||||||
├── hooks/ # 커스텀 훅
|
│ ├── pages/
|
||||||
├── pages/ # 페이지 컴포넌트 (라우팅 단위)
|
│ ├── services/
|
||||||
├── services/ # API 호출 로직
|
│ ├── store/
|
||||||
├── store/ # 상태 관리 (Context, Zustand 등)
|
│ ├── types/
|
||||||
├── types/ # TypeScript 타입 정의
|
│ ├── utils/
|
||||||
├── utils/ # 유틸리티 함수
|
│ ├── App.tsx
|
||||||
├── App.tsx
|
│ └── main.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`)
|
- Frontend: Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
|
||||||
- 새 의존성 추가: `npm install 패키지명`
|
- Backend: Maven Central (pom.xml)
|
||||||
- devDependency: `npm install -D 패키지명`
|
- Prediction: pip (requirements.txt)
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
29
docs/RELEASE-NOTES.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
이 문서는 [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/) 형식을 따릅니다.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 프론트엔드 모노레포 이관 (`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 분류 기준으로 동기화
|
||||||
1730
package-lock.json → frontend/package-lock.json
generated
@ -10,17 +10,22 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.59.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
|
"i18next": "^25.8.18",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-i18next": "^16.5.8",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-map-gl": "^8.1.0",
|
"react-map-gl": "^8.1.0",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
Before Width: | Height: | 크기: 113 KiB After Width: | Height: | 크기: 113 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 86 KiB After Width: | Height: | 크기: 86 KiB |
|
Before Width: | Height: | 크기: 1.2 MiB After Width: | Height: | 크기: 1.2 MiB |
|
Before Width: | Height: | 크기: 70 KiB After Width: | Height: | 크기: 70 KiB |
|
Before Width: | Height: | 크기: 98 KiB After Width: | Height: | 크기: 98 KiB |
|
Before Width: | Height: | 크기: 107 KiB After Width: | Height: | 크기: 107 KiB |
|
Before Width: | Height: | 크기: 114 KiB After Width: | Height: | 크기: 114 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 112 KiB After Width: | Height: | 크기: 112 KiB |
|
Before Width: | Height: | 크기: 1.3 MiB After Width: | Height: | 크기: 1.3 MiB |
|
Before Width: | Height: | 크기: 123 KiB After Width: | Height: | 크기: 123 KiB |
|
Before Width: | Height: | 크기: 226 KiB After Width: | Height: | 크기: 226 KiB |
|
Before Width: | Height: | 크기: 119 KiB After Width: | Height: | 크기: 119 KiB |
|
Before Width: | Height: | 크기: 146 KiB After Width: | Height: | 크기: 146 KiB |
|
Before Width: | Height: | 크기: 2.4 MiB After Width: | Height: | 크기: 2.4 MiB |
|
Before Width: | Height: | 크기: 123 KiB After Width: | Height: | 크기: 123 KiB |
|
Before Width: | Height: | 크기: 1.5 KiB After Width: | Height: | 크기: 1.5 KiB |
@ -23,6 +23,10 @@ import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
|||||||
import type { OsintItem } from './services/osint';
|
import type { OsintItem } from './services/osint';
|
||||||
import { propagateAircraft, propagateShips } from './services/propagation';
|
import { propagateAircraft, propagateShips } from './services/propagation';
|
||||||
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types';
|
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, LayerVisibility, AppMode } from './types';
|
||||||
|
import { useTheme } from './hooks/useTheme';
|
||||||
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LoginPage from './components/auth/LoginPage';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
// MarineTraffic-style ship classification
|
// MarineTraffic-style ship classification
|
||||||
@ -74,6 +78,32 @@ function getMarineTrafficCategory(typecode?: string, category?: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth();
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--kcg-bg)', color: 'var(--kcg-muted)' }}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage onGoogleLogin={login} onDevLogin={devLogin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthenticatedApp user={user} onLogout={logout} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedAppProps {
|
||||||
|
user: { email: string; name: string; picture?: string } | null;
|
||||||
|
onLogout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedApp(_props: AuthenticatedAppProps) {
|
||||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||||
const [events, setEvents] = useState<GeoEvent[]>([]);
|
const [events, setEvents] = useState<GeoEvent[]>([]);
|
||||||
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
||||||
@ -100,6 +130,47 @@ function App() {
|
|||||||
militaryOnly: false,
|
militaryOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Korea tab layer visibility (lifted from KoreaMap)
|
||||||
|
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({
|
||||||
|
ships: true,
|
||||||
|
aircraft: true,
|
||||||
|
satellites: true,
|
||||||
|
infra: true,
|
||||||
|
cables: true,
|
||||||
|
cctv: true,
|
||||||
|
airports: true,
|
||||||
|
coastGuard: true,
|
||||||
|
navWarning: true,
|
||||||
|
osint: true,
|
||||||
|
eez: true,
|
||||||
|
piracy: true,
|
||||||
|
militaryOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleKoreaLayer = useCallback((key: string) => {
|
||||||
|
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Category filter state (shared across tabs)
|
||||||
|
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleAcCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenAcCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleShipCategory = useCallback((cat: string) => {
|
||||||
|
setHiddenShipCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||||
|
|
||||||
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
|
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
|
||||||
@ -125,6 +196,11 @@ function App() {
|
|||||||
|
|
||||||
const replay = useReplay();
|
const replay = useReplay();
|
||||||
const monitor = useMonitor();
|
const monitor = useMonitor();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const toggleLang = useCallback(() => {
|
||||||
|
i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko');
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
const isLive = appMode === 'live';
|
const isLive = appMode === 'live';
|
||||||
|
|
||||||
@ -211,7 +287,7 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
// Fetch Korea region ship data (separate pipeline)
|
// Fetch Korea region ship data (signal-batch, 4-min cycle)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@ -220,7 +296,7 @@ function App() {
|
|||||||
} catch { /* keep previous */ }
|
} catch { /* keep previous */ }
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const interval = setInterval(load, 15_000);
|
const interval = setInterval(load, 240_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [appMode, refreshKey]);
|
}, [appMode, refreshKey]);
|
||||||
|
|
||||||
@ -376,6 +452,24 @@ function App() {
|
|||||||
[baseShipsKorea, currentTime, isLive],
|
[baseShipsKorea, currentTime, isLive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Category-filtered data for map rendering
|
||||||
|
const visibleAircraft = useMemo(
|
||||||
|
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||||
|
[aircraft, hiddenAcCategories],
|
||||||
|
);
|
||||||
|
const visibleShips = useMemo(
|
||||||
|
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||||
|
[ships, hiddenShipCategories],
|
||||||
|
);
|
||||||
|
const visibleAircraftKorea = useMemo(
|
||||||
|
() => aircraftKorea.filter(a => !hiddenAcCategories.has(a.category)),
|
||||||
|
[aircraftKorea, hiddenAcCategories],
|
||||||
|
);
|
||||||
|
const visibleKoreaShips = useMemo(
|
||||||
|
() => koreaShips.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
|
||||||
|
[koreaShips, hiddenShipCategories],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||||
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
}, []);
|
}, []);
|
||||||
@ -398,12 +492,17 @@ function App() {
|
|||||||
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||||
[aircraft],
|
[aircraft],
|
||||||
);
|
);
|
||||||
|
const koreaMilitaryCount = useMemo(
|
||||||
|
() => aircraftKorea.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
|
||||||
|
[aircraftKorea],
|
||||||
|
);
|
||||||
|
|
||||||
// Ship stats
|
// Ship stats — MT classification (matches map icon colors)
|
||||||
const shipsByCategory = useMemo(() => {
|
const shipsByCategory = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
counts[s.category] = (counts[s.category] || 0) + 1;
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
@ -424,12 +523,21 @@ function App() {
|
|||||||
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
|
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
|
||||||
const koreaShipsByCategory = useMemo(() => {
|
const koreaShipsByCategory = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of koreaKoreanShips) {
|
for (const s of koreaShips) {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [koreaKoreanShips]);
|
}, [koreaShips]);
|
||||||
|
|
||||||
|
// Korea aircraft stats
|
||||||
|
const koreaAircraftByCategory = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const ac of aircraftKorea) {
|
||||||
|
counts[ac.category] = (counts[ac.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [aircraftKorea]);
|
||||||
|
|
||||||
// Korea filtered ships by monitoring mode (independent toggles, additive highlight)
|
// Korea filtered ships by monitoring mode (independent toggles, additive highlight)
|
||||||
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
|
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
|
||||||
@ -701,8 +809,8 @@ function App() {
|
|||||||
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
|
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
|
||||||
|
|
||||||
const koreaFilteredShips = useMemo(() => {
|
const koreaFilteredShips = useMemo(() => {
|
||||||
if (!anyFilterOn) return koreaShips;
|
if (!anyFilterOn) return visibleKoreaShips;
|
||||||
return koreaShips.filter(s => {
|
return visibleKoreaShips.filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
||||||
if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
if (koreaFilters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
||||||
@ -712,7 +820,7 @@ function App() {
|
|||||||
if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [koreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
}, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||||
@ -724,28 +832,22 @@ function App() {
|
|||||||
onClick={() => setDashboardTab('iran')}
|
onClick={() => setDashboardTab('iran')}
|
||||||
>
|
>
|
||||||
<span className="dash-tab-flag">🇮🇷</span>
|
<span className="dash-tab-flag">🇮🇷</span>
|
||||||
이란 상황
|
{t('tabs.iran')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
|
||||||
onClick={() => setDashboardTab('korea')}
|
onClick={() => setDashboardTab('korea')}
|
||||||
>
|
>
|
||||||
<span className="dash-tab-flag">🇰🇷</span>
|
<span className="dash-tab-flag">🇰🇷</span>
|
||||||
한국 현황
|
{t('tabs.korea')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode Toggle */}
|
{/* Mode Toggle */}
|
||||||
{dashboardTab === 'iran' && (
|
{dashboardTab === 'iran' && (
|
||||||
<div className="mode-toggle">
|
<div className="mode-toggle">
|
||||||
<div style={{
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
<span className="text-[13px]">⚔️</span>
|
||||||
fontFamily: 'monospace', fontSize: 11, color: '#ef4444',
|
|
||||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
borderRadius: 6, padding: '4px 10px', fontWeight: 700,
|
|
||||||
animation: 'pulse 3s ease-in-out infinite',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 13 }}>⚔️</span>
|
|
||||||
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -753,14 +855,14 @@ function App() {
|
|||||||
onClick={() => setAppMode('live')}
|
onClick={() => setAppMode('live')}
|
||||||
>
|
>
|
||||||
<span className="mode-dot-icon" />
|
<span className="mode-dot-icon" />
|
||||||
LIVE
|
{t('mode.live')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
|
||||||
onClick={() => setAppMode('replay')}
|
onClick={() => setAppMode('replay')}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||||
REPLAY
|
{t('mode.replay')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -770,50 +872,50 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalFishing: !prev.illegalFishing }))}
|
||||||
title="불법어선 감시"
|
title={t('filters.illegalFishing')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🚫🐟</span>
|
<span className="text-[11px]">🚫🐟</span>
|
||||||
불법어선
|
{t('filters.illegalFishing')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, illegalTransship: !prev.illegalTransship }))}
|
||||||
title="불법환적 감시"
|
title={t('filters.illegalTransship')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>⚓</span>
|
<span className="text-[11px]">⚓</span>
|
||||||
불법환적
|
{t('filters.illegalTransship')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, darkVessel: !prev.darkVessel }))}
|
||||||
title="다크베셀 (AIS 미송출)"
|
title={t('filters.darkVessel')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>👻</span>
|
<span className="text-[11px]">👻</span>
|
||||||
다크베셀
|
{t('filters.darkVessel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, cableWatch: !prev.cableWatch }))}
|
||||||
title="해저케이블 근처 의심선박 감시"
|
title={t('filters.cableWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🔌</span>
|
<span className="text-[11px]">🔌</span>
|
||||||
해저케이블
|
{t('filters.cableWatch')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, dokdoWatch: !prev.dokdoWatch }))}
|
||||||
title="독도/울릉도 영해 접근 외국선박 감시"
|
title={t('filters.dokdoWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🏝️</span>
|
<span className="text-[11px]">🏝️</span>
|
||||||
독도감시
|
{t('filters.dokdoWatch')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
|
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
|
||||||
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
|
onClick={() => setKoreaFilters(prev => ({ ...prev, ferryWatch: !prev.ferryWatch }))}
|
||||||
title="여객선/크루즈/페리 위치 감시"
|
title={t('filters.ferryWatch')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 11 }}>🚢</span>
|
<span className="text-[11px]">🚢</span>
|
||||||
여객선감시
|
{t('filters.ferryWatch')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -825,35 +927,43 @@ function App() {
|
|||||||
onClick={() => setMapMode('flat')}
|
onClick={() => setMapMode('flat')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="1" stroke="currentColor" strokeWidth="1.5"/><line x1="1" y1="5" x2="13" y2="5" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="9" x2="13" y2="9" stroke="currentColor" strokeWidth="1"/><line x1="5" y1="1" x2="5" y2="13" stroke="currentColor" strokeWidth="1"/><line x1="9" y1="1" x2="9" y2="13" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
FLAT MAP
|
{t('mapMode.flat')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
|
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
|
||||||
onClick={() => setMapMode('globe')}
|
onClick={() => setMapMode('globe')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><ellipse cx="7" cy="7" rx="3" ry="6" stroke="currentColor" strokeWidth="1"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="1"/></svg>
|
||||||
GLOBE
|
{t('mapMode.globe')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
|
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
|
||||||
onClick={() => setMapMode('satellite')}
|
onClick={() => setMapMode('satellite')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.5"/><path d="M3 4.5C4.5 3 6 2.5 7 2.5s3 1 4.5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M2.5 7.5C4 6 6 5 7 5s3.5 1 5 2.5" stroke="currentColor" strokeWidth="0.8"/><path d="M3 10C4.5 8.5 6 8 7 8s3 .5 4 1.5" stroke="currentColor" strokeWidth="0.8"/><circle cx="7" cy="6" r="1.5" fill="currentColor" opacity="0.3"/></svg>
|
||||||
위성지도
|
{t('mapMode.satellite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="header-info">
|
<div className="header-info">
|
||||||
<div className="header-counts">
|
<div className="header-counts">
|
||||||
<span className="count-item ac-count">{aircraft.length} AC</span>
|
<span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
|
||||||
<span className="count-item mil-count">{militaryCount} MIL</span>
|
<span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
|
||||||
<span className="count-item ship-count">{ships.length} SHIP</span>
|
<span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</span>
|
||||||
<span className="count-item sat-count">{satPositions.length} SAT</span>
|
<span className="count-item sat-count">{dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-toggles">
|
||||||
|
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||||
|
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="header-toggle-btn" onClick={toggleTheme} title="Theme">
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-status">
|
<div className="header-status">
|
||||||
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
||||||
{isLive ? 'LIVE' : replay.state.isPlaying ? 'REPLAYING' : 'PAUSED'}
|
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -870,9 +980,9 @@ function App() {
|
|||||||
key="map-iran"
|
key="map-iran"
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
flyToTarget={flyToTarget}
|
flyToTarget={flyToTarget}
|
||||||
onFlyToDone={() => setFlyToTarget(null)}
|
onFlyToDone={() => setFlyToTarget(null)}
|
||||||
@ -881,32 +991,41 @@ function App() {
|
|||||||
<GlobeMap
|
<GlobeMap
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SatelliteMap
|
<SatelliteMap
|
||||||
events={isLive ? [] : mergedEvents}
|
events={isLive ? [] : mergedEvents}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
aircraft={aircraft}
|
aircraft={visibleAircraft}
|
||||||
satellites={satPositions}
|
satellites={satPositions}
|
||||||
ships={ships}
|
ships={visibleShips}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="map-overlay-left">
|
<div className="map-overlay-left">
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
layers={layers}
|
layers={layers as unknown as Record<string, boolean>}
|
||||||
onToggle={toggleLayer}
|
onToggle={toggleLayer as (key: string) => void}
|
||||||
aircraftCount={aircraft.length}
|
|
||||||
militaryCount={militaryCount}
|
|
||||||
satelliteCount={satPositions.length}
|
|
||||||
shipCount={ships.length}
|
|
||||||
koreanShipCount={koreanShips.length}
|
|
||||||
aircraftByCategory={aircraftByCategory}
|
aircraftByCategory={aircraftByCategory}
|
||||||
shipsByCategory={shipsByCategory}
|
aircraftTotal={aircraft.length}
|
||||||
|
shipsByMtCategory={shipsByCategory}
|
||||||
|
shipTotal={ships.length}
|
||||||
|
satelliteCount={satPositions.length}
|
||||||
|
extraLayers={[
|
||||||
|
{ key: 'events', label: t('layers.events'), color: '#a855f7' },
|
||||||
|
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: koreanShips.length },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
||||||
|
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
||||||
|
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -983,7 +1102,45 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="map-panel">
|
<div className="map-panel">
|
||||||
<KoreaMap ships={koreaFilteredShips} aircraft={aircraftKorea} satellites={satPositionsKorea} militaryOnly={layers.militaryOnly} osintFeed={osintFeed} currentTime={currentTime} koreaFilters={koreaFilters} transshipSuspects={transshipSuspects} cableWatchSuspects={cableWatchSet} dokdoWatchSuspects={dokdoWatchSet} dokdoAlerts={dokdoAlerts} />
|
<KoreaMap
|
||||||
|
ships={koreaFilteredShips}
|
||||||
|
aircraft={visibleAircraftKorea}
|
||||||
|
satellites={satPositionsKorea}
|
||||||
|
layers={koreaLayers}
|
||||||
|
osintFeed={osintFeed}
|
||||||
|
currentTime={currentTime}
|
||||||
|
koreaFilters={koreaFilters}
|
||||||
|
transshipSuspects={transshipSuspects}
|
||||||
|
cableWatchSuspects={cableWatchSet}
|
||||||
|
dokdoWatchSuspects={dokdoWatchSet}
|
||||||
|
dokdoAlerts={dokdoAlerts}
|
||||||
|
/>
|
||||||
|
<div className="map-overlay-left">
|
||||||
|
<LayerPanel
|
||||||
|
layers={koreaLayers}
|
||||||
|
onToggle={toggleKoreaLayer}
|
||||||
|
aircraftByCategory={koreaAircraftByCategory}
|
||||||
|
aircraftTotal={aircraftKorea.length}
|
||||||
|
shipsByMtCategory={koreaShipsByCategory}
|
||||||
|
shipTotal={koreaShips.length}
|
||||||
|
satelliteCount={satPositionsKorea.length}
|
||||||
|
extraLayers={[
|
||||||
|
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
|
||||||
|
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff' },
|
||||||
|
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15 },
|
||||||
|
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 16 },
|
||||||
|
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 46 },
|
||||||
|
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308' },
|
||||||
|
{ key: 'osint', label: t('layers.osint'), color: '#ef4444' },
|
||||||
|
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6' },
|
||||||
|
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444' },
|
||||||
|
]}
|
||||||
|
hiddenAcCategories={hiddenAcCategories}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
onAcCategoryToggle={toggleAcCategory}
|
||||||
|
onShipCategoryToggle={toggleShipCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="side-panel">
|
<aside className="side-panel">
|
||||||
|
Before Width: | Height: | 크기: 4.0 KiB After Width: | Height: | 크기: 4.0 KiB |
@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo, useState, useEffect } from 'react';
|
import { memo, useMemo, useState, useEffect } from 'react';
|
||||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Aircraft, AircraftCategory } from '../types';
|
import type { Aircraft, AircraftCategory } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -55,7 +56,7 @@ const ALT_COLORS: [number, string][] = [
|
|||||||
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
|
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const MIL_COLORS: Partial<Record<AircraftCategory, string>> = {
|
const MIL_HEX: Partial<Record<AircraftCategory, string>> = {
|
||||||
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
|
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,22 +69,18 @@ function getAltitudeColor(altMeters: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAircraftColor(ac: Aircraft): string {
|
function getAircraftColor(ac: Aircraft): string {
|
||||||
const milColor = MIL_COLORS[ac.category];
|
const milColor = MIL_HEX[ac.category];
|
||||||
if (milColor) return milColor;
|
if (milColor) return milColor;
|
||||||
if (ac.onGround) return '#555555';
|
if (ac.onGround) return '#555555';
|
||||||
return getAltitudeColor(ac.altitude);
|
return getAltitudeColor(ac.altitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<AircraftCategory, string> = {
|
|
||||||
fighter: 'FIGHTER', tanker: 'TANKER', surveillance: 'ISR',
|
|
||||||
cargo: 'CARGO', military: 'MIL', civilian: 'CIV', unknown: '???',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══ Planespotters.net photo API ═══
|
// ═══ Planespotters.net photo API ═══
|
||||||
interface PhotoResult { url: string; photographer: string; link: string; }
|
interface PhotoResult { url: string; photographer: string; link: string; }
|
||||||
const photoCache = new Map<string, PhotoResult | null>();
|
const photoCache = new Map<string, PhotoResult | null>();
|
||||||
|
|
||||||
function AircraftPhoto({ hex }: { hex: string }) {
|
function AircraftPhoto({ hex }: { hex: string }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
||||||
photoCache.has(hex) ? photoCache.get(hex) : undefined,
|
photoCache.has(hex) ? photoCache.get(hex) : undefined,
|
||||||
);
|
);
|
||||||
@ -119,19 +116,19 @@ function AircraftPhoto({ hex }: { hex: string }) {
|
|||||||
}, [hex, photo]);
|
}, [hex, photo]);
|
||||||
|
|
||||||
if (photo === undefined) {
|
if (photo === undefined) {
|
||||||
return <div style={{ textAlign: 'center', padding: 8, color: '#888', fontSize: 10 }}>Loading photo...</div>;
|
return <div className="text-center p-2 text-kcg-muted text-[10px]">{t('aircraftPopup.loadingPhoto')}</div>;
|
||||||
}
|
}
|
||||||
if (!photo) return null;
|
if (!photo) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div className="mb-1.5">
|
||||||
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
||||||
<img src={photo.url} alt="Aircraft"
|
<img src={photo.url} alt="Aircraft"
|
||||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
className="w-full rounded block"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
{photo.photographer && (
|
{photo.photographer && (
|
||||||
<div style={{ fontSize: 9, color: '#999', marginTop: 2, textAlign: 'right' }}>
|
<div className="text-[9px] text-[#999] mt-0.5 text-right">
|
||||||
© {photo.photographer}
|
© {photo.photographer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -188,6 +185,7 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
|||||||
|
|
||||||
// ═══ Aircraft Marker ═══
|
// ═══ Aircraft Marker ═══
|
||||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
const color = getAircraftColor(ac);
|
const color = getAircraftColor(ac);
|
||||||
const shape = getShape(ac);
|
const shape = getShape(ac);
|
||||||
@ -198,10 +196,11 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: size, height: size, cursor: 'pointer',
|
width: size, height: size,
|
||||||
transform: `rotate(${ac.heading}deg)`,
|
transform: `rotate(${ac.heading}deg)`,
|
||||||
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
||||||
}}
|
}}
|
||||||
@ -224,37 +223,37 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
|||||||
<Popup longitude={ac.lng} latitude={ac.lat}
|
<Popup longitude={ac.lng} latitude={ac.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||||
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong>
|
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
|
||||||
<span style={{
|
<span
|
||||||
background: color, color: '#000', padding: '1px 6px',
|
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto',
|
style={{ background: color }}
|
||||||
}}>
|
>
|
||||||
{CATEGORY_LABELS[ac.category]}
|
{t(`aircraftLabel.${ac.category}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<AircraftPhoto hex={ac.icao24} />
|
<AircraftPhoto hex={ac.icao24} />
|
||||||
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}>
|
<table className="w-full text-[11px] border-collapse">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td style={{ color: '#888', paddingRight: 8 }}>Hex</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
<tr><td className="text-kcg-muted pr-2">{t('aircraftPopup.hex')}</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
||||||
{ac.registration && <tr><td style={{ color: '#888' }}>Reg.</td><td><strong>{ac.registration}</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 style={{ color: '#888' }}>Operator</td><td>{ac.operator}</td></tr>}
|
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
|
||||||
{ac.typecode && (
|
{ac.typecode && (
|
||||||
<tr><td style={{ color: '#888' }}>Type</td>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.type')}</td>
|
||||||
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
||||||
)}
|
)}
|
||||||
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>}
|
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
|
||||||
<tr><td style={{ color: '#888' }}>Alt</td>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
|
||||||
<td>{ac.onGround ? 'GROUND' : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Speed</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Hdg</td><td>{Math.round(ac.heading)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>V/S</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ marginTop: 6, fontSize: 10, textAlign: 'right' }}>
|
<div className="mt-1.5 text-[10px] text-right">
|
||||||
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
||||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||||
Airplanes.live →
|
Airplanes.live →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
|
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
|
||||||
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
|
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
|
||||||
@ -25,32 +26,25 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
|
|||||||
const isVts = type === 'vts';
|
const isVts = type === 'vts';
|
||||||
|
|
||||||
if (isVts) {
|
if (isVts) {
|
||||||
// VTS: 레이더/안테나 아이콘
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<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" />
|
<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" />
|
<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" />
|
<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="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" />
|
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해경 로고: 방패 + 앵커
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<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"
|
<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" />
|
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" />
|
<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" />
|
<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" />
|
<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" />
|
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
|
||||||
{/* 별 (본청/지방청) */}
|
|
||||||
{(type === 'hq' || type === 'regional') && (
|
{(type === 'hq' || type === 'regional') && (
|
||||||
<circle cx="12" cy="9" r="1" fill={color} />
|
<circle cx="12" cy="9" r="1" fill={color} />
|
||||||
)}
|
)}
|
||||||
@ -60,6 +54,7 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
|
|||||||
|
|
||||||
export function CoastGuardLayer() {
|
export function CoastGuardLayer() {
|
||||||
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -69,25 +64,23 @@ export function CoastGuardLayer() {
|
|||||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<CoastGuardIcon type={f.type} size={size} />
|
<CoastGuardIcon type={f.type} size={size} />
|
||||||
{(f.type === 'hq' || f.type === 'regional') && (
|
{(f.type === 'hq' || f.type === 'regional') && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 6, color: '#fff', marginTop: 1,
|
fontSize: 6,
|
||||||
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700,
|
}} className="mt-px whitespace-nowrap font-bold text-white">
|
||||||
}}>
|
|
||||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{f.type === 'vts' && (
|
{f.type === 'vts' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color: '#da77f2', marginTop: 0,
|
fontSize: 5,
|
||||||
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.5,
|
}} className="whitespace-nowrap font-bold tracking-wider text-[#da77f2]">
|
||||||
}}>
|
|
||||||
VTS
|
VTS
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -100,27 +93,23 @@ export function CoastGuardLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
|
<div className="min-w-[200px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: TYPE_COLOR[selected.type], color: '#000',
|
background: TYPE_COLOR[selected.type],
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} 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">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.name}
|
{selected.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: TYPE_COLOR[selected.type], color: '#000',
|
background: TYPE_COLOR[selected.type],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
}}>{CG_TYPE_LABEL[selected.type]}</span>
|
{CG_TYPE_LABEL[selected.type]}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#4dabf7',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
{t('coastGuard.agency')}
|
||||||
}}>해양경찰청</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666' }}>
|
<div className="text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent, Ship } from '../types';
|
import type { GeoEvent, Ship } from '../types';
|
||||||
import type { OsintItem } from '../services/osint';
|
import type { OsintItem } from '../services/osint';
|
||||||
|
|
||||||
@ -250,16 +251,17 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
|
|||||||
intercept: 'INTERCEPT',
|
intercept: 'INTERCEPT',
|
||||||
alert: 'ALERT',
|
alert: 'ALERT',
|
||||||
impact: 'IMPACT',
|
impact: 'IMPACT',
|
||||||
|
osint: 'OSINT',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
||||||
airstrike: '#ef4444',
|
airstrike: 'var(--kcg-event-airstrike)',
|
||||||
explosion: '#f97316',
|
explosion: 'var(--kcg-event-explosion)',
|
||||||
missile_launch: '#eab308',
|
missile_launch: 'var(--kcg-event-missile)',
|
||||||
intercept: '#3b82f6',
|
intercept: 'var(--kcg-event-intercept)',
|
||||||
alert: '#a855f7',
|
alert: 'var(--kcg-event-alert)',
|
||||||
impact: '#ff0000',
|
impact: 'var(--kcg-event-impact)',
|
||||||
osint: '#06b6d4',
|
osint: 'var(--kcg-event-osint)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// MarineTraffic-style ship type classification
|
// MarineTraffic-style ship type classification
|
||||||
@ -282,54 +284,81 @@ function getShipMTCategory(typecode?: string, category?: string): string {
|
|||||||
return 'unspecified';
|
return 'unspecified';
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarineTraffic-style category labels and colors
|
// MarineTraffic-style category colors (labels come from i18n)
|
||||||
const MT_CATEGORIES: Record<string, { label: string; color: string }> = {
|
const MT_CATEGORY_COLORS: Record<string, string> = {
|
||||||
cargo: { label: '화물선', color: '#8bc34a' }, // green
|
cargo: '#8bc34a',
|
||||||
tanker: { label: '유조선', color: '#e91e63' }, // red/pink
|
tanker: '#e91e63',
|
||||||
passenger: { label: '여객선', color: '#2196f3' }, // blue
|
passenger: '#2196f3',
|
||||||
high_speed: { label: '고속선', color: '#ff9800' }, // orange
|
high_speed: '#ff9800',
|
||||||
tug_special: { label: '예인선/특수선', color: '#00bcd4' }, // teal
|
tug_special: '#00bcd4',
|
||||||
fishing: { label: '어선', color: '#ff5722' }, // deep orange
|
fishing: '#ff5722',
|
||||||
pleasure: { label: '레저선', color: '#9c27b0' }, // purple
|
pleasure: '#9c27b0',
|
||||||
military: { label: '군함', color: '#607d8b' }, // blue-grey
|
military: '#607d8b',
|
||||||
unspecified: { label: '미분류', color: '#9e9e9e' }, // grey
|
unspecified: '#9e9e9e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const NEWS_CATEGORY_STYLE: Record<BreakingNews['category'], { icon: string; color: string; label: string }> = {
|
const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
|
||||||
trump: { icon: '🇺🇸', color: '#ef4444', label: '트럼프' },
|
trump: '\u{1F1FA}\u{1F1F8}',
|
||||||
oil: { icon: '🛢️', color: '#f59e0b', label: '유가' },
|
oil: '\u{1F6E2}\u{FE0F}',
|
||||||
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
|
diplomacy: '\u{1F310}',
|
||||||
economy: { icon: '📊', color: '#3b82f6', label: '경제' },
|
economy: '\u{1F4CA}',
|
||||||
};
|
};
|
||||||
|
|
||||||
// OSINT category styles
|
// OSINT category icons (labels come from i18n)
|
||||||
const OSINT_CAT_STYLE: Record<string, { icon: string; color: string; label: string }> = {
|
const OSINT_CAT_ICONS: Record<string, string> = {
|
||||||
military: { icon: '🎯', color: '#ef4444', label: '군사' },
|
military: '\u{1F3AF}',
|
||||||
oil: { icon: '🛢', color: '#f59e0b', label: '에너지' },
|
oil: '\u{1F6E2}',
|
||||||
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
|
diplomacy: '\u{1F310}',
|
||||||
shipping: { icon: '🚢', color: '#06b6d4', label: '해운' },
|
shipping: '\u{1F6A2}',
|
||||||
nuclear: { icon: '☢', color: '#f97316', label: '핵' },
|
nuclear: '\u{2622}',
|
||||||
maritime_accident: { icon: '🚨', color: '#ef4444', label: '해양사고' },
|
maritime_accident: '\u{1F6A8}',
|
||||||
fishing: { icon: '🐟', color: '#22c55e', label: '어선/수산' },
|
fishing: '\u{1F41F}',
|
||||||
maritime_traffic: { icon: '🚢', color: '#3b82f6', label: '해상교통' },
|
maritime_traffic: '\u{1F6A2}',
|
||||||
general: { icon: '📰', color: '#6b7280', label: '일반' },
|
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_OSINT: OsintItem[] = [];
|
||||||
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
const EMPTY_SHIPS: import('../types').Ship[] = [];
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function useTimeAgo() {
|
||||||
const diff = Date.now() - ts;
|
const { t } = useTranslation('common');
|
||||||
const mins = Math.floor(diff / 60000);
|
return (ts: number): string => {
|
||||||
if (mins < 1) return '방금';
|
const diff = Date.now() - ts;
|
||||||
if (mins < 60) return `${mins}분 전`;
|
const mins = Math.floor(diff / 60000);
|
||||||
const hours = Math.floor(mins / 60);
|
if (mins < 1) return t('time.justNow');
|
||||||
if (hours < 24) return `${hours}시간 전`;
|
if (mins < 60) return t('time.minutesAgo', { count: mins });
|
||||||
const days = Math.floor(hours / 24);
|
const hours = Math.floor(mins / 60);
|
||||||
return `${days}일 전`;
|
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) {
|
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(
|
const visibleEvents = useMemo(
|
||||||
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
||||||
[events, currentTime],
|
[events, currentTime],
|
||||||
@ -374,17 +403,18 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<div className="breaking-news-section">
|
<div className="breaking-news-section">
|
||||||
<div className="breaking-news-header">
|
<div className="breaking-news-header">
|
||||||
<span className="breaking-flash">BREAKING</span>
|
<span className="breaking-flash">BREAKING</span>
|
||||||
<span className="breaking-title">속보 / 주요 뉴스</span>
|
<span className="breaking-title">{t('events:news.breakingTitle')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="breaking-news-list">
|
<div className="breaking-news-list">
|
||||||
{visibleNews.map(n => {
|
{visibleNews.map(n => {
|
||||||
const style = NEWS_CATEGORY_STYLE[n.category];
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||||
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||||
return (
|
return (
|
||||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||||
<div className="breaking-news-top">
|
<div className="breaking-news-top">
|
||||||
<span className="breaking-cat-tag" style={{ background: style.color }}>
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="breaking-news-time">
|
<span className="breaking-news-time">
|
||||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||||
@ -403,24 +433,26 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{koreanShips.length > 0 && (
|
{koreanShips.length > 0 && (
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇰🇷</span>
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="area-ship-title">한국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||||
<span className="area-ship-total" style={{ color: '#ef4444' }}>{koreanShips.length}척</span>
|
<span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="iran-mil-list">
|
<div className="iran-mil-list">
|
||||||
{koreanShips.slice(0, 30).map(s => {
|
{koreanShips.slice(0, 30).map(s => {
|
||||||
const mt = MT_CATEGORIES[getShipMTCategory(s.typecode, s.category)] || { label: '기타', color: '#888' };
|
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 (
|
return (
|
||||||
<div key={s.mmsi} className="iran-mil-item">
|
<div key={s.mmsi} className="iran-mil-item">
|
||||||
<span className="iran-mil-flag">🇰🇷</span>
|
<span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="iran-mil-name">{s.name}</span>
|
<span className="iran-mil-name">{s.name}</span>
|
||||||
<span className="iran-mil-cat" style={{ color: mt.color, background: `${mt.color}22` }}>
|
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
|
||||||
{mt.label}
|
{mtLabel}
|
||||||
</span>
|
</span>
|
||||||
{s.speed != null && s.speed > 0.5 ? (
|
{s.speed != null && s.speed > 0.5 ? (
|
||||||
<span style={{ fontSize: 9, color: '#22c55e', marginLeft: 'auto' }}>{s.speed.toFixed(1)}kn</span>
|
<span className="ml-auto text-[9px] text-kcg-success">{s.speed.toFixed(1)}kn</span>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 9, color: '#ef4444', marginLeft: 'auto' }}>정박</span>
|
<span className="ml-auto text-[9px] text-kcg-danger">{t('ships:status.anchored')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -434,12 +466,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">OSINT LIVE FEED</span>
|
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||||
<span className="osint-count">{osintFeed.length}</span>
|
<span className="osint-count">{osintFeed.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="osint-list">
|
<div className="osint-list">
|
||||||
{osintFeed.map(item => {
|
{osintFeed.map(item => {
|
||||||
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
|
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;
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -450,8 +483,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="osint-item-top">
|
<div className="osint-item-top">
|
||||||
<span className="osint-cat-tag" style={{ background: style.color }}>
|
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||||
</span>
|
</span>
|
||||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||||
@ -467,23 +500,23 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{isLive && osintFeed.length === 0 && (
|
{isLive && osintFeed.length === 0 && (
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">OSINT LIVE FEED</span>
|
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||||
<span className="osint-loading">Loading...</span>
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Event Log (replay mode) */}
|
{/* Event Log (replay mode) */}
|
||||||
{!isLive && (
|
{!isLive && (
|
||||||
<>
|
<>
|
||||||
<h3>Event Log</h3>
|
<h3>{t('events:log.title')}</h3>
|
||||||
<div className="event-list">
|
<div className="event-list">
|
||||||
{visibleEvents.length === 0 && (
|
{visibleEvents.length === 0 && (
|
||||||
<div className="event-empty">No events yet. Press play to start replay.</div>
|
<div className="event-empty">{t('events:log.noEvents')}</div>
|
||||||
)}
|
)}
|
||||||
{visibleEvents.map(e => {
|
{visibleEvents.map(e => {
|
||||||
const isNew = currentTime - e.timestamp < 86_400_000;
|
const isNew = currentTime - e.timestamp < 86_400_000;
|
||||||
return (
|
return (
|
||||||
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid #ff0000' } : undefined}>
|
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid var(--kcg-event-impact)' } : undefined}>
|
||||||
<span
|
<span
|
||||||
className="event-tag"
|
className="event-tag"
|
||||||
style={{ backgroundColor: TYPE_COLORS[e.type] }}
|
style={{ backgroundColor: TYPE_COLORS[e.type] }}
|
||||||
@ -493,10 +526,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
<div className="event-label">
|
<div className="event-label">
|
||||||
{isNew && (
|
{isNew && (
|
||||||
<span style={{
|
<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>
|
||||||
background: '#ff0000', color: '#fff', padding: '0 4px',
|
|
||||||
borderRadius: 2, fontSize: 9, marginRight: 4, fontWeight: 700,
|
|
||||||
}}>NEW</span>
|
|
||||||
)}
|
)}
|
||||||
{e.label}
|
{e.label}
|
||||||
</div>
|
</div>
|
||||||
@ -523,20 +553,21 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
{/* 한국 속보 (replay) */}
|
{/* 한국 속보 (replay) */}
|
||||||
{visibleNewsKR.length > 0 && (
|
{visibleNewsKR.length > 0 && (
|
||||||
<div className="breaking-news-section" style={{ borderLeftColor: '#3b82f6' }}>
|
<div className="breaking-news-section" style={{ borderLeftColor: 'var(--kcg-accent)' }}>
|
||||||
<div className="breaking-news-header">
|
<div className="breaking-news-header">
|
||||||
<span className="breaking-flash" style={{ background: '#3b82f6' }}>속보</span>
|
<span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
|
||||||
<span className="breaking-title">🇰🇷 한국 주요 뉴스</span>
|
<span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="breaking-news-list">
|
<div className="breaking-news-list">
|
||||||
{visibleNewsKR.map(n => {
|
{visibleNewsKR.map(n => {
|
||||||
const style = NEWS_CATEGORY_STYLE[n.category];
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
||||||
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
||||||
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
||||||
return (
|
return (
|
||||||
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
||||||
<div className="breaking-news-top">
|
<div className="breaking-news-top">
|
||||||
<span className="breaking-cat-tag" style={{ background: style.color }}>
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="breaking-news-time">
|
<span className="breaking-news-time">
|
||||||
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
||||||
@ -554,52 +585,41 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{/* 한국 선박 현황 — 선종별 분류 */}
|
{/* 한국 선박 현황 — 선종별 분류 */}
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇰🇷</span>
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||||
<span className="area-ship-title">한국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||||
<span className="area-ship-total">{koreanShips.length}척</span>
|
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
{koreanShips.length > 0 && (() => {
|
{koreanShips.length > 0 && (() => {
|
||||||
// 선종별 그룹핑
|
|
||||||
const groups: Record<string, Ship[]> = {};
|
const groups: Record<string, Ship[]> = {};
|
||||||
for (const s of koreanShips) {
|
for (const s of koreanShips) {
|
||||||
const cat = getShipMTCategory(s.typecode, s.category);
|
const cat = getShipMTCategory(s.typecode, s.category);
|
||||||
if (!groups[cat]) groups[cat] = [];
|
if (!groups[cat]) groups[cat] = [];
|
||||||
groups[cat].push(s);
|
groups[cat].push(s);
|
||||||
}
|
}
|
||||||
// 정렬 순서: 군함 → 유조선 → 화물선 → 여객선 → 어선 → 예인선 → 기타
|
|
||||||
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
|
||||||
const sorted = order.filter(k => groups[k]?.length);
|
const sorted = order.filter(k => groups[k]?.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{sorted.map(cat => {
|
{sorted.map(cat => {
|
||||||
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
|
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 list = groups[cat];
|
||||||
const moving = list.filter(s => s.speed > 0.5).length;
|
const moving = list.filter(s => s.speed > 0.5).length;
|
||||||
const anchored = list.length - moving;
|
const anchored = list.length - moving;
|
||||||
return (
|
return (
|
||||||
<div key={cat} style={{
|
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
background: `${mtColor}0a`,
|
||||||
padding: '4px 8px',
|
borderLeft: `3px solid ${mtColor}`,
|
||||||
background: `${mt.color}0a`,
|
|
||||||
borderLeft: `3px solid ${mt.color}`,
|
|
||||||
borderRadius: '0 4px 4px 0',
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||||
width: 8, height: 8, borderRadius: '50%',
|
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||||
background: mt.color, flexShrink: 0,
|
<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 style={{
|
</span>
|
||||||
fontSize: 11, fontWeight: 700, color: mt.color,
|
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||||
minWidth: 70, fontFamily: 'monospace',
|
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||||
}}>{mt.label}</span>
|
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||||
<span style={{
|
|
||||||
fontSize: 13, fontWeight: 700, color: '#fff',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}>척</span></span>
|
|
||||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
|
|
||||||
{moving > 0 && <span style={{ color: '#22c55e' }}>항해 {moving}</span>}
|
|
||||||
{anchored > 0 && <span style={{ color: '#ef4444' }}>정박 {anchored}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -612,9 +632,9 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{/* 중국 선박 현황 */}
|
{/* 중국 선박 현황 */}
|
||||||
<div className="iran-ship-summary">
|
<div className="iran-ship-summary">
|
||||||
<div className="area-ship-header">
|
<div className="area-ship-header">
|
||||||
<span className="area-ship-icon">🇨🇳</span>
|
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
|
||||||
<span className="area-ship-title">중국 선박 현황</span>
|
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
|
||||||
<span className="area-ship-total">{chineseShips.length}척</span>
|
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
|
||||||
</div>
|
</div>
|
||||||
{chineseShips.length > 0 && (() => {
|
{chineseShips.length > 0 && (() => {
|
||||||
const groups: Record<string, Ship[]> = {};
|
const groups: Record<string, Ship[]> = {};
|
||||||
@ -628,49 +648,34 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
const fishingCount = groups['fishing']?.length || 0;
|
const fishingCount = groups['fishing']?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
{fishingCount > 0 && (
|
{fishingCount > 0 && (
|
||||||
<div style={{
|
<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">
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
<span className="text-sm">{'\u{1F6A8}'}</span>
|
||||||
padding: '6px 8px', marginBottom: 2,
|
<span className="text-[11px] font-bold font-mono text-kcg-danger">
|
||||||
background: 'rgba(239,68,68,0.1)',
|
{t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
|
||||||
border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 14 }}>🚨</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', fontFamily: 'monospace' }}>
|
|
||||||
중국어선 {fishingCount}척 우리 해역 근접
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sorted.map(cat => {
|
{sorted.map(cat => {
|
||||||
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
|
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 list = groups[cat];
|
||||||
const moving = list.filter(s => s.speed > 0.5).length;
|
const moving = list.filter(s => s.speed > 0.5).length;
|
||||||
const anchored = list.length - moving;
|
const anchored = list.length - moving;
|
||||||
return (
|
return (
|
||||||
<div key={cat} style={{
|
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
background: `${mtColor}0a`,
|
||||||
padding: '4px 8px',
|
borderLeft: `3px solid ${mtColor}`,
|
||||||
background: `${mt.color}0a`,
|
|
||||||
borderLeft: `3px solid ${mt.color}`,
|
|
||||||
borderRadius: '0 4px 4px 0',
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||||
width: 8, height: 8, borderRadius: '50%',
|
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||||
background: mt.color, flexShrink: 0,
|
<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 style={{
|
</span>
|
||||||
fontSize: 11, fontWeight: 700, color: mt.color,
|
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||||
minWidth: 70, fontFamily: 'monospace',
|
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||||
}}>{mt.label}</span>
|
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||||
<span style={{
|
|
||||||
fontSize: 13, fontWeight: 700, color: '#fff',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}>척</span></span>
|
|
||||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
|
|
||||||
{moving > 0 && <span style={{ color: '#22c55e' }}>항해 {moving}</span>}
|
|
||||||
{anchored > 0 && <span style={{ color: '#ef4444' }}>정박 {anchored}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -685,7 +690,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
<>
|
<>
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">🇰🇷 OSINT LIVE</span>
|
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||||
<span className="osint-count">{(() => {
|
<span className="osint-count">{(() => {
|
||||||
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -708,7 +713,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
})().map(item => {
|
})().map(item => {
|
||||||
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
|
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;
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -719,8 +725,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<div className="osint-item-top">
|
<div className="osint-item-top">
|
||||||
<span className="osint-cat-tag" style={{ background: style.color }}>
|
<span className="osint-cat-tag" style={{ background: catColor }}>
|
||||||
{style.icon} {style.label}
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
||||||
</span>
|
</span>
|
||||||
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
||||||
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
||||||
@ -736,8 +742,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
|||||||
{osintFeed.length === 0 && (
|
{osintFeed.length === 0 && (
|
||||||
<div className="osint-header">
|
<div className="osint-header">
|
||||||
<span className="osint-live-dot" />
|
<span className="osint-live-dot" />
|
||||||
<span className="osint-title">🇰🇷 OSINT LIVE</span>
|
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||||
<span className="osint-loading">Loading...</span>
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent } from '../types';
|
import type { GeoEvent } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -21,21 +22,21 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
osint: '#06b6d4',
|
osint: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS_KO: Record<string, string> = {
|
const TYPE_KEYS: Record<string, string> = {
|
||||||
airstrike: '공습',
|
airstrike: 'event.airstrike',
|
||||||
explosion: '폭발',
|
explosion: 'event.explosion',
|
||||||
missile_launch: '미사일',
|
missile_launch: 'event.missileLaunch',
|
||||||
intercept: '요격',
|
intercept: 'event.intercept',
|
||||||
alert: '경보',
|
alert: 'event.alert',
|
||||||
impact: '피격',
|
impact: 'event.impact',
|
||||||
osint: 'OSINT',
|
osint: 'event.osint',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABELS_KO: Record<string, string> = {
|
const SOURCE_KEYS: Record<string, string> = {
|
||||||
US: '미국',
|
US: 'source.US',
|
||||||
IL: '이스라엘',
|
IL: 'source.IL',
|
||||||
IR: '이란',
|
IR: 'source.IR',
|
||||||
proxy: '대리세력',
|
proxy: 'source.proxy',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EventGroup {
|
interface EventGroup {
|
||||||
@ -44,10 +45,14 @@ interface EventGroup {
|
|||||||
events: GeoEvent[];
|
events: GeoEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토'];
|
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) {
|
export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
||||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
@ -63,12 +68,12 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
|||||||
const result: EventGroup[] = [];
|
const result: EventGroup[] = [];
|
||||||
for (const [dateKey, evs] of map) {
|
for (const [dateKey, evs] of map) {
|
||||||
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
const d = new Date(evs[0].timestamp + KST_OFFSET);
|
||||||
const dayName = DAY_NAMES[d.getUTCDay()];
|
const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]);
|
||||||
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`;
|
||||||
result.push({ dateKey, dateLabel, events: evs });
|
result.push({ dateKey, dateLabel, events: evs });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [events]);
|
}, [events, t]);
|
||||||
|
|
||||||
// Auto-open the first group if none selected
|
// Auto-open the first group if none selected
|
||||||
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
|
||||||
@ -108,8 +113,8 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
|
|||||||
{group.events.map(ev => {
|
{group.events.map(ev => {
|
||||||
const isPast = ev.timestamp <= currentTime;
|
const isPast = ev.timestamp <= currentTime;
|
||||||
const color = TYPE_COLORS[ev.type] || '#888';
|
const color = TYPE_COLORS[ev.type] || '#888';
|
||||||
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
|
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
|
||||||
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
|
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -98,7 +98,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
// 한글 국가명 라벨
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
|
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@ -141,7 +140,7 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update markers
|
// Update markers — DOM direct manipulation, inline styles intentionally kept
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -227,6 +226,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
|
|||||||
}, [events, currentTime, aircraft, satellites, ships, layers]);
|
}, [events, currentTime, aircraft, satellites, ships, layers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} className="w-full h-full" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { KOREAN_AIRPORTS } from '../services/airports';
|
import { KOREAN_AIRPORTS } from '../services/airports';
|
||||||
import type { KoreanAirport } from '../services/airports';
|
import type { KoreanAirport } from '../services/airports';
|
||||||
|
|
||||||
export function KoreaAirportLayer() {
|
export function KoreaAirportLayer() {
|
||||||
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
const [selected, setSelected] = useState<KoreanAirport | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -16,20 +18,18 @@ export function KoreaAirportLayer() {
|
|||||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
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">
|
<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" />
|
<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"
|
<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" />
|
fill={color} stroke="#fff" strokeWidth="0.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 6, color: '#fff', marginTop: 1,
|
fontSize: 6,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px whitespace-nowrap font-bold tracking-wide text-white">
|
||||||
}}>
|
|
||||||
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,35 +41,28 @@ export function KoreaAirportLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="260px" className="gl-popup">
|
anchor="bottom" maxWidth="260px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
|
<div className="min-w-[180px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: selected.intl ? '#a78bfa' : '#7c8aaa', color: '#000',
|
background: selected.intl ? '#a78bfa' : '#7c8aaa',
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} 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">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.nameKo}
|
{selected.nameKo}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
{selected.intl && (
|
{selected.intl && (
|
||||||
<span style={{
|
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
background: '#a78bfa', color: '#000',
|
{t('airport.international')}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>국제선</span>
|
|
||||||
)}
|
)}
|
||||||
{selected.domestic && (
|
{selected.domestic && (
|
||||||
<span style={{
|
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
background: '#7c8aaa', color: '#000',
|
{t('airport.domestic')}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>국내선</span>
|
|
||||||
)}
|
)}
|
||||||
<span style={{
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
background: '#1a1a2e', color: '#888',
|
{selected.id} / {selected.icao}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
</span>
|
||||||
}}>{selected.id} / {selected.icao}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666' }}>
|
<div className="text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@ -23,21 +24,22 @@ export function LiveControls({
|
|||||||
historyMinutes,
|
historyMinutes,
|
||||||
onHistoryChange,
|
onHistoryChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="live-controls">
|
<div className="live-controls">
|
||||||
<div className="live-indicator">
|
<div className="live-indicator">
|
||||||
<span className="live-dot" />
|
<span className="live-dot" />
|
||||||
<span className="live-label">LIVE</span>
|
<span className="live-label">{t('header.live')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="live-clock">{kstTime}</div>
|
<div className="live-clock">{kstTime}</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="history-controls">
|
<div className="history-controls">
|
||||||
<span className="history-label">HISTORY</span>
|
<span className="history-label">{t('time.history')}</span>
|
||||||
<div className="history-presets">
|
<div className="history-presets">
|
||||||
{HISTORY_PRESETS.map(p => (
|
{HISTORY_PRESETS.map(p => (
|
||||||
<button
|
<button
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
|
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
|
||||||
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
|
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
|
||||||
@ -31,7 +32,6 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// caution (해경 등)
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<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" />
|
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||||
@ -43,6 +43,7 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
|
|||||||
|
|
||||||
export function NavWarningLayer() {
|
export function NavWarningLayer() {
|
||||||
const [selected, setSelected] = useState<NavWarning | null>(null);
|
const [selected, setSelected] = useState<NavWarning | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -53,15 +54,14 @@ export function NavWarningLayer() {
|
|||||||
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 4px ${color}88)`,
|
filter: `drop-shadow(0 0 4px ${color}88)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<WarningIcon level={w.level} org={w.org} size={size} />
|
<WarningIcon level={w.level} org={w.org} size={size} />
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color, marginTop: 0,
|
fontSize: 5, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="whitespace-nowrap font-bold tracking-wide">
|
||||||
}}>
|
|
||||||
{w.id}
|
{w.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,48 +73,43 @@ export function NavWarningLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
|
<div className="min-w-[240px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: ORG_COLOR[selected.org], color: '#fff',
|
background: ORG_COLOR[selected.org],
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} 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">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 12,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
{selected.title}
|
{selected.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: LEVEL_COLOR[selected.level], color: '#fff',
|
background: LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||||
}}>{NW_LEVEL_LABEL[selected.level]}</span>
|
{NW_LEVEL_LABEL[selected.level]}
|
||||||
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org],
|
background: ORG_COLOR[selected.org] + '33',
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
color: ORG_COLOR[selected.org],
|
||||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||||
}}>{NW_ORG_LABEL[selected.org]}</span>
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
|
||||||
<span style={{
|
{NW_ORG_LABEL[selected.org]}
|
||||||
background: '#1a1a2e', color: '#888',
|
</span>
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
}}>{selected.area}</span>
|
{selected.area}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
|
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||||
{selected.description}
|
{selected.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
|
||||||
<div>사용고도: {selected.altitude}</div>
|
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||||
<div>출처: {selected.source}</div>
|
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
className="mt-1.5 block text-[10px] text-kcg-accent underline"
|
||||||
display: 'block', marginTop: 6,
|
>{t('navWarning.khoaLink')}</a>
|
||||||
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>KHOA 항행경보 상황판 바로가기</a>
|
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
)}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { OilFacility, OilFacilityType } from '../types';
|
import type { OilFacility, OilFacilityType } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -12,11 +13,6 @@ const TYPE_COLORS: Record<OilFacilityType, string> = {
|
|||||||
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS: Record<OilFacilityType, string> = {
|
|
||||||
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
|
|
||||||
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumber(n: number): string {
|
function formatNumber(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
@ -54,11 +50,9 @@ function DamageOverlay() {
|
|||||||
|
|
||||||
// SVG icon renderers (JSX versions)
|
// SVG icon renderers (JSX versions)
|
||||||
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Factory/refinery silhouette on gradient circle background (no white)
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Gradient circle background */}
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
|
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
|
||||||
@ -67,23 +61,16 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
</defs>
|
</defs>
|
||||||
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
|
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
|
||||||
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
|
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
|
||||||
{/* Factory building base */}
|
|
||||||
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
|
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
|
||||||
{/* Tall chimney/tower (center) */}
|
|
||||||
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
|
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
|
||||||
{/* Short tower (left) */}
|
|
||||||
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
|
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
|
||||||
{/* Medium tower (right) */}
|
|
||||||
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
|
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
|
||||||
{/* Smoke/emission from chimneys */}
|
|
||||||
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
|
<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={18} cy={5} r={2} fill={color} opacity={0.3} />
|
||||||
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
|
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
|
||||||
{/* Windows on building */}
|
|
||||||
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
<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={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)" />
|
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
|
||||||
{/* Pipe details */}
|
|
||||||
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
<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} />
|
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -92,34 +79,21 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Oil pumpjack (nodding donkey) icon — transparent style
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Base platform */}
|
|
||||||
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
|
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
|
||||||
{/* Support A-frame (tripod legs) */}
|
|
||||||
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
<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={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||||
{/* Cross brace on A-frame */}
|
|
||||||
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
|
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
|
||||||
{/* Walking beam (horizontal arm) */}
|
|
||||||
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
|
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
|
||||||
{/* Pivot point on top of A-frame */}
|
|
||||||
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
|
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
|
||||||
{/* Horse head (front end, left side) */}
|
|
||||||
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
|
||||||
{/* Polished rod (well string going down) */}
|
|
||||||
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||||
{/* Counterweight (back end, right side) */}
|
|
||||||
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
|
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
|
||||||
{/* Crank arm + pitman arm */}
|
|
||||||
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
|
||||||
{/* Motor/gear box */}
|
|
||||||
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
|
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
|
||||||
{/* Wellhead at bottom */}
|
|
||||||
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
|
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
|
||||||
{/* Oil drop symbol on wellhead */}
|
|
||||||
<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"
|
<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} />
|
fill={color} opacity={0.85} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -128,27 +102,19 @@ function OilFieldIcon({ size, color, damaged }: { size: number; color: string; d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Spherical gas storage tank with support legs (transparent style)
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Support legs */}
|
|
||||||
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
<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={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={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={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
{/* Cross braces */}
|
|
||||||
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
<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={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||||
{/* Base platform */}
|
|
||||||
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
|
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
|
||||||
{/* Spherical tank body */}
|
|
||||||
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
|
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
|
||||||
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
|
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
|
||||||
{/* Highlight arc (top reflection) */}
|
|
||||||
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
|
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
|
||||||
{/* Equator band */}
|
|
||||||
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
|
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
|
||||||
{/* Top valve/pipe */}
|
|
||||||
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
|
<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} />
|
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
@ -188,35 +154,25 @@ function PetrochemIcon({ size, color, damaged }: { size: number; color: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
|
||||||
// Water drop + faucet + filter container — desalination plant (transparent)
|
|
||||||
const sc = damaged ? '#ff0000' : color;
|
const sc = damaged ? '#ff0000' : color;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 36 36" width={size} height={size}>
|
<svg viewBox="0 0 36 36" width={size} height={size}>
|
||||||
{/* Large water drop (left side) */}
|
|
||||||
<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"
|
<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} />
|
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
|
||||||
{/* Inner drop ripple */}
|
|
||||||
<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"
|
<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} />
|
fill={color} opacity={0.3} />
|
||||||
{/* Faucet/tap (top right) */}
|
|
||||||
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
|
<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} />
|
<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"
|
<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} />
|
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
|
||||||
{/* Water drops from faucet */}
|
|
||||||
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
|
<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} />
|
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
|
||||||
{/* Filter/treatment container (bottom right) */}
|
|
||||||
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
|
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
|
||||||
stroke={sc} strokeWidth={1} />
|
stroke={sc} strokeWidth={1} />
|
||||||
{/* Filter layers inside container */}
|
|
||||||
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
<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} />
|
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
|
||||||
{/* Pipe connecting drop to container */}
|
|
||||||
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
|
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
|
||||||
{/* Output pipe from container */}
|
|
||||||
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
|
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
|
||||||
{/* Base */}
|
|
||||||
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
|
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
|
||||||
{damaged && <DamageOverlay />}
|
{damaged && <DamageOverlay />}
|
||||||
</svg>
|
</svg>
|
||||||
@ -247,6 +203,7 @@ export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, cur
|
|||||||
});
|
});
|
||||||
|
|
||||||
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
|
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
const color = TYPE_COLORS[facility.type];
|
const color = TYPE_COLORS[facility.type];
|
||||||
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
|
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
|
||||||
@ -256,33 +213,32 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
|
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
{/* Planned strike targeting ring */}
|
{/* Planned strike targeting ring */}
|
||||||
{isPlanned && (
|
{isPlanned && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
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"
|
||||||
transform: 'translate(-50%, -50%)',
|
style={{
|
||||||
width: 36, height: 36, borderRadius: '50%',
|
border: '2px dashed #ff6600',
|
||||||
border: '2px dashed #ff6600',
|
animation: 'planned-pulse 2s ease-in-out infinite',
|
||||||
animation: 'planned-pulse 2s ease-in-out infinite',
|
}}
|
||||||
pointerEvents: 'none',
|
>
|
||||||
}}>
|
|
||||||
{/* Crosshair lines */}
|
{/* Crosshair lines */}
|
||||||
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||||
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
|
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ cursor: 'pointer' }}
|
<div className="cursor-pointer"
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
||||||
<FacilityIconSvg facility={facility} damaged={isDamaged} />
|
<FacilityIconSvg facility={facility} damaged={isDamaged} />
|
||||||
</div>
|
</div>
|
||||||
<div className="gl-marker-label" style={{
|
<div className="gl-marker-label text-[8px]" style={{
|
||||||
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8,
|
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
|
||||||
}}>
|
}}>
|
||||||
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
|
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
|
||||||
{stat && <span style={{ color: '#aaa', fontSize: 7, marginLeft: 3 }}>{stat}</span>}
|
{stat && <span className="text-[#aaa] text-[7px] ml-0.5">{stat}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
@ -290,69 +246,60 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
|
|||||||
<Popup longitude={facility.lng} latitude={facility.lat}
|
<Popup longitude={facility.lng} latitude={facility.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||||
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[220px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}>
|
<div className="flex gap-1 items-center mb-1.5">
|
||||||
<span style={{
|
<span
|
||||||
background: color, color: '#fff', padding: '2px 6px',
|
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
style={{ background: color }}
|
||||||
}}>{TYPE_LABELS[facility.type]}</span>
|
>{t(`facility.type.${facility.type}`)}</span>
|
||||||
{isDamaged && (
|
{isDamaged && (
|
||||||
<span style={{
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
|
||||||
background: '#ff0000', color: '#fff', padding: '2px 6px',
|
{t('facility.damaged')}
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>피격</span>
|
|
||||||
)}
|
)}
|
||||||
{isPlanned && (
|
{isPlanned && (
|
||||||
<span style={{
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
|
||||||
background: '#ff6600', color: '#fff', padding: '2px 6px',
|
{t('facility.plannedStrike')}
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
</span>
|
||||||
}}>공격 예정</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div>
|
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
|
||||||
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div>
|
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
|
||||||
<div style={{
|
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||||
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
|
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
|
|
||||||
}}>
|
|
||||||
{facility.capacityBpd != null && (
|
{facility.capacityBpd != null && (
|
||||||
<><span style={{ color: '#888' }}>생산/처리</span>
|
<><span className="text-kcg-muted">{t('facility.production')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
|
||||||
)}
|
)}
|
||||||
{facility.capacityMgd != null && (
|
{facility.capacityMgd != null && (
|
||||||
<><span style={{ color: '#888' }}>담수생산</span>
|
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
|
||||||
)}
|
)}
|
||||||
{facility.capacityMcfd != null && (
|
{facility.capacityMcfd != null && (
|
||||||
<><span style={{ color: '#888' }}>가스생산</span>
|
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
|
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
|
||||||
)}
|
)}
|
||||||
{facility.reservesBbl != null && (
|
{facility.reservesBbl != null && (
|
||||||
<><span style={{ color: '#888' }}>매장량(유)</span>
|
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B 배럴</span></>
|
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
|
||||||
)}
|
)}
|
||||||
{facility.reservesTcf != null && (
|
{facility.reservesTcf != null && (
|
||||||
<><span style={{ color: '#888' }}>매장량(가스)</span>
|
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
|
||||||
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></>
|
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
|
||||||
)}
|
)}
|
||||||
{facility.operator && (
|
{facility.operator && (
|
||||||
<><span style={{ color: '#888' }}>운영사</span>
|
<><span className="text-kcg-muted">{t('facility.operator')}</span>
|
||||||
<span style={{ color: '#fff' }}>{facility.operator}</span></>
|
<span className="text-white">{facility.operator}</span></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{facility.description && (
|
{facility.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>{facility.description}</p>
|
<p className="mt-1.5 mb-0 text-[11px] text-kcg-text-secondary leading-snug">{facility.description}</p>
|
||||||
)}
|
)}
|
||||||
{isPlanned && facility.plannedLabel && (
|
{isPlanned && facility.plannedLabel && (
|
||||||
<div style={{
|
<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]">
|
||||||
margin: '6px 0 0', padding: '4px 8px', fontSize: 11,
|
|
||||||
background: 'rgba(255,102,0,0.15)', border: '1px solid rgba(255,102,0,0.4)',
|
|
||||||
borderRadius: 4, color: '#ff9933', lineHeight: 1.4,
|
|
||||||
}}>
|
|
||||||
{facility.plannedLabel}
|
{facility.plannedLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||||
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
|
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import type { OsintItem } from '../services/osint';
|
import type { OsintItem } from '../services/osint';
|
||||||
|
|
||||||
@ -18,13 +19,16 @@ const CAT_ICON: Record<string, string> = {
|
|||||||
shipping: '🚢',
|
shipping: '🚢',
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function useTimeAgo() {
|
||||||
const diff = Date.now() - ts;
|
const { t } = useTranslation();
|
||||||
const m = Math.floor(diff / 60000);
|
return (ts: number): string => {
|
||||||
if (m < 60) return `${m}분 전`;
|
const diff = Date.now() - ts;
|
||||||
const h = Math.floor(m / 60);
|
const m = Math.floor(diff / 60000);
|
||||||
if (h < 24) return `${h}시간 전`;
|
if (m < 60) return t('time.minutesAgo', { count: m });
|
||||||
return `${Math.floor(h / 24)}일 전`;
|
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 {
|
interface Props {
|
||||||
@ -38,8 +42,9 @@ const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffi
|
|||||||
|
|
||||||
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
||||||
const [selected, setSelected] = useState<OsintItem | null>(null);
|
const [selected, setSelected] = useState<OsintItem | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const timeAgo = useTimeAgo();
|
||||||
|
|
||||||
// 좌표가 있고, 해양 관련 카테고리이며, 최근 3시간 이내인 OSINT만 표시
|
|
||||||
const geoItems = useMemo(() => osintFeed.filter(
|
const geoItems = useMemo(() => osintFeed.filter(
|
||||||
(item): item is OsintItem & { lat: number; lng: number } =>
|
(item): item is OsintItem & { lat: number; lng: number } =>
|
||||||
item.lat != null && item.lng != null
|
item.lat != null && item.lng != null
|
||||||
@ -51,30 +56,25 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
|||||||
<>
|
<>
|
||||||
{geoItems.map(item => {
|
{geoItems.map(item => {
|
||||||
const color = CAT_COLOR[item.category] || '#888';
|
const color = CAT_COLOR[item.category] || '#888';
|
||||||
const isRecent = currentTime - item.timestamp < ONE_HOUR; // 1시간 이내
|
const isRecent = currentTime - item.timestamp < ONE_HOUR;
|
||||||
return (
|
return (
|
||||||
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
|
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 6px ${color}aa)`,
|
filter: `drop-shadow(0 0 6px ${color}aa)`,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 22, height: 22, borderRadius: '50%',
|
|
||||||
background: `rgba(0,0,0,0.6)`,
|
|
||||||
border: `2px solid ${color}`,
|
border: `2px solid ${color}`,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 12,
|
|
||||||
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
|
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] || '📰'}
|
{CAT_ICON[item.category] || '📰'}
|
||||||
</div>
|
</div>
|
||||||
{isRecent && (
|
{isRecent && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 5, color, marginTop: 1,
|
fontSize: 5, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px font-bold tracking-wide">
|
||||||
}}>
|
|
||||||
NEW
|
NEW
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -87,41 +87,36 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
|
<div className="min-w-[240px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
|
background: CAT_COLOR[selected.category] || '#888',
|
||||||
padding: '4px 8px', borderRadius: '4px 4px 0 0',
|
}} 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">
|
||||||
margin: '-10px -10px 8px -10px',
|
|
||||||
fontWeight: 700, fontSize: 11,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span>{CAT_ICON[selected.category] || '📰'}</span>
|
<span>{CAT_ICON[selected.category] || '📰'}</span>
|
||||||
OSINT
|
OSINT
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
|
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||||
{selected.title}
|
{selected.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
|
background: CAT_COLOR[selected.category] || '#888',
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
|
||||||
}}>{selected.category.replace('_', ' ').toUpperCase()}</span>
|
{selected.category.replace('_', ' ').toUpperCase()}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#888',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9,
|
{selected.source}
|
||||||
}}>{selected.source}</span>
|
</span>
|
||||||
<span style={{
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
|
||||||
background: '#1a1a2e', color: '#666',
|
{timeAgo(selected.timestamp)}
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 9,
|
</span>
|
||||||
}}>{timeAgo(selected.timestamp)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{selected.url && (
|
{selected.url && (
|
||||||
<a
|
<a
|
||||||
href={selected.url}
|
href={selected.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
|
className="text-[10px] text-kcg-accent underline"
|
||||||
>기사 원문 보기</a>
|
>{t('osintMap.viewOriginal')}</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||||
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
|
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
|
||||||
import type { PiracyZone } from '../services/piracy';
|
import type { PiracyZone } from '../services/piracy';
|
||||||
@ -6,16 +7,11 @@ import type { PiracyZone } from '../services/piracy';
|
|||||||
function SkullIcon({ color, size }: { color: string; size: number }) {
|
function SkullIcon({ color, size }: { color: string; size: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
{/* skull */}
|
|
||||||
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
|
||||||
{/* eyes */}
|
|
||||||
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
<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" />
|
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
|
||||||
{/* nose */}
|
|
||||||
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
|
||||||
{/* jaw */}
|
|
||||||
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
|
||||||
{/* crossbones */}
|
|
||||||
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
<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" />
|
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -24,6 +20,7 @@ function SkullIcon({ color, size }: { color: string; size: number }) {
|
|||||||
|
|
||||||
export function PiracyLayer() {
|
export function PiracyLayer() {
|
||||||
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
const [selected, setSelected] = useState<PiracyZone | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -34,17 +31,15 @@ export function PiracyLayer() {
|
|||||||
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
|
||||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
cursor: 'pointer',
|
||||||
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
filter: `drop-shadow(0 0 8px ${color}aa)`,
|
||||||
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
|
||||||
}}>
|
}} className="flex flex-col items-center">
|
||||||
<SkullIcon color={color} size={size} />
|
<SkullIcon color={color} size={size} />
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 7, color, marginTop: 1,
|
fontSize: 7, color,
|
||||||
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
|
||||||
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
|
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}>
|
|
||||||
{PIRACY_LEVEL_LABEL[zone.level]}
|
{PIRACY_LEVEL_LABEL[zone.level]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -56,43 +51,40 @@ export function PiracyLayer() {
|
|||||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||||
onClose={() => setSelected(null)} closeOnClick={false}
|
onClose={() => setSelected(null)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 260 }}>
|
<div className="min-w-[260px] font-mono text-xs">
|
||||||
<div style={{
|
<div style={{
|
||||||
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
|
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '5px 10px', borderRadius: '4px 4px 0 0',
|
}} 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">
|
||||||
margin: '-10px -10px 8px -10px',
|
<span className="text-sm">☠️</span>
|
||||||
fontWeight: 700, fontSize: 12,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 14 }}>☠️</span>
|
|
||||||
{selected.nameKo}
|
{selected.nameKo}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
|
background: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||||
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span>
|
{PIRACY_LEVEL_LABEL[selected.level]}
|
||||||
<span style={{
|
</span>
|
||||||
background: '#1a1a2e', color: '#888',
|
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10,
|
{selected.name}
|
||||||
}}>{selected.name}</span>
|
</span>
|
||||||
{selected.recentIncidents != null && (
|
{selected.recentIncidents != null && (
|
||||||
<span style={{
|
<span style={{
|
||||||
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level],
|
color: PIRACY_LEVEL_COLOR[selected.level],
|
||||||
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
||||||
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
|
||||||
}}>최근 1년 {selected.recentIncidents}건</span>
|
}} className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold">
|
||||||
|
{t('piracy.recentIncidents', { count: selected.recentIncidents })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.5 }}>
|
<div className="mb-1.5 text-[11px] leading-relaxed text-kcg-text-secondary">
|
||||||
{selected.description}
|
{selected.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}>
|
<div className="text-[10px] leading-snug text-[#999]">
|
||||||
{selected.detail}
|
{selected.detail}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 9, color: '#666', marginTop: 6 }}>
|
<div className="mt-1.5 text-[9px] text-kcg-dim">
|
||||||
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@ -51,6 +52,7 @@ export function ReplayControls({
|
|||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
|
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
|
||||||
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
|
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
|
||||||
@ -76,7 +78,7 @@ export function ReplayControls({
|
|||||||
return (
|
return (
|
||||||
<div className="replay-controls">
|
<div className="replay-controls">
|
||||||
{/* Left: transport controls */}
|
{/* Left: transport controls */}
|
||||||
<button className="ctrl-btn" onClick={onReset} title="Reset">
|
<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">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M1 4v6h6" />
|
<path d="M1 4v6h6" />
|
||||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||||
@ -109,7 +111,7 @@ export function ReplayControls({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Right: range presets + custom picker */}
|
{/* Right: range presets + custom picker */}
|
||||||
<div className="range-controls">
|
<div className="range-controls">
|
||||||
@ -126,7 +128,7 @@ export function ReplayControls({
|
|||||||
<button
|
<button
|
||||||
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
|
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
|
||||||
onClick={() => setShowPicker(!showPicker)}
|
onClick={() => setShowPicker(!showPicker)}
|
||||||
title="Custom range"
|
title={t('controls.customRange')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
@ -141,7 +143,7 @@ export function ReplayControls({
|
|||||||
<div className="range-picker">
|
<div className="range-picker">
|
||||||
<div className="range-picker-row">
|
<div className="range-picker-row">
|
||||||
<label>
|
<label>
|
||||||
<span>FROM (KST)</span>
|
<span>{t('controls.from')}</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={customStart}
|
value={customStart}
|
||||||
@ -149,7 +151,7 @@ export function ReplayControls({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>TO (KST)</span>
|
<span>{t('controls.to')}</span>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={customEnd}
|
value={customEnd}
|
||||||
@ -157,7 +159,7 @@ export function ReplayControls({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="range-apply-btn" onClick={handleCustomApply}>
|
<button className="range-apply-btn" onClick={handleCustomApply}>
|
||||||
APPLY
|
{t('controls.apply')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
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 { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { AircraftLayer } from './AircraftLayer';
|
import { AircraftLayer } from './AircraftLayer';
|
||||||
@ -105,6 +106,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
|
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -182,7 +184,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
>
|
>
|
||||||
<NavigationControl position="top-right" />
|
<NavigationControl position="top-right" />
|
||||||
|
|
||||||
{/* 한글 국가명 라벨 */}
|
|
||||||
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
|
||||||
<Layer
|
<Layer
|
||||||
id="country-label-lg"
|
id="country-label-lg"
|
||||||
@ -266,9 +267,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = EVENT_RADIUS[event.type] * 5;
|
const size = EVENT_RADIUS[event.type] * 5;
|
||||||
return (
|
return (
|
||||||
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-pulse-ring" style={{
|
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
border: `2px solid ${color}`, pointerEvents: 'none',
|
border: `2px solid ${color}`,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -279,9 +280,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = event.type === 'impact' ? 100 : 70;
|
const size = event.type === 'impact' ? 100 : 70;
|
||||||
return (
|
return (
|
||||||
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-shockwave" style={{
|
<div className="gl-shockwave rounded-full pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
border: `3px solid ${color}`, pointerEvents: 'none',
|
border: `3px solid ${color}`,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -292,9 +293,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const size = event.type === 'impact' ? 40 : 30;
|
const size = event.type === 'impact' ? 40 : 30;
|
||||||
return (
|
return (
|
||||||
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div className="gl-strike-flash" style={{
|
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size,
|
||||||
background: color, opacity: 0.6, pointerEvents: 'none',
|
background: color,
|
||||||
}} />
|
}} />
|
||||||
</Marker>
|
</Marker>
|
||||||
);
|
);
|
||||||
@ -304,11 +305,10 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const ageMs = currentTime - event.timestamp;
|
const ageMs = currentTime - event.timestamp;
|
||||||
const ageHours = ageMs / 3600_000;
|
const ageHours = ageMs / 3600_000;
|
||||||
const DAY_H = 24;
|
const DAY_H = 24;
|
||||||
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
|
|
||||||
const isRecent = ageHours <= DAY_H;
|
const isRecent = ageHours <= DAY_H;
|
||||||
const opacity = isRecent
|
const opacity = isRecent
|
||||||
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85
|
? Math.max(0.85, 1 - ageHours * 0.006)
|
||||||
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15
|
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
|
||||||
const color = getEventColor(event);
|
const color = getEventColor(event);
|
||||||
const isNew = ageMs >= 0 && ageMs < 600_000;
|
const isNew = ageMs >= 0 && ageMs < 600_000;
|
||||||
const baseR = EVENT_RADIUS[event.type];
|
const baseR = EVENT_RADIUS[event.type];
|
||||||
@ -318,8 +318,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
return (
|
return (
|
||||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div
|
<div
|
||||||
className={isNew ? 'gl-event-flash' : undefined}
|
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
|
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
|
||||||
>
|
>
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
@ -339,7 +338,6 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const ageMs = currentTime - event.timestamp;
|
const ageMs = currentTime - event.timestamp;
|
||||||
const ageHours = ageMs / 3600_000;
|
const ageHours = ageMs / 3600_000;
|
||||||
const isRecent = ageHours <= 24;
|
const isRecent = ageHours <= 24;
|
||||||
// 최근 1일: 진하게, 이후: 흐리게
|
|
||||||
const impactOpacity = isRecent
|
const impactOpacity = isRecent
|
||||||
? Math.max(0.8, 1 - ageHours * 0.008)
|
? Math.max(0.8, 1 - ageHours * 0.008)
|
||||||
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
|
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
|
||||||
@ -348,7 +346,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
const sw = isRecent ? 1.5 : 1;
|
const sw = isRecent ? 1.5 : 1;
|
||||||
return (
|
return (
|
||||||
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
|
||||||
<div style={{ position: 'relative', cursor: 'pointer', opacity: impactOpacity }}
|
<div className="relative cursor-pointer" style={{ opacity: impactOpacity }}
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
|
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
|
||||||
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
|
<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.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
|
||||||
@ -378,47 +376,43 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
|||||||
maxWidth="320px"
|
maxWidth="320px"
|
||||||
className="gl-popup"
|
className="gl-popup"
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 200, maxWidth: 320 }}>
|
<div className="min-w-[200px] max-w-[320px]">
|
||||||
{selectedEvent.source && (
|
{selectedEvent.source && (
|
||||||
<span style={{
|
<span
|
||||||
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
|
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
|
style={{ background: getEventColor(selectedEvent) }}
|
||||||
}}>
|
>
|
||||||
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
|
{t(`source.${selectedEvent.source}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{
|
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||||
background: '#ff0000', color: '#fff', padding: '3px 8px',
|
{t('popup.impactSite')}
|
||||||
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
|
|
||||||
display: 'inline-block',
|
|
||||||
}}>
|
|
||||||
IMPACT SITE
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div><strong>{selectedEvent.label}</strong></div>
|
<div><strong>{selectedEvent.label}</strong></div>
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>
|
<span className="text-xs text-kcg-muted">
|
||||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||||
</span>
|
</span>
|
||||||
{selectedEvent.description && (
|
{selectedEvent.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 13 }}>{selectedEvent.description}</p>
|
<p className="mt-1.5 mb-0 text-[13px]">{selectedEvent.description}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.imageUrl && (
|
{selectedEvent.imageUrl && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={selectedEvent.imageUrl}
|
src={selectedEvent.imageUrl}
|
||||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||||
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
|
className="w-full rounded max-h-[180px] object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
{selectedEvent.imageCaption && (
|
{selectedEvent.imageCaption && (
|
||||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
|
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-muted mt-1.5">
|
||||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import type { SatellitePosition } from '../types';
|
import type { SatellitePosition } from '../types';
|
||||||
|
|
||||||
@ -72,13 +73,11 @@ const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SatelliteLayer({ satellites }: Props) {
|
export function SatelliteLayer({ satellites }: Props) {
|
||||||
// Ground tracks as GeoJSON
|
|
||||||
const trackData = useMemo(() => {
|
const trackData = useMemo(() => {
|
||||||
const features: GeoJSON.Feature[] = [];
|
const features: GeoJSON.Feature[] = [];
|
||||||
for (const sat of satellites) {
|
for (const sat of satellites) {
|
||||||
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
|
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
|
||||||
const color = CAT_COLORS[sat.category];
|
const color = CAT_COLORS[sat.category];
|
||||||
// Break at antimeridian crossings
|
|
||||||
let segment: [number, number][] = [];
|
let segment: [number, number][] = [];
|
||||||
for (let i = 0; i < sat.groundTrack.length; i++) {
|
for (let i = 0; i < sat.groundTrack.length; i++) {
|
||||||
const [lat, lng] = sat.groundTrack[i];
|
const [lat, lng] = sat.groundTrack[i];
|
||||||
@ -133,6 +132,7 @@ export function SatelliteLayer({ satellites }: Props) {
|
|||||||
|
|
||||||
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
|
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
const color = CAT_COLORS[sat.category];
|
const color = CAT_COLORS[sat.category];
|
||||||
const svgBody = SVG_MAP[sat.category];
|
const svgBody = SVG_MAP[sat.category];
|
||||||
const size = 22;
|
const size = 22;
|
||||||
@ -140,9 +140,9 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
|
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
style={{ width: size, height: size, color, cursor: 'pointer' }}
|
style={{ color }} className="size-[22px] cursor-pointer"
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
|
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
|
||||||
@ -158,20 +158,21 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
|
|||||||
<Popup longitude={sat.lng} latitude={sat.lat}
|
<Popup longitude={sat.lng} latitude={sat.lat}
|
||||||
onClose={() => setShowPopup(false)} closeOnClick={false}
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="200px" className="gl-popup">
|
anchor="bottom" maxWidth="200px" className="gl-popup">
|
||||||
<div style={{ minWidth: 180, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[180px] font-mono text-xs">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
<span style={{
|
<span style={{
|
||||||
background: color, color: '#000', padding: '1px 6px',
|
background: color,
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||||
}}>{CAT_LABELS[sat.category]}</span>
|
{CAT_LABELS[sat.category]}
|
||||||
|
</span>
|
||||||
<strong>{sat.name}</strong>
|
<strong>{sat.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: '100%', fontSize: 11 }}>
|
<table className="w-full text-[11px]">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td style={{ color: '#888' }}>NORAD</td><td>{sat.noradId}</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.norad')}</td><td>{sat.noradId}</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Lat</td><td>{sat.lat.toFixed(2)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.lat')}</td><td>{sat.lat.toFixed(2)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Lng</td><td>{sat.lng.toFixed(2)}°</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.lng')}</td><td>{sat.lng.toFixed(2)}°</td></tr>
|
||||||
<tr><td style={{ color: '#888' }}>Alt</td><td>{Math.round(sat.altitude)} km</td></tr>
|
<tr><td className="text-kcg-muted">{t('satellite.alt')}</td><td>{Math.round(sat.altitude)} km</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState, useRef } from 'react';
|
import { useMemo, useState, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||||
import type { MapRef } from 'react-map-gl/maplibre';
|
import type { MapRef } from 'react-map-gl/maplibre';
|
||||||
import { AircraftLayer } from './AircraftLayer';
|
import { AircraftLayer } from './AircraftLayer';
|
||||||
@ -86,6 +87,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -179,14 +181,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
|||||||
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
className="rounded-full cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: EVENT_RADIUS[ev.type],
|
width: EVENT_RADIUS[ev.type],
|
||||||
height: EVENT_RADIUS[ev.type],
|
height: EVENT_RADIUS[ev.type],
|
||||||
borderRadius: '50%',
|
|
||||||
background: getEventColor(ev),
|
background: getEventColor(ev),
|
||||||
border: '2px solid rgba(255,255,255,0.8)',
|
border: '2px solid rgba(255,255,255,0.8)',
|
||||||
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
boxShadow: `0 0 8px ${getEventColor(ev)}`,
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Marker>
|
</Marker>
|
||||||
@ -203,46 +204,42 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
|||||||
maxWidth="320px"
|
maxWidth="320px"
|
||||||
className="event-popup"
|
className="event-popup"
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 200, maxWidth: 320 }}>
|
<div className="min-w-[200px] max-w-[320px]">
|
||||||
{selectedEvent.source && (
|
{selectedEvent.source && (
|
||||||
<span style={{
|
<span
|
||||||
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
|
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
|
style={{ background: getEventColor(selectedEvent) }}
|
||||||
}}>
|
>
|
||||||
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
|
{t(`source.${selectedEvent.source}`)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.type === 'impact' && (
|
{selectedEvent.type === 'impact' && (
|
||||||
<div style={{
|
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
|
||||||
background: '#ff0000', color: '#fff', padding: '3px 8px',
|
{t('popup.impactSite')}
|
||||||
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
|
|
||||||
display: 'inline-block',
|
|
||||||
}}>
|
|
||||||
IMPACT SITE
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ color: '#e0e0e0' }}><strong>{selectedEvent.label}</strong></div>
|
<div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>
|
<span className="text-xs text-kcg-muted">
|
||||||
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
|
||||||
</span>
|
</span>
|
||||||
{selectedEvent.description && (
|
{selectedEvent.description && (
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#ccc' }}>{selectedEvent.description}</p>
|
<p className="mt-1.5 mb-0 text-[13px] text-kcg-text-secondary">{selectedEvent.description}</p>
|
||||||
)}
|
)}
|
||||||
{selectedEvent.imageUrl && (
|
{selectedEvent.imageUrl && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div className="mt-2">
|
||||||
<img
|
<img
|
||||||
src={selectedEvent.imageUrl}
|
src={selectedEvent.imageUrl}
|
||||||
alt={selectedEvent.imageCaption || selectedEvent.label}
|
alt={selectedEvent.imageCaption || selectedEvent.label}
|
||||||
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
|
className="w-full rounded max-h-[180px] object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
{selectedEvent.imageCaption && (
|
{selectedEvent.imageCaption && (
|
||||||
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
|
<div className="text-[10px] text-kcg-muted mt-[3px]">{selectedEvent.imageCaption}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
|
<div className="text-[10px] text-kcg-dim mt-1.5">
|
||||||
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@ -19,6 +20,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SensorChart({ data, currentTime, startTime }: Props) {
|
export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const visibleData = useMemo(
|
const visibleData = useMemo(
|
||||||
() => data.filter(d => d.timestamp <= currentTime),
|
() => data.filter(d => d.timestamp <= currentTime),
|
||||||
[data, currentTime],
|
[data, currentTime],
|
||||||
@ -35,10 +38,10 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sensor-chart">
|
<div className="sensor-chart">
|
||||||
<h3>Sensor Data</h3>
|
<h3>{t('sensor.title')}</h3>
|
||||||
<div className="chart-grid">
|
<div className="chart-grid">
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Seismic Activity</h4>
|
<h4>{t('sensor.seismicActivity')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -52,7 +55,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Noise Level (dB)</h4>
|
<h4>{t('sensor.noiseLevelDb')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -65,7 +68,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Air Pressure (hPa)</h4>
|
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -78,7 +81,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-item">
|
<div className="chart-item">
|
||||||
<h4>Radiation (uSv/h)</h4>
|
<h4>{t('sensor.radiationUsv')}</h4>
|
||||||
<ResponsiveContainer width="100%" height={80}>
|
<ResponsiveContainer width="100%" height={80}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { memo, useMemo, useState, useEffect } from 'react';
|
import { memo, useMemo, useState, useEffect } from 'react';
|
||||||
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Ship, ShipCategory } from '../types';
|
import type { Ship, ShipCategory } from '../types';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
@ -9,17 +10,30 @@ interface Props {
|
|||||||
koreanOnly?: boolean;
|
koreanOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MarineTraffic-style vessel type colors ──
|
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||||
const MT_TYPE_COLORS: Record<string, string> = {
|
const MT_TYPE_COLORS: Record<string, string> = {
|
||||||
cargo: '#f0a830', // orange-yellow
|
cargo: 'var(--kcg-ship-cargo)',
|
||||||
tanker: '#e74c3c', // red
|
tanker: 'var(--kcg-ship-tanker)',
|
||||||
passenger: '#4caf50', // green
|
passenger: 'var(--kcg-ship-passenger)',
|
||||||
fishing: '#42a5f5', // light blue
|
fishing: 'var(--kcg-ship-fishing)',
|
||||||
pleasure: '#e91e8c', // pink/magenta
|
pleasure: 'var(--kcg-ship-pleasure)',
|
||||||
military: '#d32f2f', // dark red
|
military: 'var(--kcg-ship-military)',
|
||||||
tug_special: '#2e7d32', // dark green
|
tug_special: 'var(--kcg-ship-tug)',
|
||||||
other: '#5c6bc0', // indigo/blue
|
other: 'var(--kcg-ship-other)',
|
||||||
unknown: '#9e9e9e', // grey
|
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
|
// Map our internal ShipCategory + typecode → MT visual type
|
||||||
@ -63,21 +77,6 @@ const NAVY_COLORS: Record<string, string> = {
|
|||||||
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<ShipCategory, string> = {
|
|
||||||
carrier: 'CARRIER', destroyer: 'DDG', warship: 'WARSHIP', submarine: 'SUB',
|
|
||||||
patrol: 'PATROL', tanker: 'TANKER', cargo: 'CARGO', civilian: 'CIV', unknown: 'N/A',
|
|
||||||
};
|
|
||||||
|
|
||||||
const MT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
cargo: 'Cargo', tanker: 'Tanker', passenger: 'Passenger', fishing: 'Fishing',
|
|
||||||
pleasure: 'Yacht', military: 'Military', tug_special: 'Tug/Special', other: 'Other', unknown: 'Unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLAG_LABELS: Record<string, string> = {
|
|
||||||
US: 'USN', UK: 'RN', FR: 'MN', KR: 'ROKN', IR: 'IRIN',
|
|
||||||
JP: 'JMSDF', AU: 'RAN', DE: 'DM', IN: 'IN',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FLAG_EMOJI: Record<string, string> = {
|
const FLAG_EMOJI: Record<string, string> = {
|
||||||
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
|
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}',
|
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
|
||||||
@ -104,6 +103,10 @@ function getShipColor(ship: Ship): string {
|
|||||||
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
|
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 ──
|
// ── Local Korean ship photos ──
|
||||||
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
||||||
'440034000': '/ships/440034000.jpg',
|
'440034000': '/ships/440034000.jpg',
|
||||||
@ -126,30 +129,92 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
|
|||||||
interface VesselPhotoData { url: string; }
|
interface VesselPhotoData { url: string; }
|
||||||
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
|
||||||
|
|
||||||
function VesselPhoto({ mmsi }: { mmsi: string }) {
|
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];
|
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
|
||||||
const [photo, setPhoto] = useState<VesselPhotoData | null | undefined>(() => {
|
|
||||||
if (localUrl) return { url: localUrl };
|
// 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;
|
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localUrl) return;
|
if (activeTab !== 'marinetraffic') return;
|
||||||
if (photo !== undefined) return;
|
if (mtPhoto !== undefined) return;
|
||||||
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); };
|
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
|
||||||
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); };
|
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
|
||||||
img.src = imgUrl;
|
img.src = imgUrl;
|
||||||
}, [mmsi, photo, localUrl]);
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!photo) return null;
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div className="mb-1.5">
|
||||||
<img src={photo.url} alt="Vessel"
|
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
|
||||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
{hasSignalBatch && (
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -215,7 +280,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
|||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {
|
properties: {
|
||||||
mmsi: ship.mmsi,
|
mmsi: ship.mmsi,
|
||||||
color: getShipColor(ship),
|
color: getShipHex(ship),
|
||||||
size: SIZE_MAP[ship.category],
|
size: SIZE_MAP[ship.category],
|
||||||
isMil: isMilitary(ship.category) ? 1 : 0,
|
isMil: isMilitary(ship.category) ? 1 : 0,
|
||||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||||
@ -328,76 +393,71 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation('ships');
|
||||||
const mtType = getMTType(ship);
|
const mtType = getMTType(ship);
|
||||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||||
const isMil = isMilitary(ship.category);
|
const isMil = isMilitary(ship.category);
|
||||||
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined;
|
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
|
||||||
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color;
|
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
|
||||||
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup longitude={ship.lng} latitude={ship.lat}
|
<Popup longitude={ship.lng} latitude={ship.lat}
|
||||||
onClose={onClose} closeOnClick={false}
|
onClose={onClose} closeOnClick={false}
|
||||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||||
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}>
|
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
|
||||||
<div style={{
|
<div
|
||||||
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff',
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
|
||||||
padding: '6px 10px', borderRadius: '4px 4px 0 0',
|
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
|
||||||
margin: '-10px -10px 8px -10px',
|
>
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
|
||||||
}}>
|
<strong className="text-[13px] flex-1">{ship.name}</strong>
|
||||||
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
|
|
||||||
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
|
|
||||||
{navyLabel && (
|
{navyLabel && (
|
||||||
<span style={{
|
<span
|
||||||
background: navyAccent, color: '#000', padding: '1px 6px',
|
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
style={{ background: navyAccent || color }}
|
||||||
}}>{navyLabel}</span>
|
>{navyLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<VesselPhoto mmsi={ship.mmsi} />
|
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
|
||||||
<div style={{
|
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
|
||||||
display: 'flex', gap: 4, marginBottom: 6,
|
<span
|
||||||
borderBottom: '1px solid #ddd', paddingBottom: 4,
|
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
|
||||||
}}>
|
style={{ background: color }}
|
||||||
<span style={{
|
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
|
||||||
background: color, color: '#fff', padding: '1px 6px',
|
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
|
||||||
borderRadius: 3, fontSize: 10, fontWeight: 700,
|
{t(`categoryLabel.${ship.category}`)}
|
||||||
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span>
|
</span>
|
||||||
<span style={{
|
|
||||||
background: '#333', color: '#ccc', padding: '1px 6px',
|
|
||||||
borderRadius: 3, fontSize: 10,
|
|
||||||
}}>{CATEGORY_LABELS[ship.category]}</span>
|
|
||||||
{ship.typeDesc && (
|
{ship.typeDesc && (
|
||||||
<span style={{ color: '#666', fontSize: 10, lineHeight: '18px' }}>{ship.typeDesc}</span>
|
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11 }}>
|
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
|
||||||
<div>
|
<div>
|
||||||
<div><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div>
|
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
|
||||||
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>}
|
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
|
||||||
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>}
|
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
|
||||||
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>}
|
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
|
||||||
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>}
|
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
|
||||||
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>}
|
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
|
||||||
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>}
|
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}°</div>
|
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}°</div>
|
||||||
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}°</div>
|
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}°</div>
|
||||||
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div>
|
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
|
||||||
<div><span style={{ color: '#888' }}>Lat : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
|
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
|
||||||
<div><span style={{ color: '#888' }}>Lon : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</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 style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>}
|
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
|
||||||
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>}
|
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}>
|
<div className="mt-1.5 text-[9px] text-[#999] text-right">
|
||||||
Last Update : {new Date(ship.lastSeen).toLocaleString()}
|
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
<div className="mt-1 text-[10px] text-right">
|
||||||
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
|
||||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
||||||
MarineTraffic →
|
MarineTraffic →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { GeoEvent } from '../types';
|
import type { GeoEvent } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -22,24 +23,25 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
osint: '#06b6d4',
|
osint: '#06b6d4',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS_KO: Record<string, string> = {
|
const TYPE_I18N_KEYS: Record<string, string> = {
|
||||||
airstrike: '공습',
|
airstrike: 'event.airstrike',
|
||||||
explosion: '폭발',
|
explosion: 'event.explosion',
|
||||||
missile_launch: '미사일 발사',
|
missile_launch: 'event.missileLaunch',
|
||||||
intercept: '요격',
|
intercept: 'event.intercept',
|
||||||
alert: '경보',
|
alert: 'event.alert',
|
||||||
impact: '피격',
|
impact: 'event.impact',
|
||||||
osint: 'OSINT',
|
osint: 'event.osint',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABELS_KO: Record<string, string> = {
|
const SOURCE_I18N_KEYS: Record<string, string> = {
|
||||||
US: '미국',
|
US: 'source.US',
|
||||||
IL: '이스라엘',
|
IL: 'source.IL',
|
||||||
IR: '이란',
|
IR: 'source.IR',
|
||||||
proxy: '대리세력',
|
proxy: 'source.proxy',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||||
@ -53,13 +55,13 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
}));
|
}));
|
||||||
}, [events, startTime, endTime]);
|
}, [events, startTime, endTime]);
|
||||||
|
|
||||||
const formatTime = (t: number) => {
|
const formatTime = (ts: number) => {
|
||||||
const d = new Date(t + KST_OFFSET);
|
const d = new Date(ts + KST_OFFSET);
|
||||||
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimeShort = (t: number) => {
|
const formatTimeShort = (ts: number) => {
|
||||||
const d = new Date(t + KST_OFFSET);
|
const d = new Date(ts + KST_OFFSET);
|
||||||
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,8 +130,10 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
const color = TYPE_COLORS[ev.type] || '#888';
|
const color = TYPE_COLORS[ev.type] || '#888';
|
||||||
const isPast = ev.timestamp <= currentTime;
|
const isPast = ev.timestamp <= currentTime;
|
||||||
const isActive = ev.id === selectedId;
|
const isActive = ev.id === selectedId;
|
||||||
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
|
const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
|
||||||
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
|
const source = sourceKey ? t(sourceKey) : '';
|
||||||
|
const typeKey = TYPE_I18N_KEYS[ev.type];
|
||||||
|
const typeLabel = typeKey ? t(typeKey) : ev.type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -137,7 +141,7 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
|
|||||||
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
|
||||||
style={{ '--card-color': color } as React.CSSProperties}
|
style={{ '--card-color': color } as React.CSSProperties}
|
||||||
onClick={() => handleEventCardClick(ev)}
|
onClick={() => handleEventCardClick(ev)}
|
||||||
title="클릭하면 지도에서 해당 위치로 이동합니다"
|
title={t('timeline.flyToTooltip')}
|
||||||
>
|
>
|
||||||
<span className="tl-card-dot" />
|
<span className="tl-card-dot" />
|
||||||
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
|
||||||
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;
|
||||||
1
src/env.d.ts → frontend/src/env.d.ts
vendored
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SPG_API_KEY?: string;
|
readonly VITE_SPG_API_KEY?: string;
|
||||||
|
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||