feat: 모노레포 전환 + signal-batch 연동 + Tailwind/i18n + 백엔드 스켈레톤 #2

병합
htlee feature/monorepo-restructure 에서 develop 로 4 commits 를 머지했습니다 2026-03-17 14:07:35 +09:00
140개의 변경된 파일6407개의 추가작업 그리고 2588개의 파일을 삭제

파일 보기

@ -43,5 +43,42 @@
"Read(./**/.env.*)",
"Read(./**/secrets/**)"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
}
}

파일 보기

@ -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"
}

파일 보기

@ -0,0 +1,75 @@
name: Deploy KCG
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ═══ Frontend ═══
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Configure npm registry
run: |
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > frontend/.npmrc
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> frontend/.npmrc
- name: Build frontend
working-directory: frontend
env:
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
run: |
npm ci
npx vite build
- name: Deploy frontend
run: |
mkdir -p /deploy/kcg
rm -rf /deploy/kcg/*
cp -r frontend/dist/* /deploy/kcg/
# ═══ Backend ═══
- name: Install JDK 17 + Maven
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq openjdk-17-jdk-headless maven
- name: Build backend
working-directory: backend
run: mvn -B clean package -DskipTests
- name: Deploy backend
run: |
DEPLOY_DIR=/deploy/kcg-backend
mkdir -p $DEPLOY_DIR/backup
# JAR 백업 (최근 5개 유지)
if [ -f $DEPLOY_DIR/kcg.jar ]; then
cp $DEPLOY_DIR/kcg.jar $DEPLOY_DIR/backup/kcg-$(date +%Y%m%d%H%M%S).jar
ls -t $DEPLOY_DIR/backup/*.jar | tail -n +6 | xargs -r rm
fi
# 서비스 중지 → JAR 교체 → 서비스 시작
sudo systemctl stop kcg-backend || true
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar
sudo systemctl start kcg-backend
- name: Health check
run: |
echo "Waiting for backend to start..."
for i in $(seq 1 30); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/auth/me 2>/dev/null || echo "000")
if [ "$HTTP_CODE" != "000" ]; then
echo "Backend is up (HTTP $HTTP_CODE, attempt $i)"
exit 0
fi
sleep 2
done
echo "Backend health check failed after 60s"
exit 1

파일 보기

@ -1,54 +1,88 @@
#!/bin/bash
#==============================================================================
# pre-commit hook (React TypeScript)
# TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단
# pre-commit hook (모노레포: Frontend + Backend)
# Frontend: TypeScript 컴파일 + 린트 검증
# Backend: Maven 컴파일 검증
# 실패 시 커밋 차단
#==============================================================================
echo "pre-commit: TypeScript 타입 체크 중..."
FAILED=0
# npm 확인
if ! command -v npx &>/dev/null; then
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
exit 0
fi
#------------------------------------------------------------------------------
# Frontend 검증
#------------------------------------------------------------------------------
if [ -d "frontend" ]; then
echo "pre-commit: [Frontend] TypeScript 타입 체크 중..."
# node_modules 확인
if [ ! -d "node_modules" ]; then
echo "경고: node_modules가 없습니다. 'npm install' 실행 후 다시 시도하세요."
exit 1
fi
if ! command -v npx &>/dev/null; then
echo "경고: npx가 설치되지 않았습니다. Frontend 검증을 건너뜁니다."
elif [ ! -d "frontend/node_modules" ]; then
echo "경고: frontend/node_modules가 없습니다. 'cd frontend && npm install' 실행 후 다시 시도하세요."
FAILED=1
else
(cd frontend && npx tsc --noEmit --pretty 2>&1)
TSC_RESULT=$?
# TypeScript 타입 체크
npx tsc --noEmit --pretty 2>&1
TSC_RESULT=$?
if [ $TSC_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ [Frontend] TypeScript 타입 에러! 커밋이 차단되었습니다.║"
echo "╚══════════════════════════════════════════════════════════╝"
FAILED=1
else
echo "pre-commit: [Frontend] 타입 체크 성공"
fi
if [ $TSC_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║"
echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
# ESLint 검증
if [ -f "frontend/eslint.config.js" ] || [ -f "frontend/eslint.config.mjs" ] || [ -f "frontend/.eslintrc.js" ] || [ -f "frontend/.eslintrc.json" ]; then
echo "pre-commit: [Frontend] ESLint 검증 중..."
(cd frontend && npx eslint src/ --ext .ts,.tsx --quiet 2>&1)
LINT_RESULT=$?
echo "pre-commit: 타입 체크 성공"
# ESLint 검증 (설정 파일이 있는 경우만)
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
echo "pre-commit: ESLint 검증 중..."
npx eslint src/ --ext .ts,.tsx --quiet 2>&1
LINT_RESULT=$?
if [ $LINT_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
echo "║ 'npm run lint -- --fix'로 자동 수정을 시도해보세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
if [ $LINT_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ [Frontend] ESLint 에러! 커밋이 차단되었습니다. ║"
echo "╚══════════════════════════════════════════════════════════╝"
FAILED=1
else
echo "pre-commit: [Frontend] ESLint 통과"
fi
fi
fi
echo "pre-commit: ESLint 통과"
fi
#------------------------------------------------------------------------------
# Backend 검증
#------------------------------------------------------------------------------
if [ -d "backend" ] && [ -f "backend/pom.xml" ]; then
echo "pre-commit: [Backend] Maven 컴파일 검증 중..."
if ! command -v mvn &>/dev/null; then
echo "경고: mvn이 설치되지 않았습니다. Backend 검증을 건너뜁니다."
else
(cd backend && mvn compile -q 2>&1)
MVN_RESULT=$?
if [ $MVN_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ [Backend] Maven 컴파일 에러! 커밋이 차단되었습니다. ║"
echo "╚══════════════════════════════════════════════════════════╝"
FAILED=1
else
echo "pre-commit: [Backend] 컴파일 성공"
fi
fi
fi
#------------------------------------------------------------------------------
# 결과
#------------------------------------------------------------------------------
if [ $FAILED -ne 0 ]; then
echo ""
echo "pre-commit: 검증 실패! 에러를 수정한 후 다시 커밋해주세요."
exit 1
fi
echo "pre-commit: 모든 검증 통과"

15
.gitignore vendored
파일 보기

@ -68,3 +68,18 @@ dist-ssr
*.sln
*.sw?
*.pdf
# === Frontend ===
frontend/dist/
frontend/node_modules/
# === Backend ===
backend/target/
backend/.env
backend/src/main/resources/application-local.yml
backend/src/main/resources/application-prod.yml
# === Prediction ===
prediction/__pycache__/
prediction/venv/
prediction/.env

102
CLAUDE.md
파일 보기

@ -1,48 +1,90 @@
# 프로젝트 개요
- **타입**: React + TypeScript + Vite
- **타입**: 모노레포 (Frontend + Backend + Prediction)
- **Frontend**: React + TypeScript + Vite
- **Backend**: Spring Boot 3.2.5 + Java 17 + PostgreSQL
- **Prediction**: FastAPI (Python)
- **Node.js**: `.node-version` 참조
- **패키지 매니저**: npm
- **빌드 도구**: Vite
- **Java**: `backend/.sdkmanrc` 참조
- **패키지 매니저**: npm (frontend), Maven (backend), pip (prediction)
## 빌드 및 실행
### Frontend
```bash
# 의존성 설치
cd frontend
npm install
# 개발 서버
npm run dev
```
# 빌드
npm run build
### Backend
```bash
cd backend
# application-local.yml 설정 필요 (application-local.yml.example 참조)
cp src/main/resources/application-local.yml.example src/main/resources/application-local.yml
mvn spring-boot:run -Dspring-boot.run.profiles=local
```
# 테스트
npm run test
### Database
```bash
psql -U postgres -f database/init.sql
psql -U postgres -d kcgdb -f database/migration/001_initial_schema.sql
```
# 린트
npm run lint
### Prediction
```bash
cd prediction
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
# 포맷팅
npm run format
### 린트/검증
```bash
# Frontend
cd frontend && npm run lint
# Backend
cd backend && mvn compile
```
## 프로젝트 구조
```
src/
├── assets/ # 정적 리소스 (이미지, 폰트 등)
├── components/ # 공통 UI 컴포넌트
│ ├── common/ # 범용 컴포넌트 (Button, Input 등)
│ └── layout/ # 레이아웃 컴포넌트 (Header, Sidebar 등)
├── hooks/ # 커스텀 훅
├── pages/ # 페이지 컴포넌트 (라우팅 단위)
├── services/ # API 호출 로직
├── store/ # 상태 관리 (Context, Zustand 등)
├── types/ # TypeScript 타입 정의
├── utils/ # 유틸리티 함수
├── App.tsx
└── main.tsx
frontend/
├── src/
│ ├── assets/
│ ├── components/
│ ├── hooks/
│ ├── pages/
│ ├── services/
│ ├── store/
│ ├── types/
│ ├── utils/
│ ├── App.tsx
│ └── main.tsx
├── package.json
└── vite.config.ts
backend/
├── src/main/java/gc/mda/kcg/
│ ├── config/ # 설정 (CORS, Security, Properties)
│ ├── auth/ # 인증 (Google OAuth + JWT)
│ ├── domain/ # 도메인 (event, news, osint, aircraft)
│ └── collector/ # 데이터 수집기 (GDELT, GoogleNews, CENTCOM)
├── src/main/resources/
│ ├── application.yml
│ └── application-*.yml.example
└── pom.xml
database/
├── init.sql
└── migration/
prediction/
├── main.py
└── requirements.txt
```
## 팀 규칙
@ -55,6 +97,6 @@ src/
## 의존성 관리
- Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
- 새 의존성 추가: `npm install 패키지명`
- devDependency: `npm install -D 패키지명`
- Frontend: Nexus 프록시 레포지토리를 통해 npm 패키지 관리 (`.npmrc`)
- Backend: Maven Central (pom.xml)
- Prediction: pip (requirements.txt)

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>

파일 보기

@ -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);
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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();
}

파일 보기

@ -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 {
}

파일 보기

@ -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;
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -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;

파일 보기

@ -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

파일 보기

@ -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}

파일 보기

@ -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;

파일 보기

@ -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);

파일 보기

@ -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 분류 기준으로 동기화

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

파일 보기

@ -10,17 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"@rollup/rollup-darwin-arm64": "^4.59.0",
"@tailwindcss/vite": "^4.2.1",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"i18next": "^25.8.18",
"leaflet": "^1.9.4",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.8",
"react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2"
"satellite.js": "^6.0.2",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@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

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

파일 보기

@ -23,6 +23,10 @@ import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import type { OsintItem } from './services/osint';
import { propagateAircraft, propagateShips } from './services/propagation';
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';
// MarineTraffic-style ship classification
@ -74,6 +78,32 @@ function getMarineTrafficCategory(typecode?: string, category?: string): string
}
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 [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
@ -100,6 +130,47 @@ function App() {
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);
// 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트)
@ -125,6 +196,11 @@ function App() {
const replay = useReplay();
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';
@ -211,7 +287,7 @@ function App() {
return () => clearInterval(interval);
}, [appMode, refreshKey]);
// Fetch Korea region ship data (separate pipeline)
// Fetch Korea region ship data (signal-batch, 4-min cycle)
useEffect(() => {
const load = async () => {
try {
@ -220,7 +296,7 @@ function App() {
} catch { /* keep previous */ }
};
load();
const interval = setInterval(load, 15_000);
const interval = setInterval(load, 240_000);
return () => clearInterval(interval);
}, [appMode, refreshKey]);
@ -376,6 +452,24 @@ function App() {
[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) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
@ -398,12 +492,17 @@ function App() {
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[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 counts: Record<string, number> = {};
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;
}, [ships]);
@ -424,12 +523,21 @@ function App() {
const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]);
const koreaShipsByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of koreaKoreanShips) {
for (const s of koreaShips) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
}
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)
const anyFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
@ -701,8 +809,8 @@ function App() {
}, [koreaShips, koreaFilters.dokdoWatch, currentTime]);
const koreaFilteredShips = useMemo(() => {
if (!anyFilterOn) return koreaShips;
return koreaShips.filter(s => {
if (!anyFilterOn) return visibleKoreaShips;
return visibleKoreaShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (koreaFilters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') 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;
return false;
});
}, [koreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
}, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
return (
<div className={`app ${isLive ? 'app-live' : ''}`}>
@ -724,28 +832,22 @@ function App() {
onClick={() => setDashboardTab('iran')}
>
<span className="dash-tab-flag">🇮🇷</span>
{t('tabs.iran')}
</button>
<button
className={`dash-tab ${dashboardTab === 'korea' ? 'active' : ''}`}
onClick={() => setDashboardTab('korea')}
>
<span className="dash-tab-flag">🇰🇷</span>
{t('tabs.korea')}
</button>
</div>
{/* Mode Toggle */}
{dashboardTab === 'iran' && (
<div className="mode-toggle">
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
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>
<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">
<span className="text-[13px]"></span>
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
</div>
<button
@ -753,14 +855,14 @@ function App() {
onClick={() => setAppMode('live')}
>
<span className="mode-dot-icon" />
LIVE
{t('mode.live')}
</button>
<button
className={`mode-btn ${appMode === 'replay' ? 'active' : ''}`}
onClick={() => setAppMode('replay')}
>
<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>
</div>
)}
@ -770,50 +872,50 @@ function App() {
<button
className={`mode-btn ${koreaFilters.illegalFishing ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.illegalTransship ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.darkVessel ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.cableWatch ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.dokdoWatch ? 'active live' : ''}`}
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
className={`mode-btn ${koreaFilters.ferryWatch ? 'active live' : ''}`}
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>
</div>
)}
@ -825,35 +927,43 @@ function App() {
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>
FLAT MAP
{t('mapMode.flat')}
</button>
<button
className={`map-mode-btn ${mapMode === 'globe' ? 'active' : ''}`}
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>
GLOBE
{t('mapMode.globe')}
</button>
<button
className={`map-mode-btn ${mapMode === 'satellite' ? 'active' : ''}`}
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>
{t('mapMode.satellite')}
</button>
</div>
)}
<div className="header-info">
<div className="header-counts">
<span className="count-item ac-count">{aircraft.length} AC</span>
<span className="count-item mil-count">{militaryCount} MIL</span>
<span className="count-item ship-count">{ships.length} SHIP</span>
<span className="count-item sat-count">{satPositions.length} SAT</span>
<span className="count-item ac-count">{dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC</span>
<span className="count-item mil-count">{dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL</span>
<span className="count-item ship-count">{dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP</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 className="header-status">
<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>
</header>
@ -870,9 +980,9 @@ function App() {
key="map-iran"
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
flyToTarget={flyToTarget}
onFlyToDone={() => setFlyToTarget(null)}
@ -881,32 +991,41 @@ function App() {
<GlobeMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
/>
) : (
<SatelliteMap
events={isLive ? [] : mergedEvents}
currentTime={currentTime}
aircraft={aircraft}
aircraft={visibleAircraft}
satellites={satPositions}
ships={ships}
ships={visibleShips}
layers={layers}
/>
)}
<div className="map-overlay-left">
<LayerPanel
layers={layers}
onToggle={toggleLayer}
aircraftCount={aircraft.length}
militaryCount={militaryCount}
satelliteCount={satPositions.length}
shipCount={ships.length}
koreanShipCount={koreanShips.length}
layers={layers as unknown as Record<string, boolean>}
onToggle={toggleLayer as (key: string) => void}
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>
@ -983,7 +1102,45 @@ function App() {
<>
<main className="app-main">
<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>
<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 { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Aircraft, AircraftCategory } from '../types';
interface Props {
@ -55,7 +56,7 @@ const ALT_COLORS: [number, string][] = [
[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',
};
@ -68,22 +69,18 @@ function getAltitudeColor(altMeters: number): string {
}
function getAircraftColor(ac: Aircraft): string {
const milColor = MIL_COLORS[ac.category];
const milColor = MIL_HEX[ac.category];
if (milColor) return milColor;
if (ac.onGround) return '#555555';
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 ═══
interface PhotoResult { url: string; photographer: string; link: string; }
const photoCache = new Map<string, PhotoResult | null>();
function AircraftPhoto({ hex }: { hex: string }) {
const { t } = useTranslation('ships');
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
photoCache.has(hex) ? photoCache.get(hex) : undefined,
);
@ -119,19 +116,19 @@ function AircraftPhoto({ hex }: { hex: string }) {
}, [hex, photo]);
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;
return (
<div style={{ marginBottom: 6 }}>
<div className="mb-1.5">
<a href={photo.link} target="_blank" rel="noopener noreferrer">
<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'; }}
/>
</a>
{photo.photographer && (
<div style={{ fontSize: 9, color: '#999', marginTop: 2, textAlign: 'right' }}>
<div className="text-[9px] text-[#999] mt-0.5 text-right">
&copy; {photo.photographer}
</div>
)}
@ -188,6 +185,7 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
// ═══ Aircraft Marker ═══
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = getAircraftColor(ac);
const shape = getShape(ac);
@ -198,10 +196,11 @@ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
return (
<>
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div className="relative">
<div
className="cursor-pointer"
style={{
width: size, height: size, cursor: 'pointer',
width: size, height: size,
transform: `rotate(${ac.heading}deg)`,
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}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong>
<span style={{
background: color, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto',
}}>
{CATEGORY_LABELS[ac.category]}
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
<div className="flex items-center gap-2 mb-1.5">
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
<span
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
style={{ background: color }}
>
{t(`aircraftLabel.${ac.category}`)}
</span>
</div>
<AircraftPhoto hex={ac.icao24} />
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}>
<table className="w-full text-[11px] border-collapse">
<tbody>
<tr><td style={{ color: '#888', paddingRight: 8 }}>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.operator && <tr><td style={{ color: '#888' }}>Operator</td><td>{ac.operator}</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 className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
{ac.typecode && (
<tr><td 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>
)}
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>}
<tr><td style={{ color: '#888' }}>Alt</td>
<td>{ac.onGround ? '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 style={{ color: '#888' }}>Hdg</td><td>{Math.round(ac.heading)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>V/S</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}&deg;</td></tr>
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
</tbody>
</table>
<div 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}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
Airplanes.live &rarr;
</a>
</div>

파일 보기

@ -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 { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
@ -25,32 +26,25 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
const isVts = type === 'vts';
if (isVts) {
// VTS: 레이더/안테나 아이콘
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 안테나 */}
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
{/* 전파 */}
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
</svg>
);
}
// 해경 로고: 방패 + 앵커
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* 방패 배경 */}
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
{/* 앵커 */}
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
{/* 별 (본청/지방청) */}
{(type === 'hq' || type === 'regional') && (
<circle cx="12" cy="9" r="1" fill={color} />
)}
@ -60,6 +54,7 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
export function CoastGuardLayer() {
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
const { t } = useTranslation();
return (
<>
@ -69,25 +64,23 @@ export function CoastGuardLayer() {
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
}}>
}} className="flex flex-col items-center">
<CoastGuardIcon type={f.type} size={size} />
{(f.type === 'hq' || f.type === 'regional') && (
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
fontSize: 6,
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() || '본청'}
</div>
)}
{f.type === 'vts' && (
<div style={{
fontSize: 5, color: '#da77f2', marginTop: 0,
fontSize: 5,
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
</div>
)}
@ -100,27 +93,23 @@ export function CoastGuardLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: TYPE_COLOR[selected.type], color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
background: TYPE_COLOR[selected.type],
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
{selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: TYPE_COLOR[selected.type], color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{CG_TYPE_LABEL[selected.type]}</span>
<span style={{
background: '#1a1a2e', color: '#4dabf7',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
background: TYPE_COLOR[selected.type],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
{CG_TYPE_LABEL[selected.type]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
{t('coastGuard.agency')}
</span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
<div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent, Ship } from '../types';
import type { OsintItem } from '../services/osint';
@ -250,16 +251,17 @@ const TYPE_LABELS: Record<GeoEvent['type'], string> = {
intercept: 'INTERCEPT',
alert: 'ALERT',
impact: 'IMPACT',
osint: 'OSINT',
};
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
airstrike: 'var(--kcg-event-airstrike)',
explosion: 'var(--kcg-event-explosion)',
missile_launch: 'var(--kcg-event-missile)',
intercept: 'var(--kcg-event-intercept)',
alert: 'var(--kcg-event-alert)',
impact: 'var(--kcg-event-impact)',
osint: 'var(--kcg-event-osint)',
};
// MarineTraffic-style ship type classification
@ -282,54 +284,81 @@ function getShipMTCategory(typecode?: string, category?: string): string {
return 'unspecified';
}
// MarineTraffic-style category labels and colors
const MT_CATEGORIES: Record<string, { label: string; color: string }> = {
cargo: { label: '화물선', color: '#8bc34a' }, // green
tanker: { label: '유조선', color: '#e91e63' }, // red/pink
passenger: { label: '여객선', color: '#2196f3' }, // blue
high_speed: { label: '고속선', color: '#ff9800' }, // orange
tug_special: { label: '예인선/특수선', color: '#00bcd4' }, // teal
fishing: { label: '어선', color: '#ff5722' }, // deep orange
pleasure: { label: '레저선', color: '#9c27b0' }, // purple
military: { label: '군함', color: '#607d8b' }, // blue-grey
unspecified: { label: '미분류', color: '#9e9e9e' }, // grey
// MarineTraffic-style category colors (labels come from i18n)
const MT_CATEGORY_COLORS: Record<string, string> = {
cargo: '#8bc34a',
tanker: '#e91e63',
passenger: '#2196f3',
high_speed: '#ff9800',
tug_special: '#00bcd4',
fishing: '#ff5722',
pleasure: '#9c27b0',
military: '#607d8b',
unspecified: '#9e9e9e',
};
const NEWS_CATEGORY_STYLE: Record<BreakingNews['category'], { icon: string; color: string; label: string }> = {
trump: { icon: '🇺🇸', color: '#ef4444', label: '트럼프' },
oil: { icon: '🛢️', color: '#f59e0b', label: '유가' },
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
economy: { icon: '📊', color: '#3b82f6', label: '경제' },
const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
trump: '\u{1F1FA}\u{1F1F8}',
oil: '\u{1F6E2}\u{FE0F}',
diplomacy: '\u{1F310}',
economy: '\u{1F4CA}',
};
// OSINT category styles
const OSINT_CAT_STYLE: Record<string, { icon: string; color: string; label: string }> = {
military: { icon: '🎯', color: '#ef4444', label: '군사' },
oil: { icon: '🛢', color: '#f59e0b', label: '에너지' },
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
shipping: { icon: '🚢', color: '#06b6d4', label: '해운' },
nuclear: { icon: '☢', color: '#f97316', label: '핵' },
maritime_accident: { icon: '🚨', color: '#ef4444', label: '해양사고' },
fishing: { icon: '🐟', color: '#22c55e', label: '어선/수산' },
maritime_traffic: { icon: '🚢', color: '#3b82f6', label: '해상교통' },
general: { icon: '📰', color: '#6b7280', label: '일반' },
// OSINT category icons (labels come from i18n)
const OSINT_CAT_ICONS: Record<string, string> = {
military: '\u{1F3AF}',
oil: '\u{1F6E2}',
diplomacy: '\u{1F310}',
shipping: '\u{1F6A2}',
nuclear: '\u{2622}',
maritime_accident: '\u{1F6A8}',
fishing: '\u{1F41F}',
maritime_traffic: '\u{1F6A2}',
general: '\u{1F4F0}',
};
// OSINT category colors
const OSINT_CAT_COLORS: Record<string, string> = {
military: '#ef4444',
oil: '#f59e0b',
diplomacy: '#8b5cf6',
shipping: '#06b6d4',
nuclear: '#f97316',
maritime_accident: '#ef4444',
fishing: '#22c55e',
maritime_traffic: '#3b82f6',
general: '#6b7280',
};
// NEWS category colors
const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
trump: '#ef4444',
oil: '#f59e0b',
diplomacy: '#8b5cf6',
economy: '#3b82f6',
};
const EMPTY_OSINT: OsintItem[] = [];
const EMPTY_SHIPS: import('../types').Ship[] = [];
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return '방금';
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
function useTimeAgo() {
const { t } = useTranslation('common');
return (ts: number): string => {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('time.justNow');
if (mins < 60) return t('time.minutesAgo', { count: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t('time.hoursAgo', { count: hours });
const days = Math.floor(hours / 24);
return t('time.daysAgo', { count: days });
};
}
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) {
const { t } = useTranslation(['common', 'events', 'ships']);
const timeAgo = useTimeAgo();
const visibleEvents = useMemo(
() => events.filter(e => e.timestamp <= currentTime).reverse(),
[events, currentTime],
@ -374,17 +403,18 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<div className="breaking-news-section">
<div className="breaking-news-header">
<span className="breaking-flash">BREAKING</span>
<span className="breaking-title"> / </span>
<span className="breaking-title">{t('events:news.breakingTitle')}</span>
</div>
<div className="breaking-news-list">
{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;
return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
<span className="breaking-cat-tag" style={{ background: catColor }}>
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
</span>
<span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
@ -403,24 +433,26 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{koreanShips.length > 0 && (
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total" style={{ color: '#ef4444' }}>{koreanShips.length}</span>
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
<span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
</div>
<div className="iran-mil-list">
{koreanShips.slice(0, 30).map(s => {
const 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 (
<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-cat" style={{ color: mt.color, background: `${mt.color}22` }}>
{mt.label}
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
{mtLabel}
</span>
{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>
);
@ -434,12 +466,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<>
<div className="osint-header">
<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>
</div>
<div className="osint-list">
{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;
return (
<a
@ -450,8 +483,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
rel="noopener noreferrer"
>
<div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
<span className="osint-cat-tag" style={{ background: catColor }}>
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
</span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span>
@ -467,23 +500,23 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{isLive && osintFeed.length === 0 && (
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">OSINT LIVE FEED</span>
<span className="osint-loading">Loading...</span>
<span className="osint-title">{t('events:osint.liveTitle')}</span>
<span className="osint-loading">{t('events:osint.loading')}</span>
</div>
)}
{/* Event Log (replay mode) */}
{!isLive && (
<>
<h3>Event Log</h3>
<h3>{t('events:log.title')}</h3>
<div className="event-list">
{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 => {
const isNew = currentTime - e.timestamp < 86_400_000;
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
className="event-tag"
style={{ backgroundColor: TYPE_COLORS[e.type] }}
@ -493,10 +526,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<div className="event-content">
<div className="event-label">
{isNew && (
<span style={{
background: '#ff0000', color: '#fff', padding: '0 4px',
borderRadius: 2, fontSize: 9, marginRight: 4, fontWeight: 700,
}}>NEW</span>
<span className="inline-block rounded-sm bg-[var(--kcg-event-impact)] px-1 mr-1 text-[9px] font-bold text-white">{t('events:log.new')}</span>
)}
{e.label}
</div>
@ -523,20 +553,21 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<>
{/* 한국 속보 (replay) */}
{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">
<span className="breaking-flash" style={{ background: '#3b82f6' }}></span>
<span className="breaking-title">🇰🇷 </span>
<span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
<span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
</div>
<div className="breaking-news-list">
{visibleNewsKR.map(n => {
const 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;
return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
<span className="breaking-cat-tag" style={{ background: catColor }}>
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
</span>
<span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
@ -554,52 +585,41 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{/* 한국 선박 현황 — 선종별 분류 */}
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total">{koreanShips.length}</span>
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
</div>
{koreanShips.length > 0 && (() => {
// 선종별 그룹핑
const groups: Record<string, Ship[]> = {};
for (const s of koreanShips) {
const cat = getShipMTCategory(s.typecode, s.category);
if (!groups[cat]) groups[cat] = [];
groups[cat].push(s);
}
// 정렬 순서: 군함 → 유조선 → 화물선 → 여객선 → 어선 → 예인선 → 기타
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
const sorted = order.filter(k => groups[k]?.length);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
<div className="flex flex-col gap-0.5 py-1">
{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 moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving;
return (
<div key={cat} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
background: `${mtColor}0a`,
borderLeft: `3px solid ${mtColor}`,
}}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: mt.color, flexShrink: 0,
}} />
<span style={{
fontSize: 11, fontWeight: 700, color: mt.color,
minWidth: 70, fontFamily: 'monospace',
}}>{mt.label}</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 className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
<span className="text-[13px] font-bold font-mono text-kcg-text">
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
</span>
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
</span>
</div>
);
@ -612,9 +632,9 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{/* 중국 선박 현황 */}
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇨🇳</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total">{chineseShips.length}</span>
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
</div>
{chineseShips.length > 0 && (() => {
const groups: Record<string, Ship[]> = {};
@ -628,49 +648,34 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
const fishingCount = groups['fishing']?.length || 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
<div className="flex flex-col gap-0.5 py-1">
{fishingCount > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 8px', marginBottom: 2,
background: 'rgba(239,68,68,0.1)',
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}
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
<span className="text-sm">{'\u{1F6A8}'}</span>
<span className="text-[11px] font-bold font-mono text-kcg-danger">
{t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
</span>
</div>
)}
{sorted.map(cat => {
const 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 moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving;
return (
<div key={cat} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
background: `${mtColor}0a`,
borderLeft: `3px solid ${mtColor}`,
}}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: mt.color, flexShrink: 0,
}} />
<span style={{
fontSize: 11, fontWeight: 700, color: mt.color,
minWidth: 70, fontFamily: 'monospace',
}}>{mt.label}</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 className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
<span className="text-[13px] font-bold font-mono text-kcg-text">
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
</span>
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
</span>
</div>
);
@ -685,7 +690,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<>
<div className="osint-header">
<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">{(() => {
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
const seen = new Set<string>();
@ -708,7 +713,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
return true;
});
})().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;
return (
<a
@ -719,8 +725,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
rel="noopener noreferrer"
>
<div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
<span className="osint-cat-tag" style={{ background: catColor }}>
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
</span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span>
@ -736,8 +742,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
{osintFeed.length === 0 && (
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">🇰🇷 OSINT LIVE</span>
<span className="osint-loading">Loading...</span>
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
<span className="osint-loading">{t('events:osint.loading')}</span>
</div>
)}
</>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent } from '../types';
interface Props {
@ -21,21 +22,21 @@ const TYPE_COLORS: Record<string, string> = {
osint: '#06b6d4',
};
const TYPE_LABELS_KO: Record<string, string> = {
airstrike: '공습',
explosion: '폭발',
missile_launch: '미사일',
intercept: '요격',
alert: '경보',
impact: '피격',
osint: 'OSINT',
const TYPE_KEYS: Record<string, string> = {
airstrike: 'event.airstrike',
explosion: 'event.explosion',
missile_launch: 'event.missileLaunch',
intercept: 'event.intercept',
alert: 'event.alert',
impact: 'event.impact',
osint: 'event.osint',
};
const SOURCE_LABELS_KO: Record<string, string> = {
US: '미국',
IL: '이스라엘',
IR: '이란',
proxy: '대리세력',
const SOURCE_KEYS: Record<string, string> = {
US: 'source.US',
IL: 'source.IL',
IR: 'source.IR',
proxy: 'source.proxy',
};
interface EventGroup {
@ -44,10 +45,14 @@ interface EventGroup {
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) {
const [openDate, setOpenDate] = useState<string | null>(null);
const { t } = useTranslation();
const groups = useMemo(() => {
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
@ -63,12 +68,12 @@ export function EventStrip({ events, currentTime, onEventClick }: Props) {
const result: EventGroup[] = [];
for (const [dateKey, evs] of map) {
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})`;
result.push({ dateKey, dateLabel, events: evs });
}
return result;
}, [events]);
}, [events, t]);
// Auto-open the first group if none selected
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 => {
const isPast = ev.timestamp <= currentTime;
const color = TYPE_COLORS[ev.type] || '#888';
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : '';
const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type);
return (
<button

파일 보기

@ -98,7 +98,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
map.addControl(new maplibregl.NavigationControl(), 'top-right');
// 한글 국가명 라벨
map.on('load', () => {
map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() });
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(() => {
const map = mapRef.current;
if (!map) return;
@ -227,6 +226,6 @@ export function GlobeMap({ events, currentTime, aircraft, satellites, ships, lay
}, [events, currentTime, aircraft, satellites, ships, layers]);
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 { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREAN_AIRPORTS } from '../services/airports';
import type { KoreanAirport } from '../services/airports';
export function KoreaAirportLayer() {
const [selected, setSelected] = useState<KoreanAirport | null>(null);
const { t } = useTranslation();
return (
<>
@ -16,20 +18,18 @@ export function KoreaAirportLayer() {
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 3px ${color}88)`,
}}>
}} className="flex flex-col items-center">
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 비행기 모양 (위를 향한 여객기) */}
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
fill={color} stroke="#fff" strokeWidth="0.3" />
</svg>
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
fontSize: 6,
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('공항', '')}
</div>
</div>
@ -41,35 +41,28 @@ export function KoreaAirportLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: selected.intl ? '#a78bfa' : '#7c8aaa', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
background: selected.intl ? '#a78bfa' : '#7c8aaa',
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
{selected.intl && (
<span style={{
background: '#a78bfa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
{t('airport.international')}
</span>
)}
{selected.domestic && (
<span style={{
background: '#7c8aaa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
{t('airport.domestic')}
</span>
)}
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.id} / {selected.icao}</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.id} / {selected.icao}
</span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
<div className="text-[9px] text-kcg-dim">
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>

파일 보기

@ -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: '&copy; 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>
);
}

파일 보기

@ -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 { useTranslation } from 'react-i18next';
interface Props {
currentTime: number;
@ -23,21 +24,22 @@ export function LiveControls({
historyMinutes,
onHistoryChange,
}: Props) {
const { t } = useTranslation();
const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
return (
<div className="live-controls">
<div className="live-indicator">
<span className="live-dot" />
<span className="live-label">LIVE</span>
<span className="live-label">{t('header.live')}</span>
</div>
<div className="live-clock">{kstTime}</div>
<div style={{ flex: 1 }} />
<div className="flex-1" />
<div className="history-controls">
<span className="history-label">HISTORY</span>
<span className="history-label">{t('time.history')}</span>
<div className="history-presets">
{HISTORY_PRESETS.map(p => (
<button

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
@ -31,7 +32,6 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
);
}
// caution (해경 등)
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
@ -43,6 +43,7 @@ function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: Traini
export function NavWarningLayer() {
const [selected, setSelected] = useState<NavWarning | null>(null);
const { t } = useTranslation();
return (
<>
@ -53,15 +54,14 @@ export function NavWarningLayer() {
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 4px ${color}88)`,
}}>
}} className="flex flex-col items-center">
<WarningIcon level={w.level} org={w.org} size={size} />
<div style={{
fontSize: 5, color, marginTop: 0,
fontSize: 5, color,
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}
</div>
</div>
@ -73,48 +73,43 @@ export function NavWarningLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: ORG_COLOR[selected.org], color: '#fff',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
background: ORG_COLOR[selected.org],
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-xs font-bold text-white">
{selected.title}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{NW_LEVEL_LABEL[selected.level]}</span>
background: LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{NW_LEVEL_LABEL[selected.level]}
</span>
<span style={{
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
background: ORG_COLOR[selected.org] + '33',
color: ORG_COLOR[selected.org],
border: `1px solid ${ORG_COLOR[selected.org]}44`,
}}>{NW_ORG_LABEL[selected.org]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.area}</span>
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
{NW_ORG_LABEL[selected.org]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.area}
</span>
</div>
<div 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}
</div>
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}>
<div>: {selected.altitude}</div>
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
<div>: {selected.source}</div>
<div>{t('navWarning.source')}: {selected.source}</div>
</div>
<a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block', marginTop: 6,
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
}}
>KHOA </a>
className="mt-1.5 block text-[10px] text-kcg-accent underline"
>{t('navWarning.khoaLink')}</a>
</div>
</Popup>
)}

파일 보기

@ -1,5 +1,6 @@
import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { OilFacility, OilFacilityType } from '../types';
interface Props {
@ -12,11 +13,6 @@ const TYPE_COLORS: Record<OilFacilityType, string> = {
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
const TYPE_LABELS: Record<OilFacilityType, string> = {
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
};
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
@ -54,11 +50,9 @@ function DamageOverlay() {
// SVG icon renderers (JSX versions)
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;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Gradient circle background */}
<defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
@ -67,23 +61,16 @@ function RefineryIcon({ size, color, damaged }: { size: number; color: string; d
</defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
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} />
{/* Tall chimney/tower (center) */}
<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} />
{/* Medium tower (right) */}
<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={18} cy={5} r={2} 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={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)" />
{/* Pipe details */}
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />}
@ -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 }) {
// Oil pumpjack (nodding donkey) icon — transparent style
const sc = damaged ? '#ff0000' : color;
return (
<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} />
{/* 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={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} />
{/* Walking beam (horizontal arm) */}
<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} />
{/* 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} />
{/* 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} />
{/* 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} />
{/* Crank arm + pitman arm */}
<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} />
{/* 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} />
{/* 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"
fill={color} opacity={0.85} />
{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 }) {
// Spherical gas storage tank with support legs (transparent style)
return (
<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={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
{/* Cross braces */}
<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} />
{/* Base platform */}
<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}
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} />
{/* Equator band */}
<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} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{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 }) {
// Water drop + faucet + filter container — desalination plant (transparent)
const sc = damaged ? '#ff0000' : color;
return (
<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"
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"
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} />
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
{/* Water drops from faucet */}
<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} />
{/* Filter/treatment container (bottom right) */}
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
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={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} />
{/* Output pipe from container */}
<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} />
{damaged && <DamageOverlay />}
</svg>
@ -247,6 +203,7 @@ export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, cur
});
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
@ -256,33 +213,32 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div className="relative">
{/* Planned strike targeting ring */}
{isPlanned && (
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 36, height: 36, borderRadius: '50%',
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
pointerEvents: 'none',
}}>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-9 rounded-full pointer-events-none"
style={{
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
}}
>
{/* Crosshair lines */}
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, 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 className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-px h-1.5 bg-[#ff6600] opacity-70" />
<div className="absolute -left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
<div className="absolute -right-1.5 top-1/2 -translate-y-1/2 w-1.5 h-px bg-[#ff6600] opacity-70" />
</div>
)}
<div style={{ cursor: 'pointer' }}
<div className="cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} />
</div>
<div className="gl-marker-label" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8,
<div className="gl-marker-label text-[8px]" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color,
}}>
{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>
</Marker>
@ -290,69 +246,60 @@ function FacilityMarker({ facility, currentTime }: { facility: OilFacility; curr
<Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}>
<span style={{
background: color, color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{TYPE_LABELS[facility.type]}</span>
<div className="min-w-[220px] font-mono text-xs">
<div className="flex gap-1 items-center mb-1.5">
<span
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`facility.type.${facility.type}`)}</span>
{isDamaged && (
<span style={{
background: '#ff0000', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff0000]">
{t('facility.damaged')}
</span>
)}
{isPlanned && (
<span style={{
background: '#ff6600', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}> </span>
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white bg-[#ff6600]">
{t('facility.plannedStrike')}
</span>
)}
</div>
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div>
<div style={{
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
}}>
<div className="font-bold text-[13px] my-1">{facility.nameKo}</div>
<div className="text-[10px] text-kcg-muted mb-1.5">{facility.name}</div>
<div className="bg-black/30 rounded p-1.5 grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{facility.capacityBpd != null && (
<><span style={{ color: '#888' }}>/</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></>
<><span className="text-kcg-muted">{t('facility.production')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityBpd)} bpd</span></>
)}
{facility.capacityMgd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></>
<><span className="text-kcg-muted">{t('facility.desalProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMgd)} MGD</span></>
)}
{facility.capacityMcfd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
<><span className="text-kcg-muted">{t('facility.gasProduction')}</span>
<span className="text-white font-semibold">{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)}
{facility.reservesBbl != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B </span></>
<><span className="text-kcg-muted">{t('facility.reserveOil')}</span>
<span className="text-white font-semibold">{facility.reservesBbl}B {t('facility.barrels')}</span></>
)}
{facility.reservesTcf != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></>
<><span className="text-kcg-muted">{t('facility.reserveGas')}</span>
<span className="text-white font-semibold">{facility.reservesTcf} Tcf</span></>
)}
{facility.operator && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff' }}>{facility.operator}</span></>
<><span className="text-kcg-muted">{t('facility.operator')}</span>
<span className="text-white">{facility.operator}</span></>
)}
</div>
{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 && (
<div style={{
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,
}}>
<div className="mt-1.5 px-2 py-1 text-[11px] rounded leading-snug bg-[rgba(255,102,0,0.15)] border border-[rgba(255,102,0,0.4)] text-[#ff9933]">
{facility.plannedLabel}
</div>
)}
<div 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
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { OsintItem } from '../services/osint';
@ -18,13 +19,16 @@ const CAT_ICON: Record<string, string> = {
shipping: '🚢',
};
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}분 전`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}시간 전`;
return `${Math.floor(h / 24)}일 전`;
function useTimeAgo() {
const { t } = useTranslation();
return (ts: number): string => {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 60) return t('time.minutesAgo', { count: m });
const h = Math.floor(m / 60);
if (h < 24) return t('time.hoursAgo', { count: h });
return t('time.daysAgo', { count: Math.floor(h / 24) });
};
}
interface Props {
@ -38,8 +42,9 @@ const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffi
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
const [selected, setSelected] = useState<OsintItem | null>(null);
const { t } = useTranslation();
const timeAgo = useTimeAgo();
// 좌표가 있고, 해양 관련 카테고리이며, 최근 3시간 이내인 OSINT만 표시
const geoItems = useMemo(() => osintFeed.filter(
(item): item is OsintItem & { lat: number; lng: number } =>
item.lat != null && item.lng != null
@ -51,30 +56,25 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
<>
{geoItems.map(item => {
const color = CAT_COLOR[item.category] || '#888';
const isRecent = currentTime - item.timestamp < ONE_HOUR; // 1시간 이내
const isRecent = currentTime - item.timestamp < ONE_HOUR;
return (
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 6px ${color}aa)`,
}}>
}} className="flex flex-col items-center">
<div style={{
width: 22, height: 22, borderRadius: '50%',
background: `rgba(0,0,0,0.6)`,
border: `2px solid ${color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12,
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
}}>
}} className="flex size-[22px] items-center justify-center rounded-full bg-black/60 text-xs">
{CAT_ICON[item.category] || '📰'}
</div>
{isRecent && (
<div style={{
fontSize: 5, color, marginTop: 1,
fontSize: 5, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
fontWeight: 700, letterSpacing: 0.3,
}}>
}} className="mt-px font-bold tracking-wide">
NEW
</div>
)}
@ -87,41 +87,36 @@ export function OsintMapLayer({ osintFeed, currentTime }: Props) {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6,
}}>
background: CAT_COLOR[selected.category] || '#888',
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[11px] font-bold text-white">
<span>{CAT_ICON[selected.category] || '📰'}</span>
OSINT
</div>
<div 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}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>{selected.category.replace('_', ' ').toUpperCase()}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 9,
}}>{selected.source}</span>
<span style={{
background: '#1a1a2e', color: '#666',
padding: '1px 6px', borderRadius: 3, fontSize: 9,
}}>{timeAgo(selected.timestamp)}</span>
background: CAT_COLOR[selected.category] || '#888',
}} className="rounded-sm px-1.5 py-px text-[9px] font-bold text-white">
{selected.category.replace('_', ' ').toUpperCase()}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-muted">
{selected.source}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[9px] text-kcg-dim">
{timeAgo(selected.timestamp)}
</span>
</div>
{selected.url && (
<a
href={selected.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
> </a>
className="text-[10px] text-kcg-accent underline"
>{t('osintMap.viewOriginal')}</a>
)}
</div>
</Popup>

파일 보기

@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
import type { PiracyZone } from '../services/piracy';
@ -6,16 +7,11 @@ import type { PiracyZone } from '../services/piracy';
function SkullIcon({ color, size }: { color: string; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* skull */}
<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="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" />
{/* jaw */}
<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="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
@ -24,6 +20,7 @@ function SkullIcon({ color, size }: { color: string; size: number }) {
export function PiracyLayer() {
const [selected, setSelected] = useState<PiracyZone | null>(null);
const { t } = useTranslation();
return (
<>
@ -34,17 +31,15 @@ export function PiracyLayer() {
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer',
filter: `drop-shadow(0 0 8px ${color}aa)`,
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
}}>
}} className="flex flex-col items-center">
<SkullIcon color={color} size={size} />
<div style={{
fontSize: 7, color, marginTop: 1,
fontSize: 7, color,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
fontFamily: 'monospace',
}}>
}} className="mt-px whitespace-nowrap font-mono font-bold tracking-wide">
{PIRACY_LEVEL_LABEL[zone.level]}
</div>
</div>
@ -56,43 +51,40 @@ export function PiracyLayer() {
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
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={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '5px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 14 }}></span>
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2.5 py-1.5 text-xs font-bold text-white">
<span className="text-sm"></span>
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<div className="mb-1.5 flex flex-wrap gap-1">
<span style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.name}</span>
background: PIRACY_LEVEL_COLOR[selected.level],
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
{PIRACY_LEVEL_LABEL[selected.level]}
</span>
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
{selected.name}
</span>
{selected.recentIncidents != null && (
<span style={{
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
color: PIRACY_LEVEL_COLOR[selected.level],
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 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}
</div>
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}>
<div className="text-[10px] leading-snug text-[#999]">
{selected.detail}
</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
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
isPlaying: boolean;
@ -51,6 +52,7 @@ export function ReplayControls({
onSpeedChange,
onRangeChange,
}: Props) {
const { t } = useTranslation();
const [showPicker, setShowPicker] = useState(false);
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
@ -76,7 +78,7 @@ export function ReplayControls({
return (
<div className="replay-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">
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
@ -109,7 +111,7 @@ export function ReplayControls({
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
<div className="flex-1" />
{/* Right: range presets + custom picker */}
<div className="range-controls">
@ -126,7 +128,7 @@ export function ReplayControls({
<button
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
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">
<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-row">
<label>
<span>FROM (KST)</span>
<span>{t('controls.from')}</span>
<input
type="datetime-local"
value={customStart}
@ -149,7 +151,7 @@ export function ReplayControls({
/>
</label>
<label>
<span>TO (KST)</span>
<span>{t('controls.to')}</span>
<input
type="datetime-local"
value={customEnd}
@ -157,7 +159,7 @@ export function ReplayControls({
/>
</label>
<button className="range-apply-btn" onClick={handleCustomApply}>
APPLY
{t('controls.apply')}
</button>
</div>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer';
@ -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) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(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" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
@ -266,9 +267,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = EVENT_RADIUS[event.type] * 5;
return (
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-pulse-ring" style={{
width: size, height: size, borderRadius: '50%',
border: `2px solid ${color}`, pointerEvents: 'none',
<div className="gl-pulse-ring rounded-full pointer-events-none" style={{
width: size, height: size,
border: `2px solid ${color}`,
}} />
</Marker>
);
@ -279,9 +280,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 100 : 70;
return (
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-shockwave" style={{
width: size, height: size, borderRadius: '50%',
border: `3px solid ${color}`, pointerEvents: 'none',
<div className="gl-shockwave rounded-full pointer-events-none" style={{
width: size, height: size,
border: `3px solid ${color}`,
}} />
</Marker>
);
@ -292,9 +293,9 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const size = event.type === 'impact' ? 40 : 30;
return (
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-strike-flash" style={{
width: size, height: size, borderRadius: '50%',
background: color, opacity: 0.6, pointerEvents: 'none',
<div className="gl-strike-flash rounded-full opacity-60 pointer-events-none" style={{
width: size, height: size,
background: color,
}} />
</Marker>
);
@ -304,11 +305,10 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000;
const DAY_H = 24;
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
const isRecent = ageHours <= DAY_H;
const opacity = isRecent
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15
? Math.max(0.85, 1 - ageHours * 0.006)
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005);
const color = getEventColor(event);
const isNew = ageMs >= 0 && ageMs < 600_000;
const baseR = EVENT_RADIUS[event.type];
@ -318,8 +318,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div
className={isNew ? 'gl-event-flash' : undefined}
style={{ cursor: 'pointer' }}
className={`cursor-pointer ${isNew ? 'gl-event-flash' : ''}`}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
>
<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 ageHours = ageMs / 3600_000;
const isRecent = ageHours <= 24;
// 최근 1일: 진하게, 이후: 흐리게
const impactOpacity = isRecent
? Math.max(0.8, 1 - ageHours * 0.008)
: 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;
return (
<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); }}>
<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} />
@ -378,47 +376,43 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
maxWidth="320px"
className="gl-popup"
>
<div style={{ minWidth: 200, maxWidth: 320 }}>
<div className="min-w-[200px] max-w-[320px]">
{selectedEvent.source && (
<span style={{
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
}}>
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
<span
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
style={{ background: getEventColor(selectedEvent) }}
>
{t(`source.${selectedEvent.source}`)}
</span>
)}
{selectedEvent.type === 'impact' && (
<div style={{
background: '#ff0000', color: '#fff', padding: '3px 8px',
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
{t('popup.impactSite')}
</div>
)}
<div><strong>{selectedEvent.label}</strong></div>
<span 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
</span>
{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 && (
<div style={{ marginTop: 8 }}>
<div className="mt-2">
<img
src={selectedEvent.imageUrl}
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'; }}
/>
{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>
)}
{selectedEvent.type === 'impact' && (
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}>
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
<div className="text-[10px] text-kcg-muted mt-1.5">
{selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div>
)}
</div>

파일 보기

@ -1,4 +1,5 @@
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import type { SatellitePosition } from '../types';
@ -72,13 +73,11 @@ const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
};
export function SatelliteLayer({ satellites }: Props) {
// Ground tracks as GeoJSON
const trackData = useMemo(() => {
const features: GeoJSON.Feature[] = [];
for (const sat of satellites) {
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
const color = CAT_COLORS[sat.category];
// Break at antimeridian crossings
let segment: [number, number][] = [];
for (let i = 0; i < sat.groundTrack.length; 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 [showPopup, setShowPopup] = useState(false);
const { t } = useTranslation();
const color = CAT_COLORS[sat.category];
const svgBody = SVG_MAP[sat.category];
const size = 22;
@ -140,9 +140,9 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
return (
<>
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div className="relative">
<div
style={{ width: size, height: size, color, cursor: 'pointer' }}
style={{ color }} className="size-[22px] cursor-pointer"
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
>
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
@ -158,20 +158,21 @@ const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatelliteP
<Popup longitude={sat.lng} latitude={sat.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="200px" className="gl-popup">
<div style={{ minWidth: 180, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div className="min-w-[180px] font-mono text-xs">
<div className="mb-1.5 flex items-center gap-2">
<span style={{
background: color, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{CAT_LABELS[sat.category]}</span>
background: color,
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
{CAT_LABELS[sat.category]}
</span>
<strong>{sat.name}</strong>
</div>
<table style={{ width: '100%', fontSize: 11 }}>
<table className="w-full text-[11px]">
<tbody>
<tr><td style={{ color: '#888' }}>NORAD</td><td>{sat.noradId}</td></tr>
<tr><td style={{ color: '#888' }}>Lat</td><td>{sat.lat.toFixed(2)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>Lng</td><td>{sat.lng.toFixed(2)}&deg;</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.norad')}</td><td>{sat.noradId}</td></tr>
<tr><td className="text-kcg-muted">{t('satellite.lat')}</td><td>{sat.lat.toFixed(2)}&deg;</td></tr>
<tr><td className="text-kcg-muted">{t('satellite.lng')}</td><td>{sat.lng.toFixed(2)}&deg;</td></tr>
<tr><td className="text-kcg-muted">{t('satellite.alt')}</td><td>{Math.round(sat.altitude)} km</td></tr>
</tbody>
</table>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer';
@ -86,6 +87,7 @@ const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
};
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
@ -179,14 +181,13 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
>
<div
className="rounded-full cursor-pointer"
style={{
width: EVENT_RADIUS[ev.type],
height: EVENT_RADIUS[ev.type],
borderRadius: '50%',
background: getEventColor(ev),
border: '2px solid rgba(255,255,255,0.8)',
boxShadow: `0 0 8px ${getEventColor(ev)}`,
cursor: 'pointer',
}}
/>
</Marker>
@ -203,46 +204,42 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
maxWidth="320px"
className="event-popup"
>
<div style={{ minWidth: 200, maxWidth: 320 }}>
<div className="min-w-[200px] max-w-[320px]">
{selectedEvent.source && (
<span style={{
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
}}>
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
<span
className="inline-block rounded-sm text-[10px] font-bold text-white mb-1 px-1.5 py-px"
style={{ background: getEventColor(selectedEvent) }}
>
{t(`source.${selectedEvent.source}`)}
</span>
)}
{selectedEvent.type === 'impact' && (
<div style={{
background: '#ff0000', color: '#fff', padding: '3px 8px',
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
<div className="inline-block rounded-sm text-[11px] font-bold text-white mb-1.5 px-2 py-[3px] bg-kcg-event-impact">
{t('popup.impactSite')}
</div>
)}
<div style={{ color: '#e0e0e0' }}><strong>{selectedEvent.label}</strong></div>
<span style={{ fontSize: 12, color: '#888' }}>
<div className="text-kcg-text"><strong>{selectedEvent.label}</strong></div>
<span className="text-xs text-kcg-muted">
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
</span>
{selectedEvent.description && (
<p 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 && (
<div style={{ marginTop: 8 }}>
<div className="mt-2">
<img
src={selectedEvent.imageUrl}
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'; }}
/>
{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 style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
<div className="text-[10px] text-kcg-dim mt-1.5">
{selectedEvent.lat.toFixed(4)}&deg;N, {selectedEvent.lng.toFixed(4)}&deg;E
</div>
</div>
</Popup>

파일 보기

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
LineChart,
Line,
@ -19,6 +20,8 @@ interface Props {
}
export function SensorChart({ data, currentTime, startTime }: Props) {
const { t } = useTranslation();
const visibleData = useMemo(
() => data.filter(d => d.timestamp <= currentTime),
[data, currentTime],
@ -35,10 +38,10 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
return (
<div className="sensor-chart">
<h3>Sensor Data</h3>
<h3>{t('sensor.title')}</h3>
<div className="chart-grid">
<div className="chart-item">
<h4>Seismic Activity</h4>
<h4>{t('sensor.seismicActivity')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -52,7 +55,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div>
<div className="chart-item">
<h4>Noise Level (dB)</h4>
<h4>{t('sensor.noiseLevelDb')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -65,7 +68,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div>
<div className="chart-item">
<h4>Air Pressure (hPa)</h4>
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
@ -78,7 +81,7 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div>
<div className="chart-item">
<h4>Radiation (uSv/h)</h4>
<h4>{t('sensor.radiationUsv')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />

파일 보기

@ -1,5 +1,6 @@
import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Ship, ShipCategory } from '../types';
import maplibregl from 'maplibre-gl';
@ -9,17 +10,30 @@ interface Props {
koreanOnly?: boolean;
}
// ── MarineTraffic-style vessel type colors ──
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
const MT_TYPE_COLORS: Record<string, string> = {
cargo: '#f0a830', // orange-yellow
tanker: '#e74c3c', // red
passenger: '#4caf50', // green
fishing: '#42a5f5', // light blue
pleasure: '#e91e8c', // pink/magenta
military: '#d32f2f', // dark red
tug_special: '#2e7d32', // dark green
other: '#5c6bc0', // indigo/blue
unknown: '#9e9e9e', // grey
cargo: 'var(--kcg-ship-cargo)',
tanker: 'var(--kcg-ship-tanker)',
passenger: 'var(--kcg-ship-passenger)',
fishing: 'var(--kcg-ship-fishing)',
pleasure: 'var(--kcg-ship-pleasure)',
military: 'var(--kcg-ship-military)',
tug_special: 'var(--kcg-ship-tug)',
other: 'var(--kcg-ship-other)',
unknown: 'var(--kcg-ship-unknown)',
};
// Resolved hex colors for MapLibre paint (which cannot use CSS vars)
const MT_TYPE_HEX: Record<string, string> = {
cargo: '#f0a830',
tanker: '#e74c3c',
passenger: '#4caf50',
fishing: '#42a5f5',
pleasure: '#e91e8c',
military: '#d32f2f',
tug_special: '#2e7d32',
other: '#5c6bc0',
unknown: '#9e9e9e',
};
// Map our internal ShipCategory + typecode → MT visual type
@ -63,21 +77,6 @@ const NAVY_COLORS: Record<string, string> = {
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> = {
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}',
@ -104,6 +103,10 @@ function getShipColor(ship: Ship): string {
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
}
function getShipHex(ship: Ship): string {
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
}
// ── Local Korean ship photos ──
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'440034000': '/ships/440034000.jpg',
@ -126,30 +129,92 @@ const LOCAL_SHIP_PHOTOS: Record<string, string> = {
interface VesselPhotoData { url: string; }
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 [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;
});
useEffect(() => {
if (localUrl) return;
if (photo !== undefined) return;
if (activeTab !== 'marinetraffic') return;
if (mtPhoto !== undefined) return;
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image();
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); };
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); };
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 (
<div style={{ marginBottom: 6 }}>
<img src={photo.url} alt="Vessel"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="mb-1.5">
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
{hasSignalBatch && (
<div
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('signal-batch')}
>
signal-batch
</div>
)}
<div
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'marinetraffic' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('marinetraffic')}
>
MarineTraffic
</div>
</div>
{currentUrl ? (
<img src={currentUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
activeTab === 'marinetraffic' && mtPhoto === undefined
? <div className="text-center p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
: null
)}
</div>
);
}
@ -215,7 +280,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
type: 'Feature' as const,
properties: {
mmsi: ship.mmsi,
color: getShipColor(ship),
color: getShipHex(ship),
size: SIZE_MAP[ship.category],
isMil: isMilitary(ship.category) ? 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 { t } = useTranslation('ships');
const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
const isMil = isMilitary(ship.category);
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color;
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
return (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
<div
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
>
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
<strong className="text-[13px] flex-1">{ship.name}</strong>
{navyLabel && (
<span style={{
background: navyAccent, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{navyLabel}</span>
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
style={{ background: navyAccent || color }}
>{navyLabel}</span>
)}
</div>
<VesselPhoto mmsi={ship.mmsi} />
<div style={{
display: 'flex', gap: 4, marginBottom: 6,
borderBottom: '1px solid #ddd', paddingBottom: 4,
}}>
<span style={{
background: color, color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span>
<span style={{
background: '#333', color: '#ccc', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{CATEGORY_LABELS[ship.category]}</span>
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
{t(`categoryLabel.${ship.category}`)}
</span>
{ship.typeDesc && (
<span style={{ color: '#666', fontSize: 10, lineHeight: '18px' }}>{ship.typeDesc}</span>
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
)}
</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><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div>
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>}
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>}
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>}
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>}
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>}
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>}
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
</div>
<div>
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div>
<div><span style={{ color: '#888' }}>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>
{ship.destination && <div><span style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>}
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>}
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
<div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
</div>
</div>
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}>
Last Update : {new Date(ship.lastSeen).toLocaleString()}
<div className="mt-1.5 text-[9px] text-[#999] text-right">
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
</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}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
MarineTraffic &rarr;
</a>
</div>

파일 보기

@ -1,4 +1,5 @@
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent } from '../types';
interface Props {
@ -22,24 +23,25 @@ const TYPE_COLORS: Record<string, string> = {
osint: '#06b6d4',
};
const TYPE_LABELS_KO: Record<string, string> = {
airstrike: '공습',
explosion: '폭발',
missile_launch: '미사일 발사',
intercept: '요격',
alert: '경보',
impact: '피격',
osint: 'OSINT',
const TYPE_I18N_KEYS: Record<string, string> = {
airstrike: 'event.airstrike',
explosion: 'event.explosion',
missile_launch: 'event.missileLaunch',
intercept: 'event.intercept',
alert: 'event.alert',
impact: 'event.impact',
osint: 'event.osint',
};
const SOURCE_LABELS_KO: Record<string, string> = {
US: '미국',
IL: '이스라엘',
IR: '이란',
proxy: '대리세력',
const SOURCE_I18N_KEYS: Record<string, string> = {
US: 'source.US',
IL: 'source.IL',
IR: 'source.IR',
proxy: 'source.proxy',
};
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
const { t } = useTranslation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
@ -53,13 +55,13 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
}));
}, [events, startTime, endTime]);
const formatTime = (t: number) => {
const d = new Date(t + KST_OFFSET);
const formatTime = (ts: number) => {
const d = new Date(ts + KST_OFFSET);
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
};
const formatTimeShort = (t: number) => {
const d = new Date(t + KST_OFFSET);
const formatTimeShort = (ts: number) => {
const d = new Date(ts + KST_OFFSET);
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 isPast = ev.timestamp <= currentTime;
const isActive = ev.id === selectedId;
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : '';
const source = sourceKey ? t(sourceKey) : '';
const typeKey = TYPE_I18N_KEYS[ev.type];
const typeLabel = typeKey ? t(typeKey) : ev.type;
return (
<button
@ -137,7 +141,7 @@ export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
style={{ '--card-color': color } as React.CSSProperties}
onClick={() => handleEventCardClick(ev)}
title="클릭하면 지도에서 해당 위치로 이동합니다"
title={t('timeline.flyToTooltip')}
>
<span className="tl-card-dot" />
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>

파일 보기

@ -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">&#x1f6e1;&#xfe0f;</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;

파일 보기

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_SPG_API_KEY?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
}
interface ImportMeta {

파일 보기

@ -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,
};
}

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