diff --git a/.claude/settings.json b/.claude/settings.json index 03c2889..b13b469 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 + } + ] + } + ] } } diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json new file mode 100644 index 0000000..56519c9 --- /dev/null +++ b/.claude/workflow-version.json @@ -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" +} diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..6e0c4af --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 7a28940..b713ed0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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: 모든 검증 통과" diff --git a/.gitignore b/.gitignore index 7cc0e11..83d80c2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,57 @@ coverage/ .prettiercache *.tsbuildinfo -# === Claude Code (개인 설정) === +# === Claude Code === +# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함 +!.claude/ .claude/settings.local.json .claude/CLAUDE.local.md +# Team workflow (managed by /sync-team-workflow) +.claude/rules/ +.claude/agents/ +.claude/skills/push/ +.claude/skills/mr/ +.claude/skills/create-mr/ +.claude/skills/release/ +.claude/skills/version/ +.claude/skills/fix-issue/ +.claude/scripts/ + +# 프로젝트 기존 항목 +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +node_modules +dist +dist-ssr +*.local +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.pdf + +# === Frontend === +frontend/dist/ +frontend/node_modules/ + +# === Backend === +backend/target/ +backend/.env +backend/src/main/resources/application-local.yml +backend/src/main/resources/application-prod.yml + +# === Prediction === +prediction/__pycache__/ +prediction/venv/ +prediction/.env diff --git a/CLAUDE.md b/CLAUDE.md index f10228b..7958058 100644 --- a/CLAUDE.md +++ b/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) diff --git a/README.md b/README.md index d27fafb..d2e7761 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,73 @@ -# kcg-monitoring +# React + TypeScript + Vite -KCG 모니터링 대시보드 \ No newline at end of file +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/backend/.sdkmanrc b/backend/.sdkmanrc new file mode 100644 index 0000000..a4417cd --- /dev/null +++ b/backend/.sdkmanrc @@ -0,0 +1 @@ +java=17.0.18-amzn diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c99e425 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + gc.mda + kcg + 0.0.1-SNAPSHOT + kcg + KCG Monitoring Dashboard Backend + + + 17 + 0.12.6 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + true + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + com.google.api-client + google-api-client + 2.7.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + kcg + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/src/main/java/gc/mda/kcg/KcgApplication.java b/backend/src/main/java/gc/mda/kcg/KcgApplication.java new file mode 100644 index 0000000..8c11f99 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/KcgApplication.java @@ -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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java new file mode 100644 index 0000000..655088c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java @@ -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 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 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 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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java new file mode 100644 index 0000000..d25beb4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthService.java b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java new file mode 100644 index 0000000..876e272 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java @@ -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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java new file mode 100644 index 0000000..3cd15a3 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java new file mode 100644 index 0000000..e7a5901 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java @@ -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; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java new file mode 100644 index 0000000..6239c4b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java @@ -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; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java new file mode 100644 index 0000000..e994de7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java @@ -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(); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/User.java b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java new file mode 100644 index 0000000..d939d15 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java @@ -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(); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java new file mode 100644 index 0000000..b7c3556 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java @@ -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 { +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java new file mode 100644 index 0000000..17d9ae5 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java @@ -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 { + + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java new file mode 100644 index 0000000..83d6cd4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java @@ -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 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 entry : CATEGORY_PATTERNS.entrySet()) { + if (entry.getValue().matcher(text).find()) { + return entry.getKey(); + } + } + + return "general"; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java new file mode 100644 index 0000000..3708228 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +/** + * CENTCOM 보도자료 수집기 + * TODO: CENTCOM 웹사이트에서 보도자료 수집 구현 + */ +@Component +public class CentcomCollector { +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java new file mode 100644 index 0000000..9433654 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +/** + * GDELT 데이터 수집기 + * TODO: GDELT API를 통한 이벤트/뉴스 데이터 수집 구현 + */ +@Component +public class GdeltCollector { +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java new file mode 100644 index 0000000..3327623 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java @@ -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 { +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java new file mode 100644 index 0000000..686b67d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java new file mode 100644 index 0000000..986479e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java @@ -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 authFilterRegistration(AuthFilter authFilter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(authFilter); + registration.addUrlPatterns("/api/*"); + registration.setOrder(1); + return registration; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/WebConfig.java b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java new file mode 100644 index 0000000..f18492d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java @@ -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); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java new file mode 100644 index 0000000..f62a8d0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java @@ -0,0 +1,4 @@ +/** + * 항공기 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.aircraft; diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java new file mode 100644 index 0000000..faa6bf5 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java @@ -0,0 +1,4 @@ +/** + * 이벤트 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.event; diff --git a/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java new file mode 100644 index 0000000..16142ce --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java @@ -0,0 +1,4 @@ +/** + * 뉴스 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.news; diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java new file mode 100644 index 0000000..0a78dff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java @@ -0,0 +1,4 @@ +/** + * OSINT 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.osint; diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example new file mode 100644 index 0000000..0fa5862 --- /dev/null +++ b/backend/src/main/resources/application-local.yml.example @@ -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 diff --git a/backend/src/main/resources/application-prod.yml.example b/backend/src/main/resources/application-prod.yml.example new file mode 100644 index 0000000..71c2848 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml.example @@ -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} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..19fc170 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + default_schema: kcg +server: + port: 8080 diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..f9b5509 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,2 @@ +-- KCG 데이터베이스 초기화 +CREATE SCHEMA IF NOT EXISTS kcg; diff --git a/database/migration/001_initial_schema.sql b/database/migration/001_initial_schema.sql new file mode 100644 index 0000000..3b9eadc --- /dev/null +++ b/database/migration/001_initial_schema.sql @@ -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); diff --git a/deploy/kcg-backend.service b/deploy/kcg-backend.service new file mode 100644 index 0000000..1932377 --- /dev/null +++ b/deploy/kcg-backend.service @@ -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 diff --git a/deploy/nginx-kcg.conf b/deploy/nginx-kcg.conf new file mode 100644 index 0000000..b86d694 --- /dev/null +++ b/deploy/nginx-kcg.conf @@ -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; +} diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md new file mode 100644 index 0000000..0241b25 --- /dev/null +++ b/docs/RELEASE-NOTES.md @@ -0,0 +1,31 @@ +# Release Notes + +이 문서는 [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/) 형식을 따릅니다. + +## [Unreleased] + +## [2026-03-17] + +### 추가 +- 프론트엔드 모노레포 이관 (`frontend/` 폴더 구조) +- signal-batch API 연동 (한국 선박 실시간 위치 데이터) +- Tailwind CSS 4 + CSS 변수 테마 시스템 (dark/light) +- i18next 다국어 지원 (ko/en) — 28개 컴포넌트 적용 +- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 접이식 범례) +- Google OAuth 로그인 + DEV LOGIN 인증 우회 (개발 모드) +- 선박 이미지 탭 전환 UI (signal-batch / MarineTraffic) +- 백엔드 Spring Boot 3.2 스켈레톤 (Java 17) +- Google OAuth + JWT 인증 API (`@gcsc.co.kr` 도메인 제한) +- 데이터 수집기 placeholder (GDELT, Google News, CENTCOM) +- PostgreSQL 스키마 (events, news, osint, users, login_history) +- Python FastAPI 분석서버 placeholder +- Gitea Actions CI/CD 파이프라인 (main merge 시 자동 배포) +- nginx 설정 (SPA + API 프록시 + 외부 API CORS 프록시) +- systemd 서비스 (kcg-backend, JDK 17, 2~4GB 힙) + +### 변경 +- 외부 API 호출 CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak) +- App.css 하드코딩 색상 → CSS 변수 토큰 전환 (테마 반응) +- 선박 분류 체계 AIS shipTy 파싱 개선 +- 한국 선박 데이터 폴링 주기 15초 → 4분 +- 범례 카운트 MT 분류 기준으로 동기화 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..d533149 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,34 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + // React Compiler rules — too strict for ref-in-useMemo patterns, disable for now + 'react-hooks/refs': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/set-state-in-effect': 'off', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bcd194d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + iran-airstrike-replay + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a9a7021 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4915 @@ +{ + "name": "kcg-monitoring", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kcg-monitoring", + "version": "0.0.0", + "dependencies": { + "@rollup/rollup-darwin-arm64": "^4.59.0", + "@tailwindcss/vite": "^4.2.1", + "@types/leaflet": "^1.9.21", + "date-fns": "^4.1.0", + "hls.js": "^1.6.15", + "i18next": "^25.8.18", + "leaflet": "^1.9.4", + "maplibre-gl": "^5.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", + "react-leaflet": "^5.0.0", + "react-map-gl": "^8.1.0", + "recharts": "^3.8.0", + "satellite.js": "^6.0.2", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/geojson-vt/-/geojson-vt-6.0.3.tgz", + "integrity": "sha512-tJ8df2SAIacER7pWTiSlDjIULBBAfZnzAURvWb1d8kVzx/pmSJcG0L2p0DTAB6nEu8Lmsx5zAc8JFDcs2DTwaw==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.7.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.7", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/mlt/-/mlt-1.1.7.tgz", + "integrity": "sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vis.gl/react-mapbox": { + "version": "8.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz", + "integrity": "sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=3.5.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz", + "integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/maplibre-gl": { + "version": "5.20.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/maplibre-gl/-/maplibre-gl-5.20.1.tgz", + "integrity": "sha512-57YIgfRct+rrk78ldoWRuLWRnXV/1vM2Rk0QYfEDQmsXdpgbACwvGoREIOZtyDIaq/GJK/ORYEriaAdVZuNfvw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.0.2", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.7", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-map-gl": { + "version": "8.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-map-gl/-/react-map-gl-8.1.0.tgz", + "integrity": "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==", + "license": "MIT", + "dependencies": { + "@vis.gl/react-mapbox": "8.1.0", + "@vis.gl/react-maplibre": "8.1.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/satellite.js": { + "version": "6.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/satellite.js/-/satellite.js-6.0.2.tgz", + "integrity": "sha512-XWKxtqVF5xiJ1xAeiYeT/oSSzsukoCLWvk6nO/WFy4un0M3g4djAU9TAtOCqJLtYW9vxx9pkPJ1L9ITOc607GA==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..05be12b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "kcg-monitoring", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@rollup/rollup-darwin-arm64": "^4.59.0", + "@tailwindcss/vite": "^4.2.1", + "@types/leaflet": "^1.9.21", + "date-fns": "^4.1.0", + "hls.js": "^1.6.15", + "i18next": "^25.8.18", + "leaflet": "^1.9.4", + "maplibre-gl": "^5.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.8", + "react-leaflet": "^5.0.0", + "react-map-gl": "^8.1.0", + "recharts": "^3.8.0", + "satellite.js": "^6.0.2", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/public/ships/440034000.jpg b/frontend/public/ships/440034000.jpg new file mode 100644 index 0000000..2109235 Binary files /dev/null and b/frontend/public/ships/440034000.jpg differ diff --git a/frontend/public/ships/440150000.jpg b/frontend/public/ships/440150000.jpg new file mode 100644 index 0000000..82e26b6 Binary files /dev/null and b/frontend/public/ships/440150000.jpg differ diff --git a/frontend/public/ships/440272000.jpg b/frontend/public/ships/440272000.jpg new file mode 100644 index 0000000..721fc8a Binary files /dev/null and b/frontend/public/ships/440272000.jpg differ diff --git a/frontend/public/ships/440272000.png b/frontend/public/ships/440272000.png new file mode 100644 index 0000000..6962fc3 Binary files /dev/null and b/frontend/public/ships/440272000.png differ diff --git a/frontend/public/ships/440274000.jpg b/frontend/public/ships/440274000.jpg new file mode 100644 index 0000000..efff7e1 Binary files /dev/null and b/frontend/public/ships/440274000.jpg differ diff --git a/frontend/public/ships/440323000.jpg b/frontend/public/ships/440323000.jpg new file mode 100644 index 0000000..c37047d Binary files /dev/null and b/frontend/public/ships/440323000.jpg differ diff --git a/frontend/public/ships/440384000.jpg b/frontend/public/ships/440384000.jpg new file mode 100644 index 0000000..27e5b28 Binary files /dev/null and b/frontend/public/ships/440384000.jpg differ diff --git a/frontend/public/ships/440880000.jpg b/frontend/public/ships/440880000.jpg new file mode 100644 index 0000000..b0576e4 Binary files /dev/null and b/frontend/public/ships/440880000.jpg differ diff --git a/frontend/public/ships/441046000.jpg b/frontend/public/ships/441046000.jpg new file mode 100644 index 0000000..f8f10bf Binary files /dev/null and b/frontend/public/ships/441046000.jpg differ diff --git a/frontend/public/ships/441345000.jpg b/frontend/public/ships/441345000.jpg new file mode 100644 index 0000000..f8f10bf Binary files /dev/null and b/frontend/public/ships/441345000.jpg differ diff --git a/frontend/public/ships/441345000.png b/frontend/public/ships/441345000.png new file mode 100644 index 0000000..db5c9dc Binary files /dev/null and b/frontend/public/ships/441345000.png differ diff --git a/frontend/public/ships/441353000.jpg b/frontend/public/ships/441353000.jpg new file mode 100644 index 0000000..e164431 Binary files /dev/null and b/frontend/public/ships/441353000.jpg differ diff --git a/frontend/public/ships/441393000.jpg b/frontend/public/ships/441393000.jpg new file mode 100644 index 0000000..2e2d57a Binary files /dev/null and b/frontend/public/ships/441393000.jpg differ diff --git a/frontend/public/ships/441423000.jpg b/frontend/public/ships/441423000.jpg new file mode 100644 index 0000000..24c30ea Binary files /dev/null and b/frontend/public/ships/441423000.jpg differ diff --git a/frontend/public/ships/441548000.jpg b/frontend/public/ships/441548000.jpg new file mode 100644 index 0000000..66e6538 Binary files /dev/null and b/frontend/public/ships/441548000.jpg differ diff --git a/frontend/public/ships/441708000.png b/frontend/public/ships/441708000.png new file mode 100644 index 0000000..aeea3b2 Binary files /dev/null and b/frontend/public/ships/441708000.png differ diff --git a/frontend/public/ships/441866000.jpg b/frontend/public/ships/441866000.jpg new file mode 100644 index 0000000..cc7bdc1 Binary files /dev/null and b/frontend/public/ships/441866000.jpg differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..46461bf --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,1990 @@ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Header */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; +} + +.app-header h1 { + font-size: 14px; + font-weight: 700; + letter-spacing: 1.5px; + font-family: 'Courier New', monospace; +} + +/* Map mode toggle */ +.map-mode-toggle { + display: flex; + gap: 4px; + background: var(--kcg-subtle); + border: 1px solid var(--kcg-border); + border-radius: 6px; + padding: 3px; +} + +.map-mode-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--kcg-dim); + cursor: pointer; + transition: all 0.15s; + font-family: 'Courier New', monospace; +} + +.map-mode-btn:hover { + color: var(--text-primary); + background: var(--kcg-hover); +} + +.map-mode-btn.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(59, 130, 246, 0.12); +} + +.map-mode-btn svg { + flex-shrink: 0; +} + +.header-info { + display: flex; + align-items: center; + gap: 20px; +} + +.header-counts { + display: flex; + gap: 10px; +} + +.count-item { + font-size: 11px; + font-weight: 700; + font-family: 'Courier New', monospace; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 3px; + background: var(--kcg-hover); + border: 1px solid var(--kcg-border); +} + +.ac-count { color: #22d3ee; border-color: rgba(34, 211, 238, 0.3); } +.mil-count { color: #f97316; border-color: rgba(249, 115, 22, 0.3); } +.ship-count { color: #fb923c; border-color: rgba(251, 146, 60, 0.3); } +.sat-count { color: var(--kcg-danger); border-color: rgba(239, 68, 68, 0.3); } + +.header-toggles { + display: flex; + gap: 4px; +} + +.header-toggle-btn { + background: var(--kcg-hover); + border: 1px solid var(--kcg-border-light); + color: var(--kcg-text-secondary); + font-size: 10px; + font-weight: 700; + font-family: 'Courier New', monospace; + letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; + line-height: 1.4; +} + +.header-toggle-btn:hover { + background: var(--kcg-hover-strong); + border-color: var(--kcg-dim); + color: var(--kcg-text); +} + +.header-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--kcg-border-heavy); +} + +.status-dot.live { + background: var(--danger); + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Cheonghae Unit pulsing beacon */ +@keyframes cheonghae-pulse { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(3); opacity: 0; } +} +@keyframes cheonghae-glow { + 0% { opacity: 0.6; } + 100% { opacity: 1; } +} + +/* Planned strike targeting ring */ +@keyframes planned-pulse { + 0%, 100% { opacity: 0.9; transform: translate(-50%, -50%) scale(1); } + 50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.15); } +} + +/* US Navy pulsing beacon (red) */ +@keyframes usnavy-pulse { + 0% { transform: scale(1); opacity: 0.8; } + 100% { transform: scale(2.5); opacity: 0; } +} +@keyframes usnavy-glow { + 0% { opacity: 0.5; } + 100% { opacity: 1; } +} + +/* Main layout */ +.app-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.map-panel { + flex: 1; + position: relative; + min-width: 0; + min-height: 0; +} + +.map-panel .maplibregl-map, +.map-panel .leaflet-container { + background: var(--bg-primary); +} + +/* Map overlay - Layer panel */ +.map-overlay-left { + position: absolute; + top: 10px; + left: 10px; + z-index: 1000; +} + +/* Layer Panel */ +.layer-panel { + background: var(--kcg-glass); + backdrop-filter: blur(8px); + border: 1px solid var(--kcg-border); + border-radius: 8px; + padding: 10px; + min-width: 180px; + box-shadow: var(--kcg-panel-shadow); +} + +.layer-panel h3 { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--text-secondary); + margin-bottom: 8px; + font-family: 'Courier New', monospace; +} + +.layer-items { + display: flex; + flex-direction: column; + gap: 2px; +} + +.layer-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--kcg-dim); + font-size: 11px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: all 0.15s; +} + +.layer-toggle:hover { + background: var(--kcg-hover); +} + +.layer-toggle.active { + color: var(--text-primary); +} + +.layer-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.layer-divider { + height: 1px; + background: var(--kcg-border); + margin: 4px 0; +} + +.layer-stats { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--kcg-border); +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 8px; + font-size: 10px; + font-family: 'Courier New', monospace; +} + +.stat-cat { + color: var(--text-secondary); + letter-spacing: 0.5px; +} + +.stat-count { + color: var(--text-primary); + font-weight: 600; +} + +.stat-header { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-secondary); + padding: 2px 8px; + font-family: 'Courier New', monospace; +} + +/* Layer tree */ +.layer-tree-header { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + transition: background 0.15s; +} +.layer-tree-header:hover { background: var(--kcg-hover); } +.layer-tree-arrow { + font-size: 8px; + width: 12px; + text-align: center; + color: var(--kcg-dim); + transition: transform 0.15s; +} +.layer-tree-arrow.expanded { transform: rotate(90deg); } +.layer-tree-children { + padding-left: 20px; +} +.category-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + font-size: 10px; + cursor: pointer; + border-radius: 3px; + transition: opacity 0.15s; +} +.category-toggle.hidden { opacity: 0.35; } +.category-toggle:hover { background: var(--kcg-hover); } +.category-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.category-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.category-count { margin-left: auto; color: var(--kcg-muted); font-size: 9px; } +.legend-toggle { + font-size: 9px; + color: var(--kcg-dim); + cursor: pointer; + padding: 2px 8px; + margin-top: 2px; + background: none; + border: none; + text-align: left; +} +.legend-toggle:hover { color: var(--kcg-muted); } +.legend-content { + padding: 2px 8px 4px; +} + +/* Side panel */ +.side-panel { + width: 300px; + background: var(--bg-secondary); + border-left: 1px solid var(--kcg-border); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--kcg-panel-shadow); +} + +/* ═══ Dashboard Tabs in Header ═══ */ +.dash-tabs { + display: flex; + gap: 2px; + background: rgba(255,255,255,0.05); + border-radius: 6px; + padding: 2px; +} +.dash-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + color: var(--kcg-dim); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-family: 'Courier New', monospace; + white-space: nowrap; +} +.dash-tab:hover { + color: var(--kcg-muted); + background: rgba(255,255,255,0.05); +} +.dash-tab.active { + color: var(--kcg-text); + background: rgba(239,68,68,0.2); +} +.dash-tab-flag { + font-size: 14px; +} + +/* Event Log */ +.event-log { + display: flex; + flex-direction: column; + height: 100%; + border-left: 1px solid var(--kcg-border); +} + +.event-log h3 { + padding: 10px 14px; + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); + border-bottom: 1px solid var(--kcg-border); + font-family: 'Courier New', monospace; +} + +.event-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.event-empty { + padding: 20px; + text-align: center; + color: var(--text-secondary); + font-size: 12px; +} + +.event-item { + display: flex; + gap: 8px; + padding: 6px; + border-radius: 4px; + margin-bottom: 2px; + transition: background 0.15s; +} + +.event-item:hover { + background: var(--kcg-subtle); +} + +.event-tag { + font-size: 8px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 5px; + border-radius: 2px; + color: var(--kcg-text); + white-space: nowrap; + height: fit-content; + margin-top: 2px; + font-family: 'Courier New', monospace; +} + +.event-content { + flex: 1; + min-width: 0; +} + +.event-label { + font-size: 12px; + font-weight: 500; +} + +.event-time { + font-size: 10px; + color: var(--text-secondary); + margin-top: 1px; + font-family: 'Courier New', monospace; +} + +.event-desc { + font-size: 10px; + color: var(--text-secondary); + margin-top: 2px; + line-height: 1.4; +} + +/* Breaking News Section */ +.breaking-news-section { + flex-shrink: 0; + border-bottom: 1px solid var(--kcg-border); + max-height: 280px; + overflow-y: auto; +} + +.breaking-news-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.08); + border-bottom: 1px solid rgba(239, 68, 68, 0.15); + position: sticky; + top: 0; + z-index: 1; +} + +.breaking-flash { + font-size: 9px; + font-weight: 800; + letter-spacing: 1px; + color: var(--kcg-text); + background: var(--kcg-danger); + padding: 2px 6px; + border-radius: 2px; + font-family: 'Courier New', monospace; + animation: flash-pulse 1.5s ease-in-out infinite; +} + +@keyframes flash-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.breaking-title { + font-size: 10px; + font-weight: 700; + color: var(--text-secondary); + letter-spacing: 0.5px; + font-family: 'Courier New', monospace; +} + +.breaking-news-list { + padding: 4px 8px; +} + +.breaking-news-item { + padding: 6px 8px; + border-radius: 4px; + margin-bottom: 3px; + border-left: 2px solid transparent; + transition: background 0.15s; +} + +.breaking-news-item:hover { + background: var(--kcg-subtle); +} + +.breaking-news-item.breaking-new { + border-left: 2px solid var(--kcg-danger); + background: rgba(239, 68, 68, 0.05); +} + +.breaking-news-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 3px; +} + +.breaking-cat-tag { + font-size: 9px; + font-weight: 700; + color: var(--kcg-text); + padding: 1px 6px; + border-radius: 2px; + white-space: nowrap; +} + +.breaking-news-time { + font-size: 9px; + color: var(--kcg-dim); + font-family: 'Courier New', monospace; +} + +.breaking-news-headline { + font-size: 11px; + font-weight: 600; + color: var(--kcg-text); + line-height: 1.4; +} + +.breaking-news-detail { + font-size: 10px; + color: var(--kcg-muted); + line-height: 1.3; + margin-top: 2px; +} + +/* ═══ OSINT Live Feed ═══ */ +.osint-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--kcg-border); + background: rgba(239, 68, 68, 0.06); + flex-shrink: 0; +} +.osint-live-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--kcg-danger); + animation: osint-pulse 1.5s ease-in-out infinite; +} +@keyframes osint-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0.6); } + 50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(239,68,68,0.3); } +} +.osint-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--kcg-danger); + font-family: 'Courier New', monospace; +} +.osint-count { + margin-left: auto; + font-size: 10px; + color: var(--kcg-muted); + font-family: 'Courier New', monospace; +} +.osint-loading { + margin-left: auto; + font-size: 10px; + color: var(--kcg-dim); + font-style: italic; +} +.osint-list { + flex: 1; + overflow-y: auto; + padding: 4px 6px; +} +.osint-item { + display: block; + padding: 8px 8px 6px; + border-radius: 4px; + margin-bottom: 2px; + border-left: 3px solid transparent; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: background 0.15s; +} +.osint-item:hover { + background: rgba(255,255,255,0.05); +} +.osint-item.osint-recent { + border-left-color: var(--kcg-danger); + background: rgba(239,68,68,0.05); +} +.osint-item-top { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; +} +.osint-cat-tag { + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + border-radius: 3px; + color: var(--kcg-text); + white-space: nowrap; +} +.osint-lang-tag { + font-size: 8px; + font-weight: 700; + padding: 1px 4px; + border-radius: 2px; + background: var(--kcg-accent-hover); + color: var(--kcg-text); +} +.osint-time { + margin-left: auto; + font-size: 9px; + color: var(--kcg-dim); + white-space: nowrap; + font-family: 'Courier New', monospace; +} +.osint-item-title { + font-size: 11px; + font-weight: 600; + color: var(--kcg-text-secondary); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.osint-item:hover .osint-item-title { + color: var(--kcg-text); +} +.osint-item-source { + font-size: 9px; + color: var(--kcg-dim); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Ship Summary (area total + Korean) */ +.kr-ship-summary { + padding: 10px 12px; + background: rgba(59, 130, 246, 0.06); + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; +} + +.area-ship-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid var(--kcg-hover); +} + +.area-ship-icon { + font-size: 14px; +} + +.area-ship-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.area-ship-total { + margin-left: auto; + font-size: 16px; + font-weight: 700; + color: #fb923c; + font-family: 'Courier New', monospace; +} + +.kr-ship-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.kr-flag { + font-size: 16px; +} + +.kr-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + color: var(--text-primary); + font-family: 'Courier New', monospace; +} + +.kr-total { + margin-left: auto; + font-size: 14px; + font-weight: 700; + color: var(--kcg-accent); + font-family: 'Courier New', monospace; +} + +.kr-ship-breakdown { + display: flex; + flex-direction: column; + gap: 1px; + margin-bottom: 8px; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; +} + +.kr-ship-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +.kr-cat-dot { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.kr-cat { + flex: 1; + font-size: 10px; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.kr-count { + font-size: 10px; + font-weight: 700; + color: var(--text-primary); + text-align: right; + font-family: 'Courier New', monospace; +} + +.kr-ship-list { + max-height: 120px; + overflow-y: auto; +} + +.kr-ship-item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + font-size: 9px; + font-family: 'Courier New', monospace; +} + +.kr-ship-name { + color: var(--text-primary); + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kr-ship-type { + color: var(--text-secondary); + font-size: 8px; + flex-shrink: 0; +} + +.kr-ship-speed { + color: #22d3ee; + font-weight: 600; + flex-shrink: 0; + min-width: 32px; + text-align: right; +} + +.kr-ship-speed:not(:empty) { + padding: 1px 4px; + background: rgba(34, 211, 238, 0.1); + border-radius: 2px; +} + +/* ═══ Iran Tab — Military Ship List ═══ */ +.iran-ship-summary { + padding: 10px 12px; + background: rgba(239, 68, 68, 0.04); + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; +} +.iran-mil-header { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding-bottom: 4px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.iran-mil-icon { font-size: 12px; } +.iran-mil-title { + font-size: 10px; + font-weight: 700; + color: var(--kcg-danger); + letter-spacing: 0.5px; +} +.iran-mil-count { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: var(--kcg-danger); +} +.iran-mil-list { + max-height: 200px; + overflow-y: auto; + margin-top: 4px; +} +.iran-mil-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 2px; + font-size: 10px; +} +.iran-mil-item:hover { + background: rgba(255,255,255,0.03); +} +.iran-mil-flag { font-size: 11px; flex-shrink: 0; } +.iran-mil-name { + flex: 1; + color: var(--kcg-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.iran-mil-cat { + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + padding: 1px 4px; + border-radius: 2px; + flex-shrink: 0; +} +.iran-mil-cat.cat-carrier { color: #ff4444; background: rgba(255,68,68,0.15); } +.iran-mil-cat.cat-destroyer { color: #ff8800; background: rgba(255,136,0,0.15); } +.iran-mil-cat.cat-warship { color: #ff6600; background: rgba(255,102,0,0.15); } +.iran-mil-cat.cat-patrol { color: #ffcc00; background: rgba(255,204,0,0.15); } + +/* ═══ Korea Tab ═══ */ +.korea-safety { + padding: 12px; + background: rgba(59, 130, 246, 0.06); + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; +} +.korea-safety-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} +.korea-safety-icon { font-size: 14px; } +.korea-safety-title { + font-size: 11px; + font-weight: 700; + color: var(--kcg-accent); + letter-spacing: 0.5px; +} +.korea-safety-stats { + display: flex; + gap: 8px; +} +.korea-stat-card { + flex: 1; + text-align: center; + padding: 8px 4px; + border-radius: 6px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); +} +.korea-stat-card.total { border-color: rgba(59,130,246,0.3); } +.korea-stat-card.anchored { border-color: rgba(239,68,68,0.3); } +.korea-stat-card.moving { border-color: rgba(34,197,94,0.3); } +.korea-stat-num { + font-size: 20px; + font-weight: 800; + font-family: 'Courier New', monospace; +} +.korea-stat-card.total .korea-stat-num { color: var(--kcg-accent); } +.korea-stat-card.anchored .korea-stat-num { color: var(--kcg-danger); } +.korea-stat-card.moving .korea-stat-num { color: var(--kcg-success); } +.korea-stat-label { + font-size: 9px; + color: var(--kcg-muted); + margin-top: 2px; + font-weight: 600; +} +.korea-breakdown { + padding: 8px 12px; + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; +} +.korea-breakdown-header { + font-size: 10px; + font-weight: 700; + color: var(--kcg-muted); + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 4px; + font-family: 'Courier New', monospace; +} +.korea-ship-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.korea-ship-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + font-size: 10px; + font-weight: 700; + color: var(--kcg-muted); + border-bottom: 1px solid var(--kcg-border); + flex-shrink: 0; + font-family: 'Courier New', monospace; +} +.korea-ship-section-count { + color: var(--kcg-accent); + font-size: 11px; +} +.korea-ship-detail-list { + flex: 1; + overflow-y: auto; + padding: 4px 8px; +} +.korea-ship-card { + padding: 8px; + margin-bottom: 4px; + border-radius: 4px; + border-left: 3px solid transparent; + background: rgba(255,255,255,0.02); + transition: background 0.15s; +} +.korea-ship-card:hover { background: rgba(255,255,255,0.05); } +.korea-ship-card.anchored { border-left-color: var(--kcg-danger); } +.korea-ship-card.moving { border-left-color: var(--kcg-success); } +.korea-ship-card-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 3px; +} +.korea-ship-card-name { + font-size: 11px; + font-weight: 700; + color: var(--kcg-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.korea-ship-status { + font-size: 8px; + font-weight: 800; + padding: 1px 5px; + border-radius: 3px; + flex-shrink: 0; + margin-left: 6px; +} +.korea-ship-status.stat-anchored { + color: var(--kcg-danger); + background: rgba(239,68,68,0.15); +} +.korea-ship-status.stat-moving { + color: var(--kcg-success); + background: rgba(34,197,94,0.15); +} +.korea-ship-card-info { + display: flex; + gap: 8px; + font-size: 9px; + align-items: center; +} +.korea-ship-card-type { font-weight: 600; } +.korea-ship-card-code { color: var(--kcg-dim); } +.korea-ship-card-speed { + margin-left: auto; + color: var(--kcg-muted); + font-family: 'Courier New', monospace; +} +.korea-ship-card-dest { + font-size: 9px; + color: var(--kcg-dim); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Charts Panel */ +.charts-panel { + background: var(--bg-secondary); + border-top: 1px solid var(--kcg-border); + flex-shrink: 0; + height: 110px; + overflow: hidden; + box-shadow: var(--kcg-panel-shadow); +} + +.sensor-chart h3 { + padding: 3px 16px 0; + font-size: 9px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.chart-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 2px 12px 4px; +} + +.chart-item h4 { + font-size: 8px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0; + padding-left: 4px; + font-family: 'Courier New', monospace; +} + +/* Footer / Controls */ +.app-footer { + background: var(--bg-card); + border-top: 1px solid var(--kcg-border); + padding: 4px 20px 6px; + flex-shrink: 0; + box-shadow: var(--kcg-panel-shadow); +} + +/* Replay Controls */ +.replay-controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 3px; +} + +.ctrl-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--kcg-border-light); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s; +} + +.ctrl-btn:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.1); +} + +.play-btn { + width: 34px; + height: 34px; + border-color: var(--accent); + background: rgba(59, 130, 246, 0.15); +} + +.play-btn:hover { + background: rgba(59, 130, 246, 0.3); +} + +.speed-controls { + display: flex; + gap: 3px; + margin-left: 8px; +} + +.speed-btn { + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + border-radius: 3px; + border: 1px solid var(--kcg-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + font-family: 'Courier New', monospace; +} + +.speed-btn:hover { + border-color: var(--kcg-border-heavy); + color: var(--text-primary); +} + +.speed-btn.active { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.15); + color: var(--accent); +} + +/* Range Controls */ +.range-controls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + position: relative; +} + +.range-presets { + display: flex; + gap: 3px; + align-items: center; +} + +.range-btn { + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + border-radius: 3px; + border: 1px solid var(--kcg-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + font-family: 'Courier New', monospace; +} + +.range-btn:hover { + border-color: var(--kcg-border-heavy); + color: var(--text-primary); +} + +.range-btn.active { + border-color: var(--kcg-success); + background: rgba(34, 197, 94, 0.15); + color: var(--kcg-success); +} + +.custom-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 3px 6px; +} + +/* Range Picker Dropdown */ +.range-picker { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 6px; + background: var(--kcg-glass-dense); + border: 1px solid var(--kcg-border); + border-radius: 6px; + padding: 10px 12px; + z-index: 100; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); +} + +.range-picker-row { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.range-picker label { + display: flex; + flex-direction: column; + gap: 3px; +} + +.range-picker label span { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.range-picker input[type="datetime-local"] { + background: var(--bg-secondary); + border: 1px solid var(--kcg-border-light); + border-radius: 4px; + color: var(--text-primary); + padding: 4px 8px; + font-size: 11px; + font-family: 'Courier New', monospace; + outline: none; + transition: border-color 0.15s; +} + +.range-picker input[type="datetime-local"]:focus { + border-color: var(--accent); +} + +.range-picker input[type="datetime-local"]::-webkit-calendar-picker-indicator { + filter: invert(0.7); + cursor: pointer; +} + +.range-apply-btn { + padding: 5px 14px; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + border-radius: 4px; + border: 1px solid var(--kcg-success); + background: rgba(34, 197, 94, 0.15); + color: var(--kcg-success); + cursor: pointer; + font-family: 'Courier New', monospace; + transition: all 0.15s; + white-space: nowrap; +} + +.range-apply-btn:hover { + background: rgba(34, 197, 94, 0.3); +} + +/* Timeline Slider */ +.timeline-slider { + width: 100%; +} + +.timeline-labels { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 2px; + font-family: 'Courier New', monospace; +} + +.timeline-current { + font-weight: 700; + color: var(--text-primary); + font-size: 12px; +} + +.timeline-track { + position: relative; + height: 18px; + background: var(--kcg-card); + border-radius: 3px; + cursor: pointer; + overflow: visible; +} + +.timeline-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, rgba(59, 130, 246, 0.3), rgba(59, 130, 246, 0.1)); + border-radius: 3px 0 0 3px; + pointer-events: none; +} + +.timeline-playhead { + position: absolute; + top: -2px; + width: 2px; + height: 22px; + background: var(--accent); + border-radius: 1px; + transform: translateX(-1px); + pointer-events: none; + box-shadow: 0 0 8px rgba(59, 130, 246, 0.5); +} + +/* Timeline event markers — clickable slots */ +.tl-marker { + position: absolute; + top: 1px; + width: 5px; + height: 16px; + border-radius: 1px; + background: var(--marker-color); + opacity: 0.5; + transform: translateX(-2px); + cursor: pointer; + transition: all 0.12s ease; + z-index: 2; +} + +.tl-marker:hover { + opacity: 1; + width: 7px; + transform: translateX(-3px); + box-shadow: 0 0 8px var(--marker-color); +} + +.tl-marker.selected { + opacity: 1; + width: 7px; + transform: translateX(-3px); + box-shadow: 0 0 10px var(--marker-color); + border: 1px solid var(--kcg-text); +} + +.tl-marker.in-cluster { + opacity: 0.8; +} + +/* Event detail strip below timeline */ +.tl-detail-strip { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 5px; + max-height: 54px; + overflow-y: auto; + padding: 2px 0; + animation: tl-strip-in 0.15s ease-out; +} + +@keyframes tl-strip-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.tl-event-card { + display: flex; + align-items: center; + gap: 5px; + background: var(--kcg-subtle); + border: 1px solid var(--kcg-border); + border-radius: 4px; + padding: 3px 8px 3px 6px; + cursor: pointer; + transition: all 0.12s ease; + text-align: left; +} + +.tl-event-card:hover { + background: var(--kcg-hover-strong); + border-color: var(--card-color); +} + +.tl-event-card.active { + background: var(--kcg-hover); + border-color: var(--card-color); +} + +.tl-event-card.future { + opacity: 0.3; +} + +.tl-card-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--card-color); + flex-shrink: 0; + box-shadow: 0 0 5px var(--card-color); +} + +.tl-event-card.future .tl-card-dot { + box-shadow: none; + opacity: 0.4; +} + +.tl-card-time { + font-size: 9px; + font-weight: 700; + color: var(--kcg-dim); + font-family: 'Courier New', monospace; + flex-shrink: 0; +} + +.tl-card-source { + font-size: 7px; + font-weight: 700; + color: var(--kcg-text); + padding: 0 4px; + border-radius: 2px; + flex-shrink: 0; + line-height: 1.5; +} + +.tl-card-name { + font-size: 9px; + font-weight: 600; + color: var(--kcg-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +.tl-card-type { + font-size: 8px; + color: var(--kcg-border-heavy); + flex-shrink: 0; +} + +.tl-card-goto { + color: var(--kcg-border-heavy); + flex-shrink: 0; + transition: color 0.1s; +} + +.tl-event-card:hover .tl-card-goto { + color: var(--card-color); +} + +.stat-header { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-secondary); + margin-bottom: 2px; + padding-left: 8px; + font-family: 'Courier New', monospace; +} + +/* Aircraft, Satellite, Ship & Oil Tooltips - override Leaflet */ +.aircraft-tooltip, +.satellite-tooltip, +.ship-tooltip, +.oil-tooltip, +.airport-tooltip { + background: var(--kcg-glass) !important; + border: 1px solid var(--kcg-border) !important; + border-radius: 3px !important; + padding: 2px 6px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important; + font-family: 'Courier New', monospace !important; +} + +.aircraft-tooltip::before, +.satellite-tooltip::before, +.ship-tooltip::before, +.oil-tooltip::before, +.airport-tooltip::before { + border-right-color: var(--kcg-glass) !important; +} + +/* Aircraft, Satellite, Ship, Impact & Facility icon containers */ +.aircraft-icon, +.satellite-icon, +.ship-icon, +.impact-icon, +.airport-icon, +.refinery-icon, +.oilfield-icon, +.gasfield-icon, +.terminal-icon, +.petrochem-icon { + background: none !important; + border: none !important; +} + +/* 새로 발생한 이벤트 플래시 효과 — lightweight (no filter) */ +.event-flash { + animation: event-glow 1s ease-in-out infinite alternate; +} + +@keyframes event-glow { + 0% { opacity: 0.7; } + 100% { opacity: 1; } +} + +/* 확장 펄스 링 — lightweight opacity only */ +.event-pulse-ring { + animation: pulse-expand 1.5s ease-out infinite; +} + +@keyframes pulse-expand { + 0% { opacity: 0.6; } + 100% { opacity: 0; } +} + +/* 공격 충격파 — 큰 확장 링 */ +.event-shockwave { + animation: shockwave-expand 2s ease-out infinite; +} + +@keyframes shockwave-expand { + 0% { opacity: 0.8; stroke-width: 3; } + 50% { opacity: 0.3; } + 100% { opacity: 0; } +} + +/* 공격 내부 플래시 — 밝은 깜빡임 */ +.event-strike-flash { + animation: strike-flash 0.8s ease-in-out infinite alternate; +} + +@keyframes strike-flash { + 0% { opacity: 0.4; } + 100% { opacity: 1; } +} + +/* Impact site tooltip */ +.impact-tooltip { + background: rgba(40, 0, 0, 0.9) !important; + border: 1px solid var(--kcg-event-impact) !important; + border-radius: 3px !important; + padding: 2px 6px !important; + box-shadow: 0 2px 12px rgba(255, 0, 0, 0.4) !important; + font-family: 'Courier New', monospace !important; +} + +.impact-tooltip::before { + border-right-color: rgba(40, 0, 0, 0.9) !important; +} + +/* MapLibre GL popup override */ +.gl-popup .maplibregl-popup-content, +.event-popup .maplibregl-popup-content { + background: var(--kcg-glass-dense) !important; + color: var(--kcg-text) !important; + border: 1px solid var(--kcg-border-light) !important; + border-radius: 6px !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important; + padding: 10px !important; +} + +.gl-popup .maplibregl-popup-tip, +.event-popup .maplibregl-popup-tip { + border-top-color: var(--kcg-glass-dense) !important; +} + +.gl-popup .maplibregl-popup-close-button, +.event-popup .maplibregl-popup-close-button { + color: var(--kcg-muted) !important; + font-size: 18px; + right: 4px; + top: 2px; +} + +/* Override default white popup background globally */ +.maplibregl-popup-content { + background: var(--kcg-glass-dense) !important; + color: var(--kcg-text) !important; + border: 1px solid var(--kcg-border-light) !important; + border-radius: 6px !important; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important; +} +.maplibregl-popup-tip { + border-top-color: var(--kcg-glass-dense) !important; +} +.maplibregl-popup-close-button { + color: var(--kcg-muted) !important; +} + +/* GL marker labels (replaces Leaflet tooltips) */ +.gl-marker-label { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 6px; + white-space: nowrap; + pointer-events: none; + font-weight: 600; + font-size: 11px; + font-family: 'Courier New', monospace; + text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6); + background: var(--kcg-glass); + border: 1px solid var(--kcg-border); + border-radius: 3px; + padding: 1px 5px; +} + +.gl-impact-label { + position: absolute; + left: 100%; + top: -8px; + margin-left: 8px; + white-space: nowrap; + pointer-events: none; + color: var(--kcg-event-impact); + font-weight: 700; + font-size: 10px; + font-family: 'Courier New', monospace; + text-shadow: 0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.7); + background: rgba(40, 0, 0, 0.85); + border: 1px solid var(--kcg-event-impact); + border-radius: 3px; + padding: 1px 5px; +} + +.gl-new-badge { + background: var(--kcg-event-impact); + color: var(--kcg-text); + padding: 0 3px; + border-radius: 2px; + font-size: 8px; + margin-right: 4px; + animation: event-glow 0.8s ease-in-out infinite alternate; +} + +/* GL animation classes */ +.gl-pulse-ring { + animation: pulse-expand 1.5s ease-out infinite; +} + +.gl-shockwave { + animation: shockwave-expand 2s ease-out infinite; +} + +.gl-strike-flash { + animation: strike-flash 0.8s ease-in-out infinite alternate; +} + +.gl-event-flash { + animation: event-glow 1s ease-in-out infinite alternate; +} + +/* Leaflet popup override (kept for globe mode fallback) */ +.leaflet-popup-content-wrapper { + background: var(--kcg-glass-dense) !important; + color: var(--text-primary) !important; + border: 1px solid var(--kcg-border) !important; + border-radius: 6px !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important; +} + +.leaflet-popup-tip { + background: var(--kcg-glass-dense) !important; + border: 1px solid var(--kcg-border) !important; +} + +.leaflet-popup-close-button { + color: var(--kcg-muted) !important; +} + +/* ======================== */ +/* Mode Toggle (LIVE/REPLAY) */ +/* ======================== */ +.mode-toggle { + display: flex; + gap: 4px; + background: var(--kcg-subtle); + border: 1px solid var(--kcg-border); + border-radius: 6px; + padding: 3px; +} + +.mode-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 14px; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--kcg-dim); + cursor: pointer; + transition: all 0.15s; + font-family: 'Courier New', monospace; +} + +.mode-btn:hover { + color: var(--text-primary); + background: var(--kcg-hover); +} + +.mode-btn.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(59, 130, 246, 0.12); +} + +.mode-btn.active.live { + color: var(--kcg-danger); + border-color: var(--kcg-danger); + background: rgba(239, 68, 68, 0.12); +} + +.mode-dot-icon { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.mode-btn.active.live .mode-dot-icon { + animation: pulse 1s infinite; +} + +/* ======================== */ +/* Live Controls (footer) */ +/* ======================== */ +.live-controls { + display: flex; + align-items: center; + gap: 16px; + padding: 4px 0; +} + +.live-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.live-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--kcg-danger); + animation: pulse 1s infinite; + box-shadow: 0 0 8px rgba(239, 68, 68, 0.6); +} + +.live-label { + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + color: var(--kcg-danger); + font-family: 'Courier New', monospace; +} + +.live-clock { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + font-family: 'Courier New', monospace; + letter-spacing: 1px; + padding: 4px 12px; + background: var(--kcg-hover); + border: 1px solid var(--kcg-border); + border-radius: 4px; +} + +.history-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.history-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 1.5px; + color: var(--text-secondary); + font-family: 'Courier New', monospace; +} + +.history-presets { + display: flex; + gap: 3px; +} + +.history-btn { + padding: 3px 8px; + font-size: 10px; + font-weight: 700; + border-radius: 3px; + border: 1px solid var(--kcg-border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + font-family: 'Courier New', monospace; +} + +.history-btn:hover { + border-color: var(--kcg-border-heavy); + color: var(--text-primary); +} + +.history-btn.active { + border-color: var(--kcg-danger); + background: rgba(239, 68, 68, 0.15); + color: var(--kcg-danger); +} + +/* Live mode accent on header border */ +.app-live .app-header { + border-bottom-color: rgba(239, 68, 68, 0.3); +} + +.app-live .app-footer { + border-top-color: rgba(239, 68, 68, 0.3); +} + + +/* Zoom animation — only during active zoom, not always */ +.leaflet-zoom-anim .leaflet-zoom-animated { + will-change: transform; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--kcg-border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--kcg-border-heavy); +} + +/* ═══ News Ticker (OSINT) ═══ */ +.news-ticker { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + height: 24px; + overflow: hidden; +} + +.news-ticker-label { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + color: var(--kcg-info); + background: rgba(6, 182, 212, 0.15); + padding: 2px 6px; + border-radius: 3px; + letter-spacing: 0.5px; + animation: ticker-pulse 2s ease-in-out infinite; +} + +@keyframes ticker-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.news-ticker-track { + flex: 1; + overflow: hidden; + position: relative; + mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent); +} + +.news-ticker-scroll { + display: flex; + gap: 24px; + animation: ticker-scroll 60s linear infinite; + white-space: nowrap; + width: max-content; +} + +.news-ticker-scroll:hover { + animation-play-state: paused; +} + +@keyframes ticker-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +.news-ticker-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--kcg-text-secondary); + text-decoration: none; + cursor: pointer; + padding: 1px 0; + transition: color 0.15s; +} + +.news-ticker-item:hover { + color: var(--kcg-text); +} + +.news-ticker-cat { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + flex-shrink: 0; +} + +.news-ticker-title { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.news-ticker-time { + font-size: 9px; + color: var(--kcg-dim); + flex-shrink: 0; +} + +.news-ticker-item + .news-ticker-item::before { + content: '·'; + color: var(--kcg-border-light); + margin-right: 6px; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7249338 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1203 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { ReplayMap } from './components/ReplayMap'; +import type { FlyToTarget } from './components/ReplayMap'; +import { GlobeMap } from './components/GlobeMap'; +import { SatelliteMap } from './components/SatelliteMap'; +import { KoreaMap } from './components/KoreaMap'; +import { TimelineSlider } from './components/TimelineSlider'; +import { ReplayControls } from './components/ReplayControls'; +import { LiveControls } from './components/LiveControls'; +import { SensorChart } from './components/SensorChart'; +import { EventLog } from './components/EventLog'; +import { LayerPanel } from './components/LayerPanel'; +import { useReplay } from './hooks/useReplay'; +import { useMonitor } from './hooks/useMonitor'; +import { fetchEvents, fetchSensorData } from './services/api'; +import { fetchAircraftOpenSky } from './services/opensky'; +import { fetchMilitaryAircraft, fetchAllAircraftLive, fetchMilitaryAircraftKorea, fetchAllAircraftLiveKorea } from './services/airplaneslive'; +import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak'; +import { fetchAircraftOpenSkyKorea } from './services/opensky'; +import { fetchShips, fetchShipsKorea } from './services/ships'; +import { fetchOsintFeed } from './services/osint'; +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 +// Maps S&P STAT5CODE prefixes and our custom typecodes to MT categories +function getMarineTrafficCategory(typecode?: string, category?: string): string { + if (!typecode) { + // Fallback to our internal category + if (category === 'tanker') return 'tanker'; + if (category === 'cargo') return 'cargo'; + if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military'; + return 'unspecified'; + } + const code = typecode.toUpperCase(); + + // Our custom typecodes + if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker'; + if (code === 'CONT' || code === 'BULK') return 'cargo'; + if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military'; + + // S&P STAT5CODE (IHS StatCode5) — first 2 chars determine main category + // A1x = Tankers (crude, products, chemical, LPG, LNG) + if (code.startsWith('A1')) return 'tanker'; + // A2x = Bulk carriers + if (code.startsWith('A2')) return 'cargo'; + // A3x = General cargo / Container / Reefer / Ro-Ro + if (code.startsWith('A3')) return 'cargo'; + // B1x / B2x = Passenger / Cruise / Ferry + if (code.startsWith('B')) return 'passenger'; + // C1x = Fishing + if (code.startsWith('C')) return 'fishing'; + // D1x = Offshore (tugs, supply, etc.) + if (code.startsWith('D')) return 'tug_special'; + // E = Other activities (research, cable layers, dredgers) + if (code.startsWith('E')) return 'tug_special'; + // X = Non-propelled (barges) + if (code.startsWith('X')) return 'unspecified'; + + // S&P VesselType strings + const lower = code.toLowerCase(); + if (lower.includes('tanker')) return 'tanker'; + if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk')) return 'cargo'; + if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'passenger'; + if (lower.includes('fishing')) return 'fishing'; + if (lower.includes('tug') || lower.includes('supply') || lower.includes('offshore')) return 'tug_special'; + if (lower.includes('high speed')) return 'high_speed'; + if (lower.includes('pleasure') || lower.includes('yacht') || lower.includes('sailing')) return 'pleasure'; + + return 'unspecified'; +} + +function App() { + const { user, isLoading: authLoading, isAuthenticated, login, devLogin, logout } = useAuth(); + + if (authLoading) { + return ( +
+ Loading... +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} + +interface AuthenticatedAppProps { + user: { email: string; name: string; picture?: string } | null; + onLogout: () => Promise; +} + +function AuthenticatedApp(_props: AuthenticatedAppProps) { + const [appMode, setAppMode] = useState('live'); + const [events, setEvents] = useState([]); + const [sensorData, setSensorData] = useState([]); + const [baseAircraft, setBaseAircraft] = useState([]); + const [baseShips, setBaseShips] = useState([]); + const [baseShipsKorea, setBaseShipsKorea] = useState([]); + const [baseAircraftKorea, setBaseAircraftKorea] = useState([]); + const [satellitesKorea, setSatellitesKorea] = useState([]); + const [satPositionsKorea, setSatPositionsKorea] = useState([]); + const [osintFeed, setOsintFeed] = useState([]); + const [satellites, setSatellites] = useState([]); + const [satPositions, setSatPositions] = useState([]); + const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); + const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); + const [layers, setLayers] = useState({ + events: true, + aircraft: true, + satellites: true, + ships: true, + koreanShips: false, + airports: true, + sensorCharts: true, + oilFacilities: true, + militaryOnly: false, + }); + + // Korea tab layer visibility (lifted from KoreaMap) + const [koreaLayers, setKoreaLayers] = useState>({ + 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>(new Set()); + const [hiddenShipCategories, setHiddenShipCategories] = useState>(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(null); + + // 1시간마다 전체 데이터 강제 리프레시 (호르무즈 해협 실시간 정보 업데이트) + const [refreshKey, setRefreshKey] = useState(0); + useEffect(() => { + const HOUR_MS = 3600_000; + const interval = setInterval(() => { + console.log('[REFRESH] 1시간 주기 전체 데이터 갱신'); + setRefreshKey(k => k + 1); + }, HOUR_MS); + return () => clearInterval(interval); + }, []); + + // Korea monitoring filters (independent toggles) + const [koreaFilters, setKoreaFilters] = useState({ + illegalFishing: false, + illegalTransship: false, + darkVessel: false, + cableWatch: false, + dokdoWatch: false, + ferryWatch: false, + }); + + 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'; + + // Unified time values based on current mode + const currentTime = appMode === 'live' ? monitor.state.currentTime : replay.state.currentTime; + const startTime = appMode === 'live' ? monitor.startTime : replay.state.startTime; + const endTime = appMode === 'live' ? monitor.endTime : replay.state.endTime; + + // Load initial data — each source independently to avoid one failure blocking all + useEffect(() => { + fetchEvents().then(setEvents).catch(() => {}); + fetchSensorData().then(setSensorData).catch(() => {}); + fetchSatelliteTLE().then(setSatellites).catch(() => {}); + }, [refreshKey]); + + // Fetch base aircraft data + // LIVE: OpenSky (민간기) + Airplanes.live (모든 항공기 + 군용기) → 실시간 병합 + // REPLAY: OpenSky (샘플 폴백) + military → 리플레이 + useEffect(() => { + const load = async () => { + if (appMode === 'live') { + // 라이브: 3개 소스 동시 가져오기 — OpenSky + Airplanes.live + Military + const [opensky, allLive, mil] = await Promise.all([ + fetchAircraftOpenSky().catch(() => [] as Aircraft[]), + fetchAllAircraftLive().catch(() => [] as Aircraft[]), + fetchMilitaryAircraft().catch(() => [] as Aircraft[]), + ]); + + // 1) Airplanes.live 기본 + mil 카테고리 보강 + const milMap = new Map(mil.map(a => [a.icao24, a])); + const merged = new Map(); + + for (const ac of allLive) { + const milAc = milMap.get(ac.icao24); + if (milAc) { + merged.set(ac.icao24, { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode }); + } else { + merged.set(ac.icao24, ac); + } + } + + // 2) mil에만 있는 항공기 추가 + for (const m of mil) { + if (!merged.has(m.icao24)) merged.set(m.icao24, m); + } + + // 3) OpenSky 데이터 추가 (Airplanes.live에 없는 항공기만) + for (const ac of opensky) { + if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); + } + + const result = Array.from(merged.values()); + if (result.length > 0) setBaseAircraft(result); + } else { + // 리플레이: 기존 로직 (OpenSky 샘플 + military) + const [opensky, mil] = await Promise.all([ + fetchAircraftOpenSky(), + fetchMilitaryAircraft(), + ]); + const milIcaos = new Set(mil.map(a => a.icao24)); + const merged = [...mil, ...opensky.filter(a => !milIcaos.has(a.icao24))]; + setBaseAircraft(merged); + } + }; + load(); + const interval = setInterval(load, 15_000); + return () => clearInterval(interval); + }, [appMode, refreshKey]); + + // Fetch base ship data — never overwrite with empty to prevent flicker + useEffect(() => { + const load = async () => { + try { + const data = await fetchShips(); + if (data.length > 0) { + setBaseShips(data); + } + } catch { + // keep previous data + } + }; + load(); + const interval = setInterval(load, 15_000); + return () => clearInterval(interval); + }, [appMode, refreshKey]); + + // Fetch Korea region ship data (signal-batch, 4-min cycle) + useEffect(() => { + const load = async () => { + try { + const data = await fetchShipsKorea(); + if (data.length > 0) setBaseShipsKorea(data); + } catch { /* keep previous */ } + }; + load(); + const interval = setInterval(load, 240_000); + return () => clearInterval(interval); + }, [appMode, refreshKey]); + + // Fetch Korea satellite TLE data + useEffect(() => { + fetchSatelliteTLEKorea().then(setSatellitesKorea).catch(() => {}); + }, [refreshKey]); + + // Fetch Korea aircraft data + useEffect(() => { + const load = async () => { + const [opensky, allLive, mil] = await Promise.all([ + fetchAircraftOpenSkyKorea().catch(() => [] as Aircraft[]), + fetchAllAircraftLiveKorea().catch(() => [] as Aircraft[]), + fetchMilitaryAircraftKorea().catch(() => [] as Aircraft[]), + ]); + const milMap = new Map(mil.map(a => [a.icao24, a])); + const merged = new Map(); + for (const ac of allLive) { + const milAc = milMap.get(ac.icao24); + merged.set(ac.icao24, milAc ? { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode } : ac); + } + for (const m of mil) { if (!merged.has(m.icao24)) merged.set(m.icao24, m); } + for (const ac of opensky) { if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); } + const result = Array.from(merged.values()); + if (result.length > 0) setBaseAircraftKorea(result); + }; + load(); + const interval = setInterval(load, 25_000); + return () => clearInterval(interval); + }, [refreshKey]); + + // Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab) + useEffect(() => { + const shouldFetch = isLive || dashboardTab === 'iran'; + if (!shouldFetch) { setOsintFeed([]); return; } + const load = async () => { + try { + const data = await fetchOsintFeed(dashboardTab); + if (data.length > 0) setOsintFeed(data); + } catch { /* keep previous */ } + }; + setOsintFeed([]); // clear while loading new focus + load(); + const interval = setInterval(load, 120_000); + return () => clearInterval(interval); + }, [isLive, dashboardTab, refreshKey]); + + // OSINT → GeoEvent 변환: 피격/군사 정보를 타임라인 이벤트로 반영 + const osintEvents = useMemo((): GeoEvent[] => { + if (dashboardTab !== 'iran' || osintFeed.length === 0) return []; + + // OSINT 카테고리 → GeoEvent 타입 매핑 (피격 정보 기본) + const categoryToType: Record = { + military: 'osint', + shipping: 'osint', + oil: 'osint', + nuclear: 'osint', + diplomacy: 'osint', + }; + + // 피격/공습 키워드 → 구체적 이벤트 타입 분류 + const STRIKE_PATTERN = /strike|attack|bomb|airstrike|hit|destroy|blast|공습|타격|폭격|파괴|피격/i; + const MISSILE_PATTERN = /missile|launch|drone|발사|미사일|드론/i; + const EXPLOSION_PATTERN = /explo|blast|deton|fire|폭발|화재|폭파/i; + const INTERCEPT_PATTERN = /intercept|shoot.*down|defense|요격|격추|방어/i; + const IMPACT_PATTERN = /impact|hit|struck|damage|casualt|피격|타격|피해|사상/i; + + return osintFeed + .filter(item => { + // lat/lng가 있는 항목만 타임라인에 반영 + if (!item.lat || !item.lng) return false; + // 관련 카테고리만 + return item.category in categoryToType; + }) + .map((item): GeoEvent => { + // 피격 키워드 기반으로 구체적 이벤트 타입 분류 + let eventType: GeoEvent['type'] = 'osint'; + const title = item.title; + if (IMPACT_PATTERN.test(title)) eventType = 'impact'; + else if (STRIKE_PATTERN.test(title)) eventType = 'airstrike'; + else if (MISSILE_PATTERN.test(title)) eventType = 'missile_launch'; + else if (EXPLOSION_PATTERN.test(title)) eventType = 'explosion'; + else if (INTERCEPT_PATTERN.test(title)) eventType = 'intercept'; + + // 소스 추정 + let source: GeoEvent['source'] | undefined; + if (/US|미국|America|Pentagon|CENTCOM/i.test(title)) source = 'US'; + else if (/Israel|이스라엘|IAF|IDF/i.test(title)) source = 'IL'; + else if (/Iran|이란|IRGC/i.test(title)) source = 'IR'; + else if (/Houthi|후티|Hezbollah|헤즈볼라|PMF|proxy|대리/i.test(title)) source = 'proxy'; + + return { + id: `osint-${item.id}`, + timestamp: item.timestamp, + lat: item.lat!, + lng: item.lng!, + type: eventType, + source, + label: `[OSINT] ${item.title}`, + description: `출처: ${item.source} | ${item.url}`, + intensity: eventType === 'impact' ? 80 : eventType === 'airstrike' ? 70 : 50, + }; + }); + }, [osintFeed, dashboardTab]); + + // 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬) + const mergedEvents = useMemo(() => { + if (osintEvents.length === 0) return events; + return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp); + }, [events, osintEvents]); + + // Propagate satellite positions — throttle to every 2s of real time + const satTimeRef = useRef(0); + useEffect(() => { + if (satellites.length === 0) return; + const now = Date.now(); + if (now - satTimeRef.current < 2000) return; + satTimeRef.current = now; + const positions = propagateAll(satellites, new Date(currentTime)); + setSatPositions(positions); + }, [satellites, currentTime]); + + // Propagate Korea satellite positions + const satTimeKoreaRef = useRef(0); + useEffect(() => { + if (satellitesKorea.length === 0) return; + const now = Date.now(); + if (now - satTimeKoreaRef.current < 2000) return; + satTimeKoreaRef.current = now; + const positions = propagateAll(satellitesKorea, new Date(currentTime)); + setSatPositionsKorea(positions); + }, [satellitesKorea, currentTime]); + + // Propagate Korea aircraft (live only — no waypoint propagation needed) + const aircraftKorea = useMemo(() => baseAircraftKorea, [baseAircraftKorea]); + + // Propagate aircraft positions based on current time + const aircraft = useMemo( + () => propagateAircraft(baseAircraft, currentTime), + [baseAircraft, currentTime], + ); + + // Propagate ship positions based on current time + const ships = useMemo( + () => propagateShips(baseShips, currentTime, isLive), + [baseShips, currentTime, isLive], + ); + + // Korea region ships (separate data) + const koreaShips = useMemo( + () => propagateShips(baseShipsKorea, currentTime, isLive), + [baseShipsKorea, currentTime, isLive], + ); + + // Category-filtered data for map rendering + const visibleAircraft = useMemo( + () => aircraft.filter(a => !hiddenAcCategories.has(a.category)), + [aircraft, hiddenAcCategories], + ); + const visibleShips = useMemo( + () => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))), + [ships, hiddenShipCategories], + ); + const visibleAircraftKorea = useMemo( + () => aircraftKorea.filter(a => !hiddenAcCategories.has(a.category)), + [aircraftKorea, hiddenAcCategories], + ); + const visibleKoreaShips = useMemo( + () => koreaShips.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))), + [koreaShips, hiddenShipCategories], + ); + + const toggleLayer = useCallback((key: keyof LayerVisibility) => { + setLayers(prev => ({ ...prev, [key]: !prev[key] })); + }, []); + + // Handle event card click from timeline: fly to location on map + const handleEventFlyTo = useCallback((event: GeoEvent) => { + setFlyToTarget({ lat: event.lat, lng: event.lng, zoom: 8 }); + }, []); + + // Aircraft stats + const aircraftByCategory = useMemo(() => { + const counts: Record = {}; + for (const ac of aircraft) { + counts[ac.category] = (counts[ac.category] || 0) + 1; + } + return counts; + }, [aircraft]); + + const militaryCount = useMemo( + () => 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 — MT classification (matches map icon colors) + const shipsByCategory = useMemo(() => { + const counts: Record = {}; + for (const s of ships) { + const mtCat = getMarineTrafficCategory(s.typecode, s.category); + counts[mtCat] = (counts[mtCat] || 0) + 1; + } + return counts; + }, [ships]); + + // Korean ship stats — MarineTraffic-style classification + const koreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]); + const koreanShipsByCategory = useMemo(() => { + const counts: Record = {}; + for (const s of koreanShips) { + const mtCat = getMarineTrafficCategory(s.typecode, s.category); + counts[mtCat] = (counts[mtCat] || 0) + 1; + } + return counts; + }, [koreanShips]); + + // Korea region stats (for Korea dashboard) + const koreaKoreanShips = useMemo(() => koreaShips.filter(s => s.flag === 'KR'), [koreaShips]); + const koreaChineseShips = useMemo(() => koreaShips.filter(s => s.flag === 'CN'), [koreaShips]); + const koreaShipsByCategory = useMemo(() => { + const counts: Record = {}; + for (const s of koreaShips) { + const mtCat = getMarineTrafficCategory(s.typecode, s.category); + counts[mtCat] = (counts[mtCat] || 0) + 1; + } + return counts; + }, [koreaShips]); + + // Korea aircraft stats + const koreaAircraftByCategory = useMemo(() => { + const counts: Record = {}; + 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; + + // 불법환적 의심 선박 탐지: 100m 이내 근접 + 저속/정박 + 1시간 이상 유지 + 연안 제외 + // 근접 쌍별 최초 감지 시각 추적 (pairKey → timestamp) + const proximityStartRef = useRef>(new Map()); + const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간 + + const transshipSuspects = useMemo(() => { + if (!koreaFilters.illegalTransship) return new Set(); + + const suspects = new Set(); + const isOffshore = (s: Ship) => { + const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5; + if (nearCoastWest) { + const distFromEastCoast = s.lng - 129.5; + const distFromWestCoast = 126.0 - s.lng; + const distFromSouthCoast = 34.5 - s.lat; + if (distFromEastCoast > 0.15 || distFromWestCoast > 0.15 || distFromSouthCoast > 0.15) return true; + return false; + } + return true; + }; + + const isNearForeignCoast = (s: Ship) => { + if (s.lng < 123.5 && s.lat > 25 && s.lat < 40) return true; + if (s.lng > 130.5 && s.lat > 30 && s.lat < 46) return true; + if (s.lng > 129.1 && s.lng < 129.6 && s.lat > 34.0 && s.lat < 34.8) return true; + if (s.lng > 129.5 && s.lat > 31 && s.lat < 34) return true; + return false; + }; + + const candidates = koreaShips.filter(s => { + if (s.speed >= 2) return false; + const mtCat = getMarineTrafficCategory(s.typecode, s.category); + if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false; + if (isNearForeignCoast(s)) return false; + return isOffshore(s); + }); + + const now = currentTime; + const prevMap = proximityStartRef.current; + const currentPairs = new Set(); + const PROXIMITY_DEG = 0.001; // ~110m + + for (let i = 0; i < candidates.length; i++) { + for (let j = i + 1; j < candidates.length; j++) { + const a = candidates[i]; + const b = candidates[j]; + const dlat = Math.abs(a.lat - b.lat); + const dlng = Math.abs(a.lng - b.lng) * Math.cos((a.lat * Math.PI) / 180); + if (dlat < PROXIMITY_DEG && dlng < PROXIMITY_DEG) { + const pairKey = [a.mmsi, b.mmsi].sort().join(':'); + currentPairs.add(pairKey); + // 최초 감지 시 시작 시각 기록 + if (!prevMap.has(pairKey)) { + prevMap.set(pairKey, now); + } + // 1시간 이상 근접 유지 시 환적 의심 + const startTime = prevMap.get(pairKey)!; + if (now - startTime >= TRANSSHIP_DURATION_MS) { + suspects.add(a.mmsi); + suspects.add(b.mmsi); + } + } + } + } + + // 더 이상 근접하지 않는 쌍은 추적 해제 + for (const key of prevMap.keys()) { + if (!currentPairs.has(key)) prevMap.delete(key); + } + + return suspects; + }, [koreaShips, koreaFilters.illegalTransship, currentTime]); + + // 다크베셀 탐지: AIS 신호 이력 추적 + // mmsi → { lastSeenTs[], gapTotal, toggleCount } + const aisHistoryRef = useRef>(new Map()); + const ONE_HOUR_MS = 60 * 60 * 1000; + + const darkVesselSet = useMemo(() => { + if (!koreaFilters.darkVessel) return new Set(); + + const now = currentTime; + const history = aisHistoryRef.current; + const result = new Set(); + + // 현재 보이는 선박 mmsi 집합 + const currentMmsis = new Set(koreaShips.map(s => s.mmsi)); + + // 현재 보이는 선박: 신호 기록 갱신 + for (const s of koreaShips) { + let h = history.get(s.mmsi); + if (!h) { + h = { seen: [], lastGapStart: null }; + history.set(s.mmsi, h); + } + // AIS 신호가 꺼졌다 다시 켜진 경우 (gap이 있었으면 toggle +1) + if (h.lastGapStart !== null) { + const gapDuration = now - h.lastGapStart; + // 1시간 이상 신호 끊김 후 재등장 = 다크베셀 + if (gapDuration >= ONE_HOUR_MS) { + result.add(s.mmsi); + } + h.lastGapStart = null; + } + // seen 타임스탬프 기록 (최근 20개만 유지) + h.seen.push(now); + if (h.seen.length > 20) h.seen = h.seen.slice(-20); + + // 신호 껐다켰다 패턴: lastSeen이 현재보다 많이 이전 (불규칙 AIS) + // lastSeen 대비 현재 시간 차이가 크면 신호 불안정 + const aisAge = now - s.lastSeen; + if (aisAge > ONE_HOUR_MS) { + result.add(s.mmsi); + } + + // 신호 온오프 패턴: seen 기록에서 간격 분석 + if (h.seen.length >= 4) { + let gapCount = 0; + for (let k = 1; k < h.seen.length; k++) { + const gap = h.seen[k] - h.seen[k - 1]; + // 정상 갱신 주기(~30초)보다 5배 이상 차이 = 신호 끊김 + if (gap > 150_000) gapCount++; + } + // 3회 이상 신호 끊김 패턴 = 껐다켰다 + if (gapCount >= 3) { + result.add(s.mmsi); + } + } + } + + // 이전에 보였지만 지금 안 보이는 선박: gap 시작 기록 + for (const [mmsi, h] of history.entries()) { + if (!currentMmsis.has(mmsi) && h.lastGapStart === null) { + h.lastGapStart = now; + } + } + + // 오래된 이력 정리 (6시간 이상 미관측) + const SIX_HOURS = 6 * ONE_HOUR_MS; + for (const [mmsi, h] of history.entries()) { + if (h.seen.length > 0 && now - h.seen[h.seen.length - 1] > SIX_HOURS && !currentMmsis.has(mmsi)) { + history.delete(mmsi); + } + } + + return result; + }, [koreaShips, koreaFilters.darkVessel, currentTime]); + + // 해저케이블 감시: 케이블 라인 ~1km 이내 + 0.6노트 이하 + 3시간 이상 체류 + const cableNearStartRef = useRef>(new Map()); + const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간 + + const cableWatchSet = useMemo(() => { + if (!koreaFilters.cableWatch) return new Set(); + const result = new Set(); + const CABLE_PROX_DEG = 0.01; // ~1.1km + + // 케이블 세그먼트 수집 + const segments: [number, number, number, number][] = []; + for (const cable of KOREA_SUBMARINE_CABLES) { + for (let k = 0; k < cable.route.length - 1; k++) { + segments.push([cable.route[k][0], cable.route[k][1], cable.route[k + 1][0], cable.route[k + 1][1]]); + } + } + + // 점-선분 최소 거리 (도 단위 근사) + const distToSegment = (px: number, py: number, x1: number, y1: number, x2: number, y2: number) => { + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1); + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); + const cx = x1 + t * dx; + const cy = y1 + t * dy; + const dlng = (px - cx) * Math.cos((py * Math.PI) / 180); + return Math.hypot(dlng, py - cy); + }; + + const now = currentTime; + const prevMap = cableNearStartRef.current; + const currentNear = new Set(); + + for (const s of koreaShips) { + if (s.speed > 0.6) continue; // 0.6노트 이하만 + let nearCable = false; + for (const [x1, y1, x2, y2] of segments) { + if (distToSegment(s.lng, s.lat, x1, y1, x2, y2) < CABLE_PROX_DEG) { + nearCable = true; + break; + } + } + if (!nearCable) continue; + + currentNear.add(s.mmsi); + // 최초 감지 시 시작 시각 기록 + if (!prevMap.has(s.mmsi)) { + prevMap.set(s.mmsi, now); + } + // 3시간 이상 케이블 위 체류 시 의심 선박 + const startTime = prevMap.get(s.mmsi)!; + if (now - startTime >= CABLE_DURATION_MS) { + result.add(s.mmsi); + } + } + + // 케이블 근처 벗어난 선박은 추적 해제 + for (const mmsi of prevMap.keys()) { + if (!currentNear.has(mmsi)) prevMap.delete(mmsi); + } + + return result; + }, [koreaShips, koreaFilters.cableWatch, currentTime]); + + // 독도감시: 독도 영해(12해리≈22km) 접근 일본 선박 탐지 + 알림 + const DOKDO = { lat: 37.2417, lng: 131.8647 }; + const TERRITORIAL_DEG = 0.2; // ~22km (12해리) + const ALERT_DEG = 0.4; // ~44km (접근 경고 범위) + const dokdoAlertedRef = useRef>(new Set()); + const [dokdoAlerts, setDokdoAlerts] = useState<{ mmsi: string; name: string; dist: number; time: number }[]>([]); + + const dokdoWatchSet = useMemo(() => { + if (!koreaFilters.dokdoWatch) return new Set(); + const result = new Set(); + const newAlerts: { mmsi: string; name: string; dist: number; time: number }[] = []; + const alerted = dokdoAlertedRef.current; + + for (const s of koreaShips) { + // 일본 국적 선박만 감시 + if (s.flag !== 'JP') continue; + const dDokdo = Math.hypot( + (s.lng - DOKDO.lng) * Math.cos((DOKDO.lat * Math.PI) / 180), + s.lat - DOKDO.lat, + ); + // 영해 내 진입 + if (dDokdo < TERRITORIAL_DEG) { + result.add(s.mmsi); + if (!alerted.has(s.mmsi)) { + alerted.add(s.mmsi); + const distKm = Math.round(dDokdo * 111); + newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime }); + } + } + // 접근 경고 (영해 밖이지만 가까움) + else if (dDokdo < ALERT_DEG) { + result.add(s.mmsi); + if (!alerted.has(`warn-${s.mmsi}`)) { + alerted.add(`warn-${s.mmsi}`); + const distKm = Math.round(dDokdo * 111); + newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime }); + } + } + } + + // 벗어난 선박 추적 해제 + const currentJP = new Set(koreaShips.filter(s => s.flag === 'JP').map(s => s.mmsi)); + for (const key of alerted) { + const mmsi = key.replace('warn-', ''); + if (!currentJP.has(mmsi)) alerted.delete(key); + } + + if (newAlerts.length > 0) { + setDokdoAlerts(prev => [...newAlerts, ...prev].slice(0, 10)); + } + + return result; + }, [koreaShips, koreaFilters.dokdoWatch, currentTime]); + + const koreaFilteredShips = useMemo(() => { + 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; + if (koreaFilters.darkVessel && darkVesselSet.has(s.mmsi)) return true; + if (koreaFilters.cableWatch && cableWatchSet.has(s.mmsi)) return true; + if (koreaFilters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; + if (koreaFilters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; + return false; + }); + }, [visibleKoreaShips, koreaFilters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]); + + return ( +
+
+ {/* Dashboard Tabs (replaces title) */} +
+ + +
+ + {/* Mode Toggle */} + {dashboardTab === 'iran' && ( +
+
+ ⚔️ + D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} +
+ + +
+ )} + + {dashboardTab === 'korea' && ( +
+ + + + + + +
+ )} + + {dashboardTab === 'iran' && ( +
+ + + +
+ )} + +
+
+ {dashboardTab === 'iran' ? aircraft.length : aircraftKorea.length} AC + {dashboardTab === 'iran' ? militaryCount : koreaMilitaryCount} MIL + {dashboardTab === 'iran' ? ships.length : koreaShips.length} SHIP + {dashboardTab === 'iran' ? satPositions.length : satPositionsKorea.length} SAT +
+
+ + +
+
+ + {isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')} +
+
+
+ + {/* ═══════════════════════════════════════ + IRAN DASHBOARD + ═══════════════════════════════════════ */} + {dashboardTab === 'iran' && ( + <> +
+
+ {mapMode === 'flat' ? ( + setFlyToTarget(null)} + /> + ) : mapMode === 'globe' ? ( + + ) : ( + + )} +
+ } + onToggle={toggleLayer as (key: string) => void} + aircraftByCategory={aircraftByCategory} + 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} + /> +
+
+ + +
+ + {layers.sensorCharts && ( +
+ +
+ )} + +
+ {isLive ? ( + + ) : ( + <> + + + + )} + +
+ + )} + + {/* ═══════════════════════════════════════ + KOREA DASHBOARD + ═══════════════════════════════════════ */} + {dashboardTab === 'korea' && ( + <> +
+
+ +
+ +
+
+ + +
+ +
+ {isLive ? ( + + ) : ( + <> + + + + )} +
+ + )} +
+ ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AircraftLayer.tsx b/frontend/src/components/AircraftLayer.tsx new file mode 100644 index 0000000..173157d --- /dev/null +++ b/frontend/src/components/AircraftLayer.tsx @@ -0,0 +1,273 @@ +import { memo, useMemo, useState, useEffect } from 'react'; +import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import { useTranslation } from 'react-i18next'; +import type { Aircraft, AircraftCategory } from '../types'; + +interface Props { + aircraft: Aircraft[]; + militaryOnly: boolean; +} + +// ═══ tar1090 / Airplanes.live style SVG icons ═══ +const SHAPES: Record = { + airliner: { + viewBox: '-1 -2 34 34', w: 24, h: 24, + path: 'M16 1c-.17 0-.67.58-.9 1.03-.6 1.21-.6 1.15-.65 5.2-.04 2.97-.08 3.77-.18 3.9-.15.17-1.82 1.1-1.98 1.1-.08 0-.1-.25-.05-.83.03-.5.01-.92-.05-1.08-.1-.25-.13-.26-.71-.26-.82 0-.86.07-.78 1.5.03.6.08 1.17.11 1.25.05.12-.02.2-.25.33l-8 4.2c-.2.2-.18.1-.19 1.29 3.9-1.2 3.71-1.21 3.93-1.21.06 0 .1 0 .13.14.08.3.28.3.28-.04 0-.25.03-.27 1.16-.6.65-.2 1.22-.35 1.28-.35.05 0 .12.04.15.17.07.3.27.27.27-.08 0-.25.01-.27.7-.47.68-.1.98-.09 1.47-.1.18 0 .22 0 .26.18.06.34.22.35.27-.01.04-.2.1-.17 1.06-.14l1.07.02.05 4.2c.05 3.84.07 4.28.26 5.09.11.49.2.99.2 1.11 0 .19-.31.43-1.93 1.5l-1.93 1.26v1.02l4.13-.95.63 1.54c.05.07.12.09.19.09s.14-.02.19-.09l.63-1.54 4.13.95V29.3l-1.93-1.27c-1.62-1.06-1.93-1.3-1.93-1.49 0-.12.09-.62.2-1.11.19-.81.2-1.25.26-5.09l.05-4.2 1.07-.02c.96-.03 1.02-.05 1.06.14.05.36.21.35.27 0 .04-.17.08-.16.26-.16.49 0 .8-.02 1.48.1.68.2.69.21.69.46 0 .35.2.38.27.08.03-.13.1-.17.15-.17.06 0 .63.15 1.28.34 1.13.34 1.16.36 1.16.61 0 .35.2.34.28.04.03-.13.07-.14.13-.14.22 0 .03 0 3.93 1.2-.01-1.18.02-1.07-.19-1.27l-8-4.21c-.23-.12-.3-.21-.25-.33.03-.08.08-.65.11-1.25.08-1.43.04-1.5-.78-1.5-.58 0-.61.01-.71.26-.06.16-.08.58-.05 1.08.04.58.03.83-.05.83-.16 0-1.83-.93-1.98-1.1-.1-.13-.14-.93-.18-3.9-.05-4.05-.05-3.99-.65-5.2C16.67 1.58 16.17 1 16 1z', + }, + hi_perf: { + viewBox: '-7.8 0 80 80', w: 24, h: 24, + path: 'M 30.82,61.32 29.19,54.84 29.06,60.19 27.70,60.70 22.27,60.63 21.68,59.60 l -0.01,-2.71 6.26,-5.52 -0.03,-3.99 -13.35,-0.01 -3e-6,1.15 -1.94,0.00 -0.01,-1.31 0.68,-0.65 L 13.30,37.20 c -0.01,-0.71 0.57,-0.77 0.60,0 l 0.05,1.57 0.28,0.23 0.26,4.09 L 19.90,38.48 c 0,0 -0.04,-1.26 0.20,-1.28 0.16,-0.02 0.20,0.98 0.20,0.98 l 4.40,-3.70 c 0,0 0.02,-1.28 0.20,-1.28 0.14,-0.00 0.20,0.98 0.20,0.98 l 1.80,-1.54 C 27.02,28.77 28.82,25.58 29,21.20 c 0.06,-1.41 0.23,-3.34 0.86,-3.85 0.21,-4.40 1.32,-11.03 2.39,-11.03 1.07,0 2.17,6.64 2.39,11.03 0.63,0.51 0.80,2.45 0.86,3.85 0.18,4.38 1.98,7.57 2.10,11.44 l 1.80,1.54 c 0,0 0.06,-0.99 0.20,-0.98 0.18,0.01 0.20,1.28 0.20,1.28 l 4.40,3.70 c 0,0 0.04,-1.00 0.20,-0.98 0.24,0.03 0.20,1.28 0.20,1.28 l 5.41,4.60 0.26,-4.09 0.28,-0.23 L 50.59,37.20 c 0.03,-0.77 0.61,-0.71 0.60,0 l 0.02,9.37 0.68,0.65 -0.01,1.31 -1.94,-0.00 -3e-6,-1.15 -13.35,0.01 -0.03,3.99 6.26,5.52 L 42.81,59.60 42.22,60.63 36.79,60.70 35.43,60.19 35.30,54.84 33.67,61.32 Z', + }, + jet_nonSweep: { + viewBox: '-2 -2.4 22 22', w: 18, h: 18, + path: 'M9,17.09l-3.51.61v-.3c0-.65.11-1,.33-1.09L8.5,15a5.61,5.61,0,0,1-.28-1.32l-.53-.41-.1-.69H7.12l0-.21a7.19,7.19,0,0,1-.15-2.19L.24,9.05V8.84c0-1.1.51-1.15.61-1.15L7.8,7.18V2.88C7.8.64,8.89.3,8.93.28L9,.26l.07,0s1.13.36,1.13,2.6v4.3l7,.51c.09,0,.59.06.59,1.15v.21l-6.69,1.16a7.17,7.17,0,0,1-.15,2.19l0,.21h-.47l-.1.69-.53.41A5.61,5.61,0,0,1,9.5,15l2.74,1.28c.2.07.31.43.31,1.08v.3Z', + }, + heavy_2e: { + viewBox: '0 -3.2 64.2 64.2', w: 26, h: 26, + path: 'm 31.414,2.728 c -0.314,0.712 -1.296,2.377 -1.534,6.133 l -0.086,13.379 c 0.006,0.400 -0.380,0.888 -0.945,1.252 l -2.631,1.729 c 0.157,-0.904 0.237,-3.403 -0.162,-3.850 l -2.686,0.006 c -0.336,1.065 -0.358,2.518 -0.109,4.088 h 0.434 L 24.057,26.689 8.611,36.852 7.418,38.432 7.381,39.027 8.875,38.166 l 8.295,-2.771 0.072,0.730 0.156,-0.004 0.150,-0.859 3.799,-1.234 0.074,0.727 0.119,0.004 0.117,-0.832 2.182,-0.730 h 1.670 l 0.061,0.822 h 0.176 l 0.062,-0.822 4.018,-0.002 v 13.602 c 0.051,1.559 0.465,3.272 0.826,4.963 l -6.836,5.426 c -0.097,0.802 -0.003,1.372 0.049,1.885 l 7.734,-2.795 0.477,1.973 h 0.232 l 0.477,-1.973 7.736,2.795 c 0.052,-0.513 0.146,-1.083 0.049,-1.885 l -6.836,-5.426 c 0.361,-1.691 0.775,-3.404 0.826,-4.963 V 33.193 l 4.016,0.002 0.062,0.822 h 0.178 L 38.875,33.195 h 1.672 l 2.182,0.730 0.117,0.832 0.119,-0.004 0.072,-0.727 3.799,1.234 0.152,0.859 0.154,0.004 0.072,-0.730 8.297,2.771 1.492,0.861 -0.037,-0.596 -1.191,-1.580 -15.447,-10.162 0.363,-1.225 H 41.125 c 0.248,-1.569 0.225,-3.023 -0.111,-4.088 l -2.686,-0.006 c -0.399,0.447 -0.317,2.945 -0.160,3.850 L 35.535,23.492 C 34.970,23.128 34.584,22.640 34.590,22.240 L 34.504,8.910 C 34.193,4.926 33.369,3.602 32.934,2.722 32.442,1.732 31.894,1.828 31.414,2.728 Z', + }, + helicopter: { + viewBox: '-13 -13 90 90', w: 22, h: 22, + path: 'm 24.698,60.712 c 0,0 -0.450,2.134 -0.861,2.142 -0.561,0.011 -0.480,-3.836 -0.593,-5.761 -0.064,-1.098 1.381,-1.192 1.481,-0.042 l 5.464,0.007 -0.068,-9.482 -0.104,-1.108 c -2.410,-2.131 -3.028,-3.449 -3.152,-7.083 l -12.460,13.179 c -0.773,0.813 -2.977,0.599 -3.483,-0.428 L 26.920,35.416 26.866,29.159 11.471,14.513 c -0.813,-0.773 -0.599,-2.977 0.428,-3.483 l 14.971,14.428 0.150,-5.614 c -0.042,-1.324 1.075,-4.784 3.391,-5.633 0.686,-0.251 2.131,-0.293 3.033,0.008 2.349,0.783 3.433,4.309 3.391,5.633 l 0.073,4.400 12.573,-12.763 c 0.779,-0.807 2.977,-0.599 3.483,0.428 L 37.054,28.325 37.027,35.027 52.411,49.365 c 0.813,0.773 0.599,2.977 -0.428,3.483 L 36.992,38.359 c -0.124,3.634 -0.742,5.987 -3.152,8.118 l -0.104,1.108 -0.068,9.482 5.321,-0.068 c 0.101,-1.150 1.546,-1.057 1.481,0.042 -0.113,1.925 -0.032,5.772 -0.593,5.761 -0.412,-0.008 -0.861,-2.142 -0.861,-2.142 l -5.387,-0.011 0.085,9.377 -1.094,2.059 -1.386,-0.018 -1.093,-2.049 0.085,-9.377 z', + }, + cessna: { + viewBox: '0 -1 32 31', w: 20, h: 20, + path: 'M16.36 20.96l2.57.27s.44.05.4.54l-.02.63s-.03.47-.45.54l-2.31.34-.44-.74-.22 1.63-.25-1.62-.38.73-2.35-.35s-.44-.1-.43-.6l-.02-.6s0-.5.48-.5l2.5-.27-.56-5.4-3.64-.1-5.83-1.02h-.45v-2.06s-.07-.37.46-.34l5.8-.17 3.55.12s-.1-2.52.52-2.82l-1.68-.04s-.1-.06 0-.14l1.94-.03s.35-1.18.7 0l1.91.04s.11.05 0 .14l-1.7.02s.62-.09.56 2.82l3.54-.1 5.81.17s.51-.04.48.35l-.01 2.06h-.47l-5.8 1-3.67.11z', + }, + ground: { + viewBox: '0 0 24 24', w: 12, h: 12, + path: 'M12 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z', + }, +}; + +function getShape(ac: Aircraft) { + if (ac.onGround) return SHAPES.ground; + switch (ac.category) { + case 'fighter': case 'military': return SHAPES.hi_perf; + case 'tanker': case 'surveillance': case 'cargo': return SHAPES.heavy_2e; + case 'civilian': return SHAPES.airliner; + default: return SHAPES.jet_nonSweep; + } +} + +const ALT_COLORS: [number, string][] = [ + [0, '#00c000'], [150, '#2AD62A'], [300, '#55EC55'], [600, '#7CFC00'], + [1200, '#BFFF00'], [1800, '#FFFF00'], [3000, '#FFD700'], [6000, '#FF8C00'], + [9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'], +]; + +const MIL_HEX: Partial> = { + fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff', +}; + +function getAltitudeColor(altMeters: number): string { + if (altMeters <= 0) return ALT_COLORS[0][1]; + for (let i = ALT_COLORS.length - 1; i >= 0; i--) { + if (altMeters >= ALT_COLORS[i][0]) return ALT_COLORS[i][1]; + } + return ALT_COLORS[0][1]; +} + +function getAircraftColor(ac: Aircraft): string { + const milColor = MIL_HEX[ac.category]; + if (milColor) return milColor; + if (ac.onGround) return '#555555'; + return getAltitudeColor(ac.altitude); +} + +// ═══ Planespotters.net photo API ═══ +interface PhotoResult { url: string; photographer: string; link: string; } +const photoCache = new Map(); + +function AircraftPhoto({ hex }: { hex: string }) { + const { t } = useTranslation('ships'); + const [photo, setPhoto] = useState( + photoCache.has(hex) ? photoCache.get(hex) : undefined, + ); + + useEffect(() => { + if (photo !== undefined) return; + let cancelled = false; + (async () => { + try { + const res = await fetch(`https://api.planespotters.net/pub/photos/hex/${hex}`); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + if (cancelled) return; + if (data.photos && data.photos.length > 0) { + const p = data.photos[0]; + const result: PhotoResult = { + url: p.thumbnail_large?.src || p.thumbnail?.src || '', + photographer: p.photographer || '', + link: p.link || '', + }; + photoCache.set(hex, result); + setPhoto(result); + } else { + photoCache.set(hex, null); + setPhoto(null); + } + } catch { + photoCache.set(hex, null); + setPhoto(null); + } + })(); + return () => { cancelled = true; }; + }, [hex, photo]); + + if (photo === undefined) { + return
{t('aircraftPopup.loadingPhoto')}
; + } + if (!photo) return null; + return ( +
+ + Aircraft { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + + {photo.photographer && ( +
+ © {photo.photographer} +
+ )} +
+ ); +} + +// ═══ Main layer ═══ +export function AircraftLayer({ aircraft, militaryOnly }: Props) { + const filtered = useMemo(() => { + if (militaryOnly) { + return aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown'); + } + return aircraft; + }, [aircraft, militaryOnly]); + + // Aircraft trails as GeoJSON + const trailData = useMemo(() => ({ + type: 'FeatureCollection' as const, + features: filtered + .filter(ac => ac.trail && ac.trail.length > 1) + .map(ac => ({ + type: 'Feature' as const, + properties: { color: getAircraftColor(ac) }, + geometry: { + type: 'LineString' as const, + coordinates: ac.trail!.map(([lat, lng]) => [lng, lat]), + }, + })), + }), [filtered]); + + return ( + <> + {trailData.features.length > 0 && ( + + + + )} + {filtered.map(ac => ( + + ))} + + ); +} + +// ═══ Aircraft Marker ═══ +const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { + const { t } = useTranslation('ships'); + const [showPopup, setShowPopup] = useState(false); + const color = getAircraftColor(ac); + const shape = getShape(ac); + const size = shape.w; + const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; + const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; + + return ( + <> + +
+
{ e.stopPropagation(); setShowPopup(true); }} + > + + + +
+ {showLabel && ( +
+ {ac.callsign || ac.icao24} +
+ )} +
+
+ {showPopup && ( + setShowPopup(false)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {ac.callsign || 'N/A'} + + {t(`aircraftLabel.${ac.category}`)} + +
+ + + + + {ac.registration && } + {ac.operator && } + {ac.typecode && ( + + + )} + {ac.squawk && } + + + + + + +
{t('aircraftPopup.hex')}{ac.icao24.toUpperCase()}
{t('aircraftPopup.reg')}{ac.registration}
{t('aircraftPopup.operator')}{ac.operator}
{t('aircraftPopup.type')}{ac.typecode}{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}
{t('aircraftPopup.squawk')}{ac.squawk}
{t('aircraftPopup.alt')}{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}
{t('aircraftPopup.speed')}{Math.round(ac.velocity * 1.944)} kts
{t('aircraftPopup.hdg')}{Math.round(ac.heading)}°
{t('aircraftPopup.verticalSpeed')}{Math.round(ac.verticalRate * 196.85)} fpm
+ +
+
+ )} + + ); +}, (prev, next) => { + const a = prev.ac, b = next.ac; + return a.icao24 === b.icao24 && + Math.abs(a.lat - b.lat) < 0.001 && + Math.abs(a.lng - b.lng) < 0.001 && + Math.round(a.heading / 10) === Math.round(b.heading / 10) && + a.category === b.category && + Math.round(a.altitude / 500) === Math.round(b.altitude / 500); +}); diff --git a/frontend/src/components/AirportLayer.tsx b/frontend/src/components/AirportLayer.tsx new file mode 100644 index 0000000..1b3b1ab --- /dev/null +++ b/frontend/src/components/AirportLayer.tsx @@ -0,0 +1,138 @@ +import { memo, useMemo, useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import type { Airport } from '../data/airports'; + +const US_BASE_ICAOS = new Set([ + 'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL', +]); + +function isUSBase(airport: Airport): boolean { + return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao); +} + +const FLAG_EMOJI: Record = { + IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}', + AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}', + BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}', + TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}', + SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}', + DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}', +}; + +const TYPE_LABELS: Record = { + large: 'International Airport', medium: 'Airport', + small: 'Regional Airport', military: 'Military Airbase', +}; + +interface Props { airports: Airport[]; } + +const TYPE_PRIORITY: Record = { + military: 3, large: 2, medium: 1, small: 0, +}; + +// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small. +function deduplicateByArea(airports: Airport[]): Airport[] { + const sorted = [...airports].sort((a, b) => { + const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0); + const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0); + return pb - pa; + }); + const kept: Airport[] = []; + for (const ap of sorted) { + const tooClose = kept.some( + k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8, + ); + if (!tooClose) kept.push(ap); + } + return kept; +} + +export const AirportLayer = memo(function AirportLayer({ airports }: Props) { + const filtered = useMemo(() => deduplicateByArea(airports), [airports]); + return ( + <> + {filtered.map(ap => ( + + ))} + + ); +}); + +function AirportMarker({ airport }: { airport: Airport }) { + const [showPopup, setShowPopup] = useState(false); + const isMil = airport.type === 'military'; + const isUS = isUSBase(airport); + const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b'; + const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16; + const flag = FLAG_EMOJI[airport.country] || ''; + + // Single circle with airplane inside (plane shifted down to center in circle) + const plane = isMil + ? + : ; + const icon = ( + + + {plane} + + ); + + return ( + <> + +
{ e.stopPropagation(); setShowPopup(true); }}> + {icon} +
+
+ {showPopup && ( + setShowPopup(false)} closeOnClick={false} + anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup"> +
+
+ {isUS ? {'\u{1F1FA}\u{1F1F8}'} + : flag ? {flag} : null} + {airport.name} +
+ {airport.nameKo && ( +
{airport.nameKo}
+ )} +
+ + {isUS ? 'US Military Base' : TYPE_LABELS[airport.type]} + +
+
+ {airport.iata &&
IATA : {airport.iata}
} +
ICAO : {airport.icao}
+ {airport.city &&
City : {airport.city}
} +
Country : {airport.country}
+
+
+ {airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'} +
+ {airport.iata && ( + + )} +
+
+ )} + + ); +} diff --git a/frontend/src/components/CctvLayer.tsx b/frontend/src/components/CctvLayer.tsx new file mode 100644 index 0000000..53c3a65 --- /dev/null +++ b/frontend/src/components/CctvLayer.tsx @@ -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 = { + '제주': '#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(null); + const [streamCam, setStreamCam] = useState(null); + + return ( + <> + {KOREA_CCTV_CAMERAS.map(cam => { + const color = REGION_COLOR[cam.region] || '#aaa'; + return ( + { e.originalEvent.stopPropagation(); setSelected(cam); }}> +
+ + + + + + + +
+ {cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name} +
+
+
+ ); + })} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ 📹 {selected.name} +
+
+ + ● {t('cctv.live')} + + {selected.region} + + {t(`cctv.type.${selected.type}`, { defaultValue: selected.type })} + + + {t('cctv.khoa')} + +
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+ +
+
+
+ )} + + {/* CCTV HLS Stream Modal */} + {streamCam && ( + setStreamCam(null)} /> + )} + + ); +} + +/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */ +function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) { + const { t } = useTranslation('ships'); + const videoRef = useRef(null); + const hlsRef = useRef(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 */ +
+ {/* Modal */} +
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 */} +
+
+ + ● {status === 'playing' ? t('cctv.live') : status === 'loading' ? t('cctv.connecting') : 'ERROR'} + + 📹 {cam.name} + {cam.region} +
+ +
+ + {/* Video */} +
+
+ + {/* Footer info */} +
+ {cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E + {t('cctv.khoaFull')} +
+
+
+ ); +} diff --git a/frontend/src/components/CoastGuardLayer.tsx b/frontend/src/components/CoastGuardLayer.tsx new file mode 100644 index 0000000..87a674f --- /dev/null +++ b/frontend/src/components/CoastGuardLayer.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard'; +import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard'; + +const TYPE_COLOR: Record = { + hq: '#ff6b6b', + regional: '#ffa94d', + station: '#4dabf7', + substation: '#69db7c', + vts: '#da77f2', +}; + +const TYPE_SIZE: Record = { + hq: 24, + regional: 20, + station: 16, + substation: 13, + vts: 14, +}; + +/** 해경 로고 SVG — 작은 방패+앵커 심볼 */ +function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) { + const color = TYPE_COLOR[type]; + const isVts = type === 'vts'; + + if (isVts) { + return ( + + + + + + + + ); + } + + return ( + + + + + + + {(type === 'hq' || type === 'regional') && ( + + )} + + ); +} + +export function CoastGuardLayer() { + const [selected, setSelected] = useState(null); + const { t } = useTranslation(); + + return ( + <> + {COAST_GUARD_FACILITIES.map(f => { + const size = TYPE_SIZE[f.type]; + return ( + { e.originalEvent.stopPropagation(); setSelected(f); }}> +
+ + {(f.type === 'hq' || f.type === 'regional') && ( +
+ {f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'} +
+ )} + {f.type === 'vts' && ( +
+ VTS +
+ )} +
+
+ ); + })} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ {selected.name} +
+
+ + {CG_TYPE_LABEL[selected.type]} + + + {t('coastGuard.agency')} + +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/DamagedShipLayer.tsx b/frontend/src/components/DamagedShipLayer.tsx new file mode 100644 index 0000000..faa577f --- /dev/null +++ b/frontend/src/components/DamagedShipLayer.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { damagedShips } from '../data/damagedShips'; +import type { DamagedShip } from '../data/damagedShips'; + +interface Props { + currentTime: number; +} + +const FLAG_EMOJI: Record = { + GR: '\u{1F1EC}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', KR: '\u{1F1F0}\u{1F1F7}', + IR: '\u{1F1EE}\u{1F1F7}', US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', + PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', CN: '\u{1F1E8}\u{1F1F3}', +}; + +const DAMAGE_COLORS: Record = { + sunk: '#ff0000', + severe: '#ef4444', + moderate: '#f97316', + minor: '#eab308', +}; + +const DAMAGE_LABELS: Record = { + sunk: '침몰', + severe: '중파', + moderate: '중손', + minor: '경미', +}; + +const KST_OFFSET = 9 * 3600_000; + +function formatKST(ts: number): string { + const d = new Date(ts + KST_OFFSET); + return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} KST`; +} + +export function DamagedShipLayer({ currentTime }: Props) { + const [selectedId, setSelectedId] = useState(null); + + const visible = useMemo( + () => damagedShips.filter(s => currentTime >= s.damagedAt), + [currentTime], + ); + + const selected = selectedId ? visible.find(s => s.id === selectedId) ?? null : null; + + return ( + <> + {visible.map(ship => { + const color = DAMAGE_COLORS[ship.damage]; + const isSunk = ship.damage === 'sunk'; + const ageH = (currentTime - ship.damagedAt) / 3600_000; + const isRecent = ageH <= 24; + const size = isRecent ? 28 : 22; + const c = size / 2; + + return ( + +
{ e.stopPropagation(); setSelectedId(ship.id); }} + > + + {/* outer ring */} + + {/* ship icon (simplified) */} + + {/* X mark for damage */} + + + {/* inner X in color */} + + + + {/* label */} +
+ {isRecent && NEW} + {ship.name} +
+ {/* pulse for recent */} + {isRecent && ( +
+ )} +
+ + ); + })} + + {selected && ( + setSelectedId(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ {FLAG_EMOJI[selected.flag] && {FLAG_EMOJI[selected.flag]}} + {selected.name} + {DAMAGE_LABELS[selected.damage]} +
+
+
선종 : {selected.type}
+
국적 : {selected.flag}
+
원인 : {selected.cause}
+
피격 : {formatKST(selected.damagedAt)}
+
+
+ {selected.description} +
+
+
+ )} + + + + ); +} diff --git a/frontend/src/components/EezLayer.tsx b/frontend/src/components/EezLayer.tsx new file mode 100644 index 0000000..00d2cb6 --- /dev/null +++ b/frontend/src/components/EezLayer.tsx @@ -0,0 +1,127 @@ +import { Source, Layer } from 'react-map-gl/maplibre'; +import { KOREA_EEZ_BOUNDARY, KOREA_CHINA_PMZ, NLL_WEST_SEA, NLL_EAST_SEA } from '../services/koreaEez'; +import type { FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl'; + +// Convert [lat, lng][] to GeoJSON [lng, lat][] ring +function toRing(coords: [number, number][]): [number, number][] { + return coords.map(([lat, lng]) => [lng, lat]); +} + +function toLineCoords(coords: [number, number][]): [number, number][] { + return coords.map(([lat, lng]) => [lng, lat]); +} + +const eezGeoJSON: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [ + // EEZ 경계 폴리곤 + { + type: 'Feature', + properties: { type: 'eez' }, + geometry: { + type: 'Polygon', + coordinates: [toRing(KOREA_EEZ_BOUNDARY)], + }, + }, + // 한중 잠정조치수역 + { + type: 'Feature', + properties: { type: 'pmz' }, + geometry: { + type: 'Polygon', + coordinates: [toRing(KOREA_CHINA_PMZ)], + }, + }, + // 서해 NLL + { + type: 'Feature', + properties: { type: 'nll' }, + geometry: { + type: 'LineString', + coordinates: toLineCoords(NLL_WEST_SEA), + }, + }, + // 동해 NLL + { + type: 'Feature', + properties: { type: 'nll' }, + geometry: { + type: 'LineString', + coordinates: toLineCoords(NLL_EAST_SEA), + }, + }, + ], +}; + +const eezFillStyle: FillLayerSpecification = { + id: 'eez-fill', + type: 'fill', + source: 'eez-source', + filter: ['==', ['get', 'type'], 'eez'], + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.06, + }, +}; + +const eezLineStyle: LineLayerSpecification = { + id: 'eez-line', + type: 'line', + source: 'eez-source', + filter: ['==', ['get', 'type'], 'eez'], + paint: { + 'line-color': '#3b82f6', + 'line-width': 1.5, + 'line-dasharray': [4, 3], + 'line-opacity': 0.6, + }, +}; + +const pmzFillStyle: FillLayerSpecification = { + id: 'pmz-fill', + type: 'fill', + source: 'eez-source', + filter: ['==', ['get', 'type'], 'pmz'], + paint: { + 'fill-color': '#eab308', + 'fill-opacity': 0.08, + }, +}; + +const pmzLineStyle: LineLayerSpecification = { + id: 'pmz-line', + type: 'line', + source: 'eez-source', + filter: ['==', ['get', 'type'], 'pmz'], + paint: { + 'line-color': '#eab308', + 'line-width': 1.2, + 'line-dasharray': [3, 2], + 'line-opacity': 0.5, + }, +}; + +const nllLineStyle: LineLayerSpecification = { + id: 'nll-line', + type: 'line', + source: 'eez-source', + filter: ['==', ['get', 'type'], 'nll'], + paint: { + 'line-color': '#ef4444', + 'line-width': 2, + 'line-dasharray': [6, 4], + 'line-opacity': 0.7, + }, +}; + +export function EezLayer() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/EventLog.tsx b/frontend/src/components/EventLog.tsx new file mode 100644 index 0000000..6b96d85 --- /dev/null +++ b/frontend/src/components/EventLog.tsx @@ -0,0 +1,753 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GeoEvent, Ship } from '../types'; +import type { OsintItem } from '../services/osint'; + +type DashboardTab = 'iran' | 'korea'; + +interface Props { + events: GeoEvent[]; + currentTime: number; + totalShipCount: number; + koreanShips: Ship[]; + koreanShipsByCategory: Record; + chineseShips?: Ship[]; + osintFeed?: OsintItem[]; + isLive?: boolean; + dashboardTab?: DashboardTab; + onTabChange?: (tab: DashboardTab) => void; + ships?: Ship[]; +} + +// ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══ +interface BreakingNews { + id: string; + timestamp: number; + category: 'trump' | 'oil' | 'diplomacy' | 'economy'; + headline: string; + detail?: string; +} + +const T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR_MS = 3600_000; +const DAY_MS = 24 * HOUR_MS; +const _MIN_MS = 60_000; + +const BREAKING_NEWS: BreakingNews[] = [ + // DAY 1 + { + id: 'bn1', timestamp: T0_NEWS - 11 * HOUR_MS, + category: 'trump', headline: '트럼프: "이란 정권 제거 작전 개시"', + detail: '백악관 긴급 브리핑. "미국은 이란의 핵위협을 더 이상 용납하지 않겠다."', + }, + { + id: 'bn2', timestamp: T0_NEWS - 8 * HOUR_MS, + category: 'oil', headline: 'WTI 원유 $140 돌파 — 호르무즈 해협 봉쇄 우려', + detail: '브렌트유 $145, 아시아 선물시장 급등. 호르무즈 해협 통과 원유 일일 2,100만 배럴.', + }, + { + id: 'bn3', timestamp: T0_NEWS - 3 * HOUR_MS, + category: 'oil', headline: '호르무즈 해협 봉쇄 선언 — 유가 40% 급등', + detail: 'IRGC 해군 해협 봉쇄. WTI $165, 브렌트 $170. 글로벌 공급망 마비 우려.', + }, + { + id: 'bn4', timestamp: T0_NEWS + 2 * HOUR_MS, + category: 'trump', headline: '트럼프: "이란은 매우 큰 대가를 치를 것"', + detail: '알우데이드 미군 3명 전사 확인 후 성명. "미국 군인에 대한 공격은 10배로 갚겠다."', + }, + { + id: 'bn5', timestamp: T0_NEWS + 4 * HOUR_MS, + category: 'oil', headline: 'WTI $180 돌파 — 사상 최고가 경신', + detail: '이란 보복 공격으로 걸프 원유 수출 완전 중단. S&P 500 -7% 서킷브레이커.', + }, + { + id: 'bn6', timestamp: T0_NEWS + 6 * HOUR_MS, + category: 'economy', headline: '미 국방부: "2,000명 추가 병력 중동 긴급 배치"', + detail: '제82공수사단 신속대응여단 카타르행. 추가 패트리어트 포대 배치.', + }, + { + id: 'bn7', timestamp: T0_NEWS + 10 * HOUR_MS, + category: 'economy', headline: '한국 비상 에너지 대책 — 전략비축유 방출 검토', + detail: '산업부, 유류비 급등 대응 비상대책 발표. 걸프 한국 교민 대피 명령.', + }, + + // DAY 2 + { + id: 'bn8', timestamp: T0_NEWS + 1 * DAY_MS, + category: 'oil', headline: 'WTI $185 — 호르무즈 기뢰 추가 배치', + detail: 'IRGC 해협 기뢰 추가 설치. 보험료 1,000% 급등, 유조선 통행 사실상 중단.', + }, + { + id: 'bn9', timestamp: T0_NEWS + 1 * DAY_MS + 6 * HOUR_MS, + category: 'trump', headline: '트럼프: "이란 석유시설 전면 타격 승인"', + detail: '"이란이 해협을 닫으면 우리는 이란의 모든 석유시설을 파괴할 것."', + }, + { + id: 'bn10', timestamp: T0_NEWS + 1 * DAY_MS + 10 * HOUR_MS, + category: 'economy', headline: 'IEA 긴급 비축유 방출 — 6,000만 배럴', + detail: 'IEA 회원국 전략비축유 협조 방출 합의. 미국 3,000만 배럴 선도 방출.', + }, + + // DAY 3 + { + id: 'bn11', timestamp: T0_NEWS + 2 * DAY_MS, + category: 'oil', headline: '호르무즈 유조선 기뢰 접촉 — 원유 유출', + detail: '그리스 VLCC "아테나 글로리" 기뢰 접촉. 200만 배럴 유출 위기. WTI $190.', + }, + { + id: 'bn12', timestamp: T0_NEWS + 2 * DAY_MS + 6 * HOUR_MS, + category: 'trump', headline: '트럼프: "해군에 호르무즈 기뢰 제거 명령"', + detail: '"미 해군 소해정 부대 투입. 해협 72시간 내 재개방 목표."', + }, + { + id: 'bn13', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS, + category: 'economy', headline: '한국 선박 12척 오만만 긴급 대피', + detail: '청해부대 호위 하 호르무즈 인근 한국 선박 대피. 해운업계 손실 하루 2,000억원.', + }, + + // DAY 4 + { + id: 'bn14', timestamp: T0_NEWS + 3 * DAY_MS, + category: 'oil', headline: 'WTI $195 — 헤즈볼라 하이파 정유시설 타격', + detail: '이스라엘 하이파 정유시설 화재. 중동 전면전 우려 극대화.', + }, + { + id: 'bn15', timestamp: T0_NEWS + 3 * DAY_MS + 8 * HOUR_MS, + category: 'trump', headline: '트럼프: "디모나 공격은 레드라인 — 핵옵션 배제 안 해"', + detail: '디모나 핵시설 인근 피격 후 강경 성명. 세계 핵전쟁 공포 확산.', + }, + + // DAY 5 + { + id: 'bn16', timestamp: T0_NEWS + 4 * DAY_MS, + category: 'economy', headline: '이란 사이버공격 — 이스라엘 전력망 마비', + detail: '이란 APT, 이스라엘 전력망 해킹. 텔아비브 일대 12시간 정전.', + }, + { + id: 'bn17', timestamp: T0_NEWS + 4 * DAY_MS + 4 * HOUR_MS, + category: 'oil', headline: 'WTI $200 돌파 — 사우디 라스타누라 드론 피격', + detail: '사우디 최대 석유수출터미널 피격. 글로벌 석유 공급 일 500만 배럴 감소.', + }, + { + id: 'bn18', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS, + category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 방출 개시', + detail: '유류비 급등 대응 비상대책. 비축유 500만 배럴 방출. 주유소 가격 L당 2,800원 돌파.', + }, + + // DAY 6 + { + id: 'bn19', timestamp: T0_NEWS + 5 * DAY_MS, + category: 'oil', headline: '이란 항모전단 공격 — WTI $210', + detail: 'IRGC 대함미사일 발사, 이지스 전탄 요격. 해상보험료 역사적 최고치.', + }, + { + id: 'bn20', timestamp: T0_NEWS + 5 * DAY_MS + 4 * HOUR_MS, + category: 'trump', headline: '트럼프: "이란 해군 완전히 소탕하겠다"', + detail: '"페르시아만에서 이란 함정이 하나도 남지 않을 때까지 작전 지속."', + }, + + // DAY 7 + { + id: 'bn21', timestamp: T0_NEWS + 6 * DAY_MS + 4 * HOUR_MS, + category: 'trump', headline: '트럼프: "48시간 최후통첩 — 정권교체 불사"', + detail: '"이란이 48시간 내 미사일 발사를 중단하지 않으면 정권 교체 작전을 개시하겠다."', + }, + { + id: 'bn22', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS, + category: 'oil', headline: 'WTI $195 소폭 하락 — 휴전 기대감', + detail: '트럼프 최후통첩 후 휴전 기대감. 그러나 IRGC 거부 성명으로 다시 반등.', + }, + + // DAY 8 + { + id: 'bn23', timestamp: T0_NEWS + 7 * DAY_MS, + category: 'diplomacy', headline: 'ICRC: "중동 인도적 위기 — 이란 의약품 고갈"', + detail: '이란 내 의약품·식수 부족 심각. 이스라엘·바레인 민간인 사상자 수천 명.', + }, + { + id: 'bn24', timestamp: T0_NEWS + 7 * DAY_MS + 6 * HOUR_MS, + category: 'trump', headline: '트럼프: "이란 정보부 본부도 파괴 — 끝까지 간다"', + detail: 'B-2 이란 정보부(VAJA) 타격 후 성명. "이란에 남은 건 항복뿐."', + }, + { + id: 'bn25', timestamp: T0_NEWS + 7 * DAY_MS + 10 * HOUR_MS, + category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국', + detail: '군 수송기 투입. 청해부대 한국 선박 호위 지속. 해운업계 일 3,000억원 손실.', + }, + + // DAY 9 + { + id: 'bn26', timestamp: T0_NEWS + 8 * DAY_MS, + category: 'diplomacy', headline: '러시아, 이란에 휴전 수용 비공식 권고', + detail: '푸틴, 추가 무기 지원 거부. 이란 고립 심화.', + }, + { + id: 'bn27', timestamp: T0_NEWS + 8 * DAY_MS + 4 * HOUR_MS, + category: 'oil', headline: '이란 미사일 재고 80% 소진 — WTI $180으로 하락', + detail: '미 정보기관 분석 공개. 이란 잔존 이동식 발사대 10기 이하.', + }, + { + id: 'bn28', timestamp: T0_NEWS + 8 * DAY_MS + 8 * HOUR_MS, + category: 'trump', headline: '트럼프: "나탄즈 완전 파괴 — 이란 핵프로그램 종식"', + detail: '"이란의 핵 야망은 영원히 끝났다. 역사가 나를 기억할 것."', + }, + { + id: 'bn29', timestamp: T0_NEWS + 8 * DAY_MS + 10 * HOUR_MS, + category: 'diplomacy', headline: 'UN 72시간 인도적 휴전 결의안 채택', + detail: '안보리 찬성 13, 기권 2. 미국·이란 모두 입장 미정.', + }, + { + id: 'bn30', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS, + category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분"', + detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.', + }, +]; + +// ═══ 한국 전용 속보 (리플레이) ═══ +const BREAKING_NEWS_KR: BreakingNews[] = [ + // DAY 1 + { id: 'kr1', timestamp: T0_NEWS - 6 * HOUR_MS, category: 'economy', headline: '한국 NSC 긴급소집 — 호르무즈 사태 대응 논의', detail: '외교·국방·산업부 장관 참석. 교민 보호·에너지 수급 점검.' }, + { id: 'kr2', timestamp: T0_NEWS + 2 * HOUR_MS, category: 'economy', headline: '코스피 -4.2% 급락 — 유가 폭등 충격', detail: '한국 원유 수입의 70% 호르무즈 해협 경유. 정유·항공·운송주 급락.' }, + { id: 'kr3', timestamp: T0_NEWS + 6 * HOUR_MS, category: 'oil', headline: '국내 유가 L당 2,200원 돌파 — 주유소 대란 시작', detail: '수도권 주유소 재고 부족 속출. 산업부 긴급 유류 배급 체계 가동 검토.' }, + { id: 'kr4', timestamp: T0_NEWS + 10 * HOUR_MS, category: 'economy', headline: '한국 전략비축유 방출 검토 — 산업부 비상대책', detail: '걸프 한국 교민 대피 명령. 청해부대 한국 선박 호위 태세.' }, + + // DAY 2 + { id: 'kr5', timestamp: T0_NEWS + 1 * DAY_MS, category: 'oil', headline: '한국행 원유 탱커 5척 오만만 대기 — 통행 불가', detail: 'VLCC 5척(250만 배럴) 호르무즈 해협 진입 불가. 정유사 원유 재고 2주분.' }, + { id: 'kr6', timestamp: T0_NEWS + 1 * DAY_MS + 8 * HOUR_MS, category: 'economy', headline: '현대·삼성중공업 조선 수주 취소 우려 — 해운보험료 급등', detail: '걸프 향 선박 보험료 1,000% 인상. 해운업계 일 2,000억원 손실.' }, + + // DAY 3 + { id: 'kr7', timestamp: T0_NEWS + 2 * DAY_MS, category: 'economy', headline: '한국 교민 1,200명 UAE·카타르 대피 중', detail: '외교부 특별기 2대 투입. 청해부대 ROKS 최영함 한국 선박 호위.' }, + { id: 'kr8', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS, category: 'oil', headline: '한국 정유사 가동률 70% 감축 — 원유 부족', detail: 'SK에너지·GS칼텍스·에쓰오일 감산. LPG·석유화학 제품 공급 차질.' }, + + // DAY 4 + { id: 'kr9', timestamp: T0_NEWS + 3 * DAY_MS, category: 'economy', headline: '코스피 -8.5% — 서킷브레이커 발동', detail: '외국인 6조원 순매도. 원/달러 1,550원 돌파. 한국 CDS 급등.' }, + { id: 'kr10', timestamp: T0_NEWS + 3 * DAY_MS + 6 * HOUR_MS, category: 'oil', headline: '한국, 미국·캐나다 긴급 원유 도입 협상', detail: '비(非)호르무즈 경유 원유 확보. 미 전략비축유 한국 우선배분 요청.' }, + + // DAY 5 + { id: 'kr11', timestamp: T0_NEWS + 4 * DAY_MS, category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 500만 배럴 방출', detail: '주유소 가격 L당 2,800원 돌파. 택시·화물차 운행 감축 논의.' }, + { id: 'kr12', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한국 외교부, 이란에 교민 안전 보장 요청', detail: '이란 주재 한국대사관 최소 인원 운영. 한국인 체류자 150명 잔류.' }, + + // DAY 6 + { id: 'kr13', timestamp: T0_NEWS + 5 * DAY_MS, category: 'oil', headline: '한국 LNG 긴급 수입 — 호주·카타르 장기계약 가동', detail: 'LNG 스팟 가격 MMBtu $35 돌파. 가스공사 비축량 2주분.' }, + { id: 'kr14', timestamp: T0_NEWS + 5 * DAY_MS + 6 * HOUR_MS, category: 'economy', headline: '한국 해운 3사 호르무즈 회항 — 희망봉 우회', detail: 'HMM·팬오션·대한해운 전 선박 희망봉 우회. 운항 일수 +14일, 비용 +40%.' }, + + // DAY 7 + { id: 'kr15', timestamp: T0_NEWS + 6 * DAY_MS, category: 'economy', headline: '한국 제조업 PMI 42.1 — 3년 최저', detail: '석유화학·철강·자동차 부품 공급 차질. 수출 전년비 -15% 전망.' }, + { id: 'kr16', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한미 정상 긴급통화 — 에너지 안보 협력 강화', detail: '미국, 한국에 전략비축유 500만 배럴 추가 배분. 원유 수송 해군 호위 합의.' }, + + // DAY 8 + { id: 'kr17', timestamp: T0_NEWS + 7 * DAY_MS, category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국', detail: '군 수송기 C-130J 2대 투입. 청해부대 한국 선박 호위 지속.' }, + { id: 'kr18', timestamp: T0_NEWS + 7 * DAY_MS + 8 * HOUR_MS, category: 'oil', headline: '한국 원유 비축 45일분으로 감소 — 경고 수준', detail: 'IEA 권고 90일 대비 절반. 추가 긴축 조치 불가피.' }, + + // DAY 9 + { id: 'kr19', timestamp: T0_NEWS + 8 * DAY_MS, category: 'diplomacy', headline: '한국, UN 휴전 결의안 공동 발의', detail: '인도적 위기 해소와 호르무즈 재개방을 위한 72시간 휴전 촉구.' }, + { id: 'kr20', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS, category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분 목표"', detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.' }, +]; + +const TYPE_LABELS: Record = { + airstrike: 'STRIKE', + explosion: 'EXPLOSION', + missile_launch: 'LAUNCH', + intercept: 'INTERCEPT', + alert: 'ALERT', + impact: 'IMPACT', + osint: 'OSINT', +}; + +const TYPE_COLORS: Record = { + airstrike: 'var(--kcg-event-airstrike)', + explosion: 'var(--kcg-event-explosion)', + missile_launch: 'var(--kcg-event-missile)', + intercept: 'var(--kcg-event-intercept)', + alert: 'var(--kcg-event-alert)', + impact: 'var(--kcg-event-impact)', + osint: 'var(--kcg-event-osint)', +}; + +// MarineTraffic-style ship type classification +function getShipMTCategory(typecode?: string, category?: string): string { + if (!typecode) { + if (category === 'tanker') return 'tanker'; + if (category === 'cargo') return 'cargo'; + if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military'; + return 'unspecified'; + } + const code = typecode.toUpperCase(); + if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker'; + if (code === 'CONT' || code === 'BULK') return 'cargo'; + if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military'; + if (code.startsWith('A1')) return 'tanker'; + if (code.startsWith('A2') || code.startsWith('A3')) return 'cargo'; + if (code.startsWith('B')) return 'passenger'; + if (code.startsWith('C')) return 'fishing'; + if (code.startsWith('D') || code.startsWith('E')) return 'tug_special'; + return 'unspecified'; +} + +// MarineTraffic-style category colors (labels come from i18n) +const MT_CATEGORY_COLORS: Record = { + cargo: '#8bc34a', + tanker: '#e91e63', + passenger: '#2196f3', + high_speed: '#ff9800', + tug_special: '#00bcd4', + fishing: '#ff5722', + pleasure: '#9c27b0', + military: '#607d8b', + unspecified: '#9e9e9e', +}; + +const NEWS_CATEGORY_ICONS: Record = { + trump: '\u{1F1FA}\u{1F1F8}', + oil: '\u{1F6E2}\u{FE0F}', + diplomacy: '\u{1F310}', + economy: '\u{1F4CA}', +}; + +// OSINT category icons (labels come from i18n) +const OSINT_CAT_ICONS: Record = { + 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 = { + 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 = { + trump: '#ef4444', + oil: '#f59e0b', + diplomacy: '#8b5cf6', + economy: '#3b82f6', +}; + +const EMPTY_OSINT: OsintItem[] = []; +const EMPTY_SHIPS: import('../types').Ship[] = []; + +function useTimeAgo() { + const { t } = useTranslation('common'); + return (ts: number): string => { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return t('time.justNow'); + if (mins < 60) return t('time.minutesAgo', { count: mins }); + const hours = Math.floor(mins / 60); + if (hours < 24) return t('time.hoursAgo', { count: hours }); + const days = Math.floor(hours / 24); + return t('time.daysAgo', { count: days }); + }; +} + +export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS }: Props) { + const { t } = useTranslation(['common', 'events', 'ships']); + const timeAgo = useTimeAgo(); + + const visibleEvents = useMemo( + () => events.filter(e => e.timestamp <= currentTime).reverse(), + [events, currentTime], + ); + + const visibleNews = useMemo( + () => events.length > 0 + ? BREAKING_NEWS.filter(n => n.timestamp <= currentTime).reverse() + : [], + [events.length, currentTime], + ); + + const visibleNewsKR = useMemo( + () => events.length > 0 + ? BREAKING_NEWS_KR.filter(n => n.timestamp <= currentTime).reverse() + : [], + [events.length, currentTime], + ); + + // Iran-related ships (military + Iranian flag) + const _iranMilitaryShips = useMemo(() => + ships.filter(s => + s.flag === 'IR' || + s.category === 'carrier' || s.category === 'destroyer' || + s.category === 'warship' || s.category === 'patrol' + ).sort((a, b) => { + const order: Record = { carrier: 0, destroyer: 1, warship: 2, patrol: 3, tanker: 4, cargo: 5, civilian: 6, unknown: 7 }; + return (order[a.category] ?? 9) - (order[b.category] ?? 9); + }), + [ships], + ); + + return ( +
+ {/* ═══════════════════════════════════════════════ + IRAN TAB + ═══════════════════════════════════════════════ */} + {dashboardTab === 'iran' && ( + <> + {/* Breaking News Section (replay) */} + {visibleNews.length > 0 && ( +
+
+ BREAKING + {t('events:news.breakingTitle')} +
+
+ {visibleNews.map(n => { + const catColor = NEWS_CATEGORY_COLORS[n.category]; + const catIcon = NEWS_CATEGORY_ICONS[n.category]; + const isRecent = currentTime - n.timestamp < 2 * HOUR_MS; + return ( +
+
+ + {catIcon} {t(`events:news.categoryLabel.${n.category}`)} + + + {new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} + +
+
{n.headline}
+ {n.detail &&
{n.detail}
} +
+ ); + })} +
+
+ )} + + {/* Korean Ship Overview (Iran dashboard) */} + {koreanShips.length > 0 && ( +
+
+ {'\u{1F1F0}\u{1F1F7}'} + {t('ships:shipStatus.koreanTitle')} + {koreanShips.length}{t('common:units.vessels')} +
+
+ {koreanShips.slice(0, 30).map(s => { + const cat = getShipMTCategory(s.typecode, s.category); + const mtColor = MT_CATEGORY_COLORS[cat] || '#888'; + const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') }); + return ( +
+ {'\u{1F1F0}\u{1F1F7}'} + {s.name} + + {mtLabel} + + {s.speed != null && s.speed > 0.5 ? ( + {s.speed.toFixed(1)}kn + ) : ( + {t('ships:status.anchored')} + )} +
+ ); + })} +
+
+ )} + + {/* OSINT Live Feed (live mode) */} + {isLive && osintFeed.length > 0 && ( + <> +
+ + {t('events:osint.liveTitle')} + {osintFeed.length} +
+
+ {osintFeed.map(item => { + const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general; + const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general; + const isRecent = Date.now() - item.timestamp < 3600_000; + return ( + +
+ + {catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })} + + {item.language === 'ko' && KR} + {timeAgo(item.timestamp)} +
+
{item.title}
+
{item.source}
+
+ ); + })} +
+ + )} + {isLive && osintFeed.length === 0 && ( +
+ + {t('events:osint.liveTitle')} + {t('events:osint.loading')} +
+ )} + + {/* Event Log (replay mode) */} + {!isLive && ( + <> +

{t('events:log.title')}

+
+ {visibleEvents.length === 0 && ( +
{t('events:log.noEvents')}
+ )} + {visibleEvents.map(e => { + const isNew = currentTime - e.timestamp < 86_400_000; + return ( +
+ + {TYPE_LABELS[e.type]} + +
+
+ {isNew && ( + {t('events:log.new')} + )} + {e.label} +
+
+ {new Date(e.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} KST +
+ {e.description && ( +
{e.description}
+ )} +
+
+ ); + })} +
+ + )} + + )} + + {/* ═══════════════════════════════════════════════ + KOREA TAB + ═══════════════════════════════════════════════ */} + {dashboardTab === 'korea' && ( + <> + {/* 한국 속보 (replay) */} + {visibleNewsKR.length > 0 && ( +
+
+ {t('events:news.breaking')} + {'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')} +
+
+ {visibleNewsKR.map(n => { + const catColor = NEWS_CATEGORY_COLORS[n.category]; + const catIcon = NEWS_CATEGORY_ICONS[n.category]; + const isRecent = currentTime - n.timestamp < 2 * HOUR_MS; + return ( +
+
+ + {catIcon} {t(`events:news.categoryLabel.${n.category}`)} + + + {new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} + +
+
{n.headline}
+ {n.detail &&
{n.detail}
} +
+ ); + })} +
+
+ )} + + {/* 한국 선박 현황 — 선종별 분류 */} +
+
+ {'\u{1F1F0}\u{1F1F7}'} + {t('ships:shipStatus.koreanTitle')} + {koreanShips.length}{t('common:units.vessels')} +
+ {koreanShips.length > 0 && (() => { + const groups: Record = {}; + 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 ( +
+ {sorted.map(cat => { + const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified; + const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') }); + const list = groups[cat]; + const moving = list.filter(s => s.speed > 0.5).length; + const anchored = list.length - moving; + return ( +
+ + {mtLabel} + + {list.length}{t('common:units.vessels')} + + + {moving > 0 && {t('ships:status.underway')} {moving}} + {anchored > 0 && {t('ships:status.anchored')} {anchored}} + +
+ ); + })} +
+ ); + })()} +
+ + {/* 중국 선박 현황 */} +
+
+ {'\u{1F1E8}\u{1F1F3}'} + {t('ships:shipStatus.chineseTitle')} + {chineseShips.length}{t('common:units.vessels')} +
+ {chineseShips.length > 0 && (() => { + const groups: Record = {}; + for (const s of chineseShips) { + const cat = getShipMTCategory(s.typecode, s.category); + if (!groups[cat]) groups[cat] = []; + groups[cat].push(s); + } + const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified']; + const sorted = order.filter(k => groups[k]?.length); + const fishingCount = groups['fishing']?.length || 0; + + return ( +
+ {fishingCount > 0 && ( +
+ {'\u{1F6A8}'} + + {t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })} + +
+ )} + {sorted.map(cat => { + const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified; + const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') }); + const list = groups[cat]; + const moving = list.filter(s => s.speed > 0.5).length; + const anchored = list.length - moving; + return ( +
+ + {mtLabel} + + {list.length}{t('common:units.vessels')} + + + {moving > 0 && {t('ships:status.underway')} {moving}} + {anchored > 0 && {t('ships:status.anchored')} {anchored}} + +
+ ); + })} +
+ ); + })()} +
+ + {/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */} + {osintFeed.length > 0 && ( + <> +
+ + {'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')} + {(() => { + const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil'); + const seen = new Set(); + return filtered.filter(i => { + const key = i.title.replace(/\s+/g, '').slice(0, 30).toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }).length; + })()} +
+
+ {(() => { + const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil'); + const seen = new Set(); + return filtered.filter(item => { + const key = item.title.replace(/\s+/g, '').slice(0, 30).toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + })().map(item => { + const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general; + const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general; + const isRecent = Date.now() - item.timestamp < 3600_000; + return ( + +
+ + {catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })} + + {item.language === 'ko' && KR} + {timeAgo(item.timestamp)} +
+
{item.title}
+
{item.source}
+
+ ); + })} +
+ + )} + {osintFeed.length === 0 && ( +
+ + {'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')} + {t('events:osint.loading')} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/EventStrip.tsx b/frontend/src/components/EventStrip.tsx new file mode 100644 index 0000000..8a72c87 --- /dev/null +++ b/frontend/src/components/EventStrip.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GeoEvent } from '../types'; + +interface Props { + events: GeoEvent[]; + currentTime: number; + startTime: number; + endTime: number; + onEventClick: (event: GeoEvent) => void; +} + +const KST_OFFSET = 9 * 3600_000; + +const TYPE_COLORS: Record = { + airstrike: '#ef4444', + explosion: '#f97316', + missile_launch: '#eab308', + intercept: '#3b82f6', + alert: '#a855f7', + impact: '#ff0000', + osint: '#06b6d4', +}; + +const TYPE_KEYS: Record = { + airstrike: 'event.airstrike', + explosion: 'event.explosion', + missile_launch: 'event.missileLaunch', + intercept: 'event.intercept', + alert: 'event.alert', + impact: 'event.impact', + osint: 'event.osint', +}; + +const SOURCE_KEYS: Record = { + US: 'source.US', + IL: 'source.IL', + IR: 'source.IR', + proxy: 'source.proxy', +}; + +interface EventGroup { + dateKey: string; // "2026-03-01" + dateLabel: string; // "03/01 (토)" + events: GeoEvent[]; +} + +const DAY_NAME_KEYS = [ + 'dayNames.sun', 'dayNames.mon', 'dayNames.tue', 'dayNames.wed', + 'dayNames.thu', 'dayNames.fri', 'dayNames.sat', +]; + +export function EventStrip({ events, currentTime, onEventClick }: Props) { + const [openDate, setOpenDate] = useState(null); + const { t } = useTranslation(); + + const groups = useMemo(() => { + const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp); + const map = new Map(); + + for (const ev of sorted) { + const d = new Date(ev.timestamp + KST_OFFSET); + const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(ev); + } + + const result: EventGroup[] = []; + for (const [dateKey, evs] of map) { + const d = new Date(evs[0].timestamp + KST_OFFSET); + const dayName = t(DAY_NAME_KEYS[d.getUTCDay()]); + const dateLabel = `${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} (${dayName})`; + result.push({ dateKey, dateLabel, events: evs }); + } + return result; + }, [events, t]); + + // Auto-open the first group if none selected + const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null); + + const formatTimeKST = (ts: number) => { + const d = new Date(ts + KST_OFFSET); + return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + }; + + return ( +
+ {/* Date tabs */} +
+ STRIKES + {groups.map(g => { + const isActive = effectiveOpen === g.dateKey; + const passedCount = g.events.filter(e => e.timestamp <= currentTime).length; + return ( + + ); + })} +
+ + {/* Expanded event list for selected date */} + {effectiveOpen && (() => { + const group = groups.find(g => g.dateKey === effectiveOpen); + if (!group) return null; + return ( +
+ {group.events.map(ev => { + const isPast = ev.timestamp <= currentTime; + const color = TYPE_COLORS[ev.type] || '#888'; + const source = ev.source ? t(SOURCE_KEYS[ev.source] ?? ev.source) : ''; + const typeLabel = t(TYPE_KEYS[ev.type] ?? ev.type); + + return ( + + ); + })} +
+ ); + })()} +
+ ); +} diff --git a/frontend/src/components/GlobeMap.tsx b/frontend/src/components/GlobeMap.tsx new file mode 100644 index 0000000..109bdc5 --- /dev/null +++ b/frontend/src/components/GlobeMap.tsx @@ -0,0 +1,231 @@ +import { useRef, useEffect } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import { countryLabelsGeoJSON } from '../data/countryLabels'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; + +interface Props { + events: GeoEvent[]; + currentTime: number; + aircraft: Aircraft[]; + satellites: SatellitePosition[]; + ships: Ship[]; + layers: LayerVisibility; +} + +const EVENT_COLORS: Record = { + airstrike: '#ef4444', + explosion: '#f97316', + missile_launch: '#eab308', + intercept: '#3b82f6', + alert: '#a855f7', + impact: '#ff0000', + osint: '#06b6d4', +}; + +// Navy flag-based colors for military vessels +const NAVY_COLORS: Record = { + US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff', + IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', +}; +const SHIP_COLORS: Record = { + carrier: '#ef4444', + destroyer: '#f97316', + warship: '#fb923c', + patrol: '#fbbf24', + submarine: '#8b5cf6', + tanker: '#22d3ee', + cargo: '#94a3b8', + civilian: '#64748b', +}; +const MIL_SHIP_CATS = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol']; +function getGlobeShipColor(cat: string, flag?: string): string { + if (MIL_SHIP_CATS.includes(cat) && flag && NAVY_COLORS[flag]) return NAVY_COLORS[flag]; + return SHIP_COLORS[cat] || '#64748b'; +} + +const AC_COLORS: Record = { + fighter: '#ef4444', + bomber: '#dc2626', + surveillance: '#f59e0b', + tanker: '#22d3ee', + transport: '#10b981', + cargo: '#6366f1', + helicopter: '#a855f7', + civilian: '#64748b', + unknown: '#475569', +}; + +export function GlobeMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + + // Initialize map + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: { + version: 8, + sources: { + 'dark-tiles': { + type: 'raster', + tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'], + tileSize: 256, + attribution: '© OpenStreetMap', + }, + }, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#0a0a1a' }, + }, + { + id: 'dark-tiles', + type: 'raster', + source: 'dark-tiles', + }, + ], + projection: { type: 'globe' }, + } as maplibregl.StyleSpecification, + center: [44, 31.5], + zoom: 3, + pitch: 20, + }); + + map.addControl(new maplibregl.NavigationControl(), 'top-right'); + + map.on('load', () => { + map.addSource('country-labels', { type: 'geojson', data: countryLabelsGeoJSON() }); + map.addLayer({ + id: 'country-label-lg', type: 'symbol', source: 'country-labels', + filter: ['==', ['get', 'rank'], 1], + layout: { + 'text-field': ['get', 'name'], 'text-size': 14, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-allow-overlap': false, 'text-padding': 6, + }, + paint: { 'text-color': '#e2e8f0', 'text-halo-color': '#000', 'text-halo-width': 2, 'text-opacity': 0.9 }, + }); + map.addLayer({ + id: 'country-label-md', type: 'symbol', source: 'country-labels', + filter: ['==', ['get', 'rank'], 2], + layout: { + 'text-field': ['get', 'name'], 'text-size': 11, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-allow-overlap': false, 'text-padding': 4, + }, + paint: { 'text-color': '#94a3b8', 'text-halo-color': '#000', 'text-halo-width': 1.5, 'text-opacity': 0.85 }, + }); + map.addLayer({ + id: 'country-label-sm', type: 'symbol', source: 'country-labels', + filter: ['==', ['get', 'rank'], 3], minzoom: 5, + layout: { + 'text-field': ['get', 'name'], 'text-size': 10, + 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], + 'text-allow-overlap': false, 'text-padding': 2, + }, + paint: { 'text-color': '#64748b', 'text-halo-color': '#000', 'text-halo-width': 1, 'text-opacity': 0.75 }, + }); + }); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + // Update markers — DOM direct manipulation, inline styles intentionally kept + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + // Clear old markers + for (const m of markersRef.current) m.remove(); + markersRef.current = []; + + const addMarker = (lng: number, lat: number, color: string, size: number, tooltip: string) => { + const el = document.createElement('div'); + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.borderRadius = '50%'; + el.style.background = color; + el.style.border = `1.5px solid ${color}`; + el.style.boxShadow = `0 0 ${size}px ${color}80`; + el.style.cursor = 'pointer'; + el.title = tooltip; + + const marker = new maplibregl.Marker({ element: el }) + .setLngLat([lng, lat]) + .addTo(map); + markersRef.current.push(marker); + }; + + const addTriangle = (lng: number, lat: number, color: string, size: number, heading: number, tooltip: string) => { + const el = document.createElement('div'); + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.transform = `rotate(${heading}deg)`; + el.style.cursor = 'pointer'; + el.title = tooltip; + el.innerHTML = ` + + `; + + const marker = new maplibregl.Marker({ element: el }) + .setLngLat([lng, lat]) + .addTo(map); + markersRef.current.push(marker); + }; + + // Events + if (layers.events) { + const visible = events.filter(e => e.timestamp <= currentTime); + for (const e of visible) { + const color = EVENT_COLORS[e.type] || '#888'; + const size = e.type === 'impact' ? 14 : 8; + addMarker(e.lng, e.lat, color, size, `${e.label}\n${new Date(e.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST`); + } + } + + // Aircraft + if (layers.aircraft) { + const filtered = layers.militaryOnly + ? aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown') + : aircraft; + for (const ac of filtered) { + const color = AC_COLORS[ac.category] || '#64748b'; + addTriangle(ac.lng, ac.lat, color, 10, ac.heading || 0, + `${ac.callsign || ac.icao24} [${ac.category}]\nAlt: ${ac.altitude?.toFixed(0) || '?'}ft`); + } + } + + // Satellites + if (layers.satellites) { + for (const sat of satellites) { + addMarker(sat.lng, sat.lat, '#ef4444', 5, `${sat.name}\nAlt: ${sat.altitude?.toFixed(0)}km`); + } + } + + // Ships + if (layers.ships) { + const filtered = layers.militaryOnly + ? ships.filter(s => !['civilian', 'cargo', 'tanker'].includes(s.category)) + : ships; + for (const s of filtered) { + const color = getGlobeShipColor(s.category, s.flag); + addTriangle(s.lng, s.lat, color, 10, s.heading || 0, + `${s.name} [${s.category}]\n${s.flag || ''}`); + } + } + }, [events, currentTime, aircraft, satellites, ships, layers]); + + return ( +
+ ); +} diff --git a/frontend/src/components/InfraLayer.tsx b/frontend/src/components/InfraLayer.tsx new file mode 100644 index 0000000..5681159 --- /dev/null +++ b/frontend/src/components/InfraLayer.tsx @@ -0,0 +1,171 @@ +import { useMemo, useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import type { PowerFacility } from '../services/infra'; + +// SVG Wind Turbine Icon +function WindTurbineIcon({ color, size = 14 }: { color: string; size?: number }) { + return ( + + {/* Tower */} + + + {/* Hub */} + + {/* Blade 1 - top */} + + {/* Blade 2 - bottom-right */} + + {/* Blade 3 - bottom-left */} + + {/* Base */} + + + ); +} + +interface Props { + facilities: PowerFacility[]; +} + +// Source → icon & color +const SOURCE_STYLE: Record = { + nuclear: { icon: '☢️', color: '#e040fb', label: '원자력' }, + coal: { icon: '🏭', color: '#795548', label: '석탄' }, + gas: { icon: '🔥', color: '#ff9800', label: 'LNG' }, + oil: { icon: '🛢️', color: '#5d4037', label: '석유' }, + hydro: { icon: '💧', color: '#2196f3', label: '수력' }, + solar: { icon: '☀️', color: '#ffc107', label: '태양광' }, + wind: { icon: '🌀', color: '#00bcd4', label: '풍력' }, + biomass: { icon: '🌿', color: '#4caf50', label: '바이오' }, +}; + +const SUBSTATION_STYLE = { icon: '⚡', color: '#ffeb3b', label: '변전소' }; + +function getStyle(f: PowerFacility) { + if (f.type === 'substation') return SUBSTATION_STYLE; + return SOURCE_STYLE[f.source || ''] || { icon: '⚡', color: '#9e9e9e', label: '발전소' }; +} + +function formatVoltage(v?: string): string { + if (!v) return ''; + const kv = parseInt(v) / 1000; + if (isNaN(kv)) return v; + return `${kv}kV`; +} + +export function InfraLayer({ facilities }: Props) { + const [selectedId, setSelectedId] = useState(null); + + const plants = useMemo(() => facilities.filter(f => f.type === 'plant'), [facilities]); + const substations = useMemo(() => facilities.filter(f => f.type === 'substation'), [facilities]); + + const selected = selectedId ? facilities.find(f => f.id === selectedId) ?? null : null; + + return ( + <> + {/* Substations — smaller, show at higher zoom */} + {substations.map(f => { + const s = getStyle(f); + return ( + { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}> +
+ {s.icon} +
+
+ ); + })} + + {/* Power plants — larger, always visible */} + {plants.map(f => { + const s = getStyle(f); + const isWind = f.source === 'wind'; + return ( + { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}> +
+ {isWind ? ( + + ) : ( +
+ {s.icon} +
+ )} +
+ {f.name.length > 10 ? f.name.slice(0, 10) + '..' : f.name} +
+
+
+ ); + })} + + {/* Popup */} + {selected && ( + setSelectedId(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ {getStyle(selected).icon} + {selected.name} +
+
+ + {getStyle(selected).label} + + + {selected.type === 'plant' ? '발전소' : '변전소'} + +
+
+ {selected.output && ( +
출력: {selected.output}
+ )} + {selected.voltage && ( +
전압: {formatVoltage(selected.voltage)}
+ )} + {selected.operator && ( +
운영: {selected.operator}
+ )} + {selected.source && ( +
연료: {selected.source}
+ )} +
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+
+ )} + + ); +} diff --git a/frontend/src/components/KoreaAirportLayer.tsx b/frontend/src/components/KoreaAirportLayer.tsx new file mode 100644 index 0000000..a95a18d --- /dev/null +++ b/frontend/src/components/KoreaAirportLayer.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { KOREAN_AIRPORTS } from '../services/airports'; +import type { KoreanAirport } from '../services/airports'; + +export function KoreaAirportLayer() { + const [selected, setSelected] = useState(null); + const { t } = useTranslation(); + + return ( + <> + {KOREAN_AIRPORTS.map(ap => { + const isIntl = ap.intl; + const color = isIntl ? '#a78bfa' : '#7c8aaa'; + const size = isIntl ? 20 : 16; + return ( + { e.originalEvent.stopPropagation(); setSelected(ap); }}> +
+ + + + +
+ {ap.nameKo.replace('국제공항', '').replace('공항', '')} +
+
+
+ ); + })} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="260px" className="gl-popup"> +
+
+ {selected.nameKo} +
+
+ {selected.intl && ( + + {t('airport.international')} + + )} + {selected.domestic && ( + + {t('airport.domestic')} + + )} + + {selected.id} / {selected.icao} + +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/KoreaMap.tsx b/frontend/src/components/KoreaMap.tsx new file mode 100644 index 0000000..cd49aaf --- /dev/null +++ b/frontend/src/components/KoreaMap.tsx @@ -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; + osintFeed: OsintItem[]; + currentTime: number; + koreaFilters: KoreaFiltersState; + transshipSuspects: Set; + cableWatchSuspects: Set; + dokdoWatchSuspects: Set; + dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; +} + +// MarineTraffic-style: satellite + dark ocean + nautical overlay +const MAP_STYLE = { + version: 8 as const, + sources: { + 'satellite': { + type: 'raster' as const, + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ], + tileSize: 256, + maxzoom: 19, + attribution: '© Esri, Maxar', + }, + 'carto-dark': { + type: 'raster' as const, + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + ], + tileSize: 256, + }, + 'opensea': { + type: 'raster' as const, + tiles: [ + 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', + ], + tileSize: 256, + maxzoom: 18, + }, + }, + layers: [ + { id: 'background', type: 'background' as const, paint: { 'background-color': '#0d1f3c' } }, + { id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } }, + { id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } }, + { id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } }, + ], +}; + +// Korea-centered view +const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 }; +const KOREA_MAP_ZOOM = 6; + +const FILTER_ICON: Record = { + illegalFishing: '\u{1F6AB}\u{1F41F}', + illegalTransship: '\u2693', + darkVessel: '\u{1F47B}', + cableWatch: '\u{1F50C}', + dokdoWatch: '\u{1F3DD}\uFE0F', + ferryWatch: '\u{1F6A2}', +}; + +const FILTER_COLOR: Record = { + illegalFishing: '#ef4444', + illegalTransship: '#f97316', + darkVessel: '#8b5cf6', + cableWatch: '#00e5ff', + dokdoWatch: '#22c55e', + ferryWatch: '#2196f3', +}; + +const FILTER_I18N_KEY: Record = { + 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(null); + const [infra, setInfra] = useState([]); + + useEffect(() => { + fetchKoreaInfra().then(setInfra).catch(() => {}); + }, []); + + return ( + + + + + + + + + + {layers.ships && } + {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} + {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( + +
+ {`\u26A0 ${t('korea.transshipSuspect')}`} +
+
+ ))} + {/* Cable watch suspect labels */} + {cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => ( + +
+ {`\u{1F50C} ${t('korea.cableDanger')}`} +
+
+ ))} + {/* 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 ( + +
+ {inTerritorial ? '\u{1F6A8}' : '\u26A0'} {'\u{1F1EF}\u{1F1F5}'} {inTerritorial ? t('korea.dokdoIntrusion') : t('korea.dokdoApproach')} {`${dist}km`} +
+
+ ); + })} + {layers.infra && infra.length > 0 && } + {layers.satellites && satellites.length > 0 && } + {layers.aircraft && aircraft.length > 0 && } + {layers.cables && } + {layers.cctv && } + {layers.airports && } + {layers.coastGuard && } + {layers.navWarning && } + {layers.osint && } + {layers.eez && } + {layers.piracy && } + + {/* Filter Status Banner */} + {(() => { + const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]); + if (active.length === 0) return null; + return ( +
+ {active.map(k => { + const color = FILTER_COLOR[k]; + return ( +
+ {FILTER_ICON[k]} + {t(FILTER_I18N_KEY[k])} +
+ ); + })} +
+ {t('korea.detected', { count: ships.length })} +
+
+ ); + })()} + + {/* Dokdo alert panel */} + {dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && ( +
+
+ {`\u{1F6A8} ${t('korea.dokdoAlerts')}`} +
+ {dokdoAlerts.map((a, i) => ( +
+
+ + {a.dist < 22 ? `\u{1F6A8} ${t('korea.territorialIntrusion')}` : `\u26A0 ${t('korea.approachWarning')}`} + + + {new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + +
+
+ {`\u{1F1EF}\u{1F1F5} ${a.name} \u2014 ${t('korea.dokdoDistance', { dist: a.dist })}`} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx new file mode 100644 index 0000000..72fd261 --- /dev/null +++ b/frontend/src/components/LayerPanel.tsx @@ -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 = { + 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 = { + 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; + onToggle: (key: string) => void; + aircraftByCategory: Record; + aircraftTotal: number; + shipsByMtCategory: Record; + shipTotal: number; + satelliteCount: number; + extraLayers?: ExtraLayer[]; + hiddenAcCategories: Set; + hiddenShipCategories: Set; + 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>(new Set(['aircraft', 'ships'])); + const [legendOpen, setLegendOpen] = useState>(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 ( +
+

LAYERS

+
+ {/* Aircraft tree */} + onToggle('aircraft')} + onExpand={() => toggleExpand('aircraft')} + /> + {layers.aircraft && expanded.has('aircraft') && ( +
+ {AC_CATEGORIES.map(cat => { + const count = aircraftByCategory[cat] || 0; + if (count === 0) return null; + return ( +
+ )} + + {/* Ships tree */} + onToggle('ships')} + onExpand={() => toggleExpand('ships')} + /> + {layers.ships && expanded.has('ships') && ( +
+ {MT_CATEGORIES.map(cat => { + const count = shipsByMtCategory[cat] || 0; + if (count === 0) return null; + return ( +
+ )} + + {/* Satellites (simple toggle) */} + onToggle('satellites')} + /> + + {/* Extra layers (tab-specific) */} + {extraLayers && extraLayers.map(el => ( + onToggle(el.key)} + /> + ))} + +
+ + {/* Military only filter */} + onToggle('militaryOnly')} + /> +
+
+ ); +} + +/* ── 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 ( +
+ {expandable ? ( + { e.stopPropagation(); onExpand?.(); }} + > + {'\u25B6'} + + ) : ( + + )} + +
+ ); +} + +function CategoryToggle({ + label, + color, + count, + hidden, + onClick, +}: { + label: string; + color: string; + count: number; + hidden: boolean; + onClick: () => void; +}) { + return ( +
+ + {label} + {count} +
+ ); +} diff --git a/frontend/src/components/LiveControls.tsx b/frontend/src/components/LiveControls.tsx new file mode 100644 index 0000000..d5ba44f --- /dev/null +++ b/frontend/src/components/LiveControls.tsx @@ -0,0 +1,57 @@ +import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; + +interface Props { + currentTime: number; + historyMinutes: number; + onHistoryChange: (minutes: number) => void; + aircraftCount: number; + shipCount: number; + satelliteCount: number; +} + +const HISTORY_PRESETS = [ + { label: '30M', minutes: 30 }, + { label: '1H', minutes: 60 }, + { label: '3H', minutes: 180 }, + { label: '6H', minutes: 360 }, + { label: '12H', minutes: 720 }, + { label: '24H', minutes: 1440 }, +]; + +export function LiveControls({ + currentTime, + historyMinutes, + onHistoryChange, +}: Props) { + const { t } = useTranslation(); + const kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'"); + + return ( +
+
+ + {t('header.live')} +
+ +
{kstTime}
+ +
+ +
+ {t('time.history')} +
+ {HISTORY_PRESETS.map(p => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/NavWarningLayer.tsx b/frontend/src/components/NavWarningLayer.tsx new file mode 100644 index 0000000..f8bdf2b --- /dev/null +++ b/frontend/src/components/NavWarningLayer.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning'; +import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning'; + +const LEVEL_COLOR: Record = { + danger: '#ef4444', + caution: '#eab308', + info: '#3b82f6', +}; + +const ORG_COLOR: Record = { + '해군': '#8b5cf6', + '해병대': '#22c55e', + '공군': '#f97316', + '육군': '#ef4444', + '해경': '#3b82f6', + '국과연': '#eab308', +}; + +function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) { + const color = ORG_COLOR[org]; + + if (level === 'danger') { + return ( + + + + + + ); + } + + return ( + + + + + + ); +} + +export function NavWarningLayer() { + const [selected, setSelected] = useState(null); + const { t } = useTranslation(); + + return ( + <> + {NAV_WARNINGS.map(w => { + const color = ORG_COLOR[w.org]; + const size = w.level === 'danger' ? 16 : 14; + return ( + { e.originalEvent.stopPropagation(); setSelected(w); }}> +
+ +
+ {w.id} +
+
+
+ ); + })} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ {selected.title} +
+
+ + {NW_LEVEL_LABEL[selected.level]} + + + {NW_ORG_LABEL[selected.org]} + + + {selected.area} + +
+
+ {selected.description} +
+
+
{t('navWarning.altitude')}: {selected.altitude}
+
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
+
{t('navWarning.source')}: {selected.source}
+
+ {t('navWarning.khoaLink')} +
+
+ )} + + ); +} diff --git a/frontend/src/components/OilFacilityLayer.tsx b/frontend/src/components/OilFacilityLayer.tsx new file mode 100644 index 0000000..8e95089 --- /dev/null +++ b/frontend/src/components/OilFacilityLayer.tsx @@ -0,0 +1,310 @@ +import { memo, useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { useTranslation } from 'react-i18next'; +import type { OilFacility, OilFacilityType } from '../types'; + +interface Props { + facilities: OilFacility[]; + currentTime: number; +} + +const TYPE_COLORS: Record = { + refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1', + terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4', +}; + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +function getTooltipLabel(f: OilFacility): string { + if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`; + if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`; + if (f.reservesBbl) return `${f.reservesBbl}B bbl`; + if (f.reservesTcf) return `${f.reservesTcf} Tcf`; + if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`; + return ''; +} + +function getIconSize(f: OilFacility): number { + if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; } + if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16; + if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; } + if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; } + if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; } + return 16; +} + +// Shared damage overlay (X mark + circle) +function DamageOverlay() { + return ( + <> + + + + + ); +} + +// SVG icon renderers (JSX versions) +function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + const sc = damaged ? '#ff0000' : color; + return ( + + + + + + + + + + + + + + + + + + + + + {damaged && } + + ); +} + +function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + const sc = damaged ? '#ff0000' : color; + return ( + + + + + + + + + + + + + + + {damaged && } + + ); +} + +function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + return ( + + + + + + + + + + + + + + {damaged && } + + ); +} + +function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + return ( + + + + + + + + + + + {damaged && } + + ); +} + +function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + return ( + + + + + + + + {damaged && } + + ); +} + +function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) { + const sc = damaged ? '#ff0000' : color; + return ( + + + + + + + + + + + + + + + {damaged && } + + ); +} + +function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) { + const color = TYPE_COLORS[facility.type]; + const size = getIconSize(facility); + switch (facility.type) { + case 'refinery': return ; + case 'oilfield': return ; + case 'gasfield': return ; + case 'terminal': return ; + case 'petrochemical': return ; + case 'desalination': return ; + } +} + +export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) { + return ( + <> + {facilities.map(f => ( + + ))} + + ); +}); + +function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) { + const { t } = useTranslation('ships'); + const [showPopup, setShowPopup] = useState(false); + const color = TYPE_COLORS[facility.type]; + const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt); + const isPlanned = !!facility.planned && !isDamaged; + const stat = getTooltipLabel(facility); + + return ( + <> + +
+ {/* Planned strike targeting ring */} + {isPlanned && ( +
+ {/* Crosshair lines */} +
+
+
+
+
+ )} +
{ e.stopPropagation(); setShowPopup(true); }}> + +
+
+ {isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo} + {stat && {stat}} +
+
+ + {showPopup && ( + setShowPopup(false)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ {t(`facility.type.${facility.type}`)} + {isDamaged && ( + + {t('facility.damaged')} + + )} + {isPlanned && ( + + {t('facility.plannedStrike')} + + )} +
+
{facility.nameKo}
+
{facility.name}
+
+ {facility.capacityBpd != null && ( + <>{t('facility.production')} + {formatNumber(facility.capacityBpd)} bpd + )} + {facility.capacityMgd != null && ( + <>{t('facility.desalProduction')} + {formatNumber(facility.capacityMgd)} MGD + )} + {facility.capacityMcfd != null && ( + <>{t('facility.gasProduction')} + {formatNumber(facility.capacityMcfd)} Mcf/d + )} + {facility.reservesBbl != null && ( + <>{t('facility.reserveOil')} + {facility.reservesBbl}B {t('facility.barrels')} + )} + {facility.reservesTcf != null && ( + <>{t('facility.reserveGas')} + {facility.reservesTcf} Tcf + )} + {facility.operator && ( + <>{t('facility.operator')} + {facility.operator} + )} +
+ {facility.description && ( +

{facility.description}

+ )} + {isPlanned && facility.plannedLabel && ( +
+ {facility.plannedLabel} +
+ )} +
+ {facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/OsintMapLayer.tsx b/frontend/src/components/OsintMapLayer.tsx new file mode 100644 index 0000000..2fa3d6d --- /dev/null +++ b/frontend/src/components/OsintMapLayer.tsx @@ -0,0 +1,126 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import type { OsintItem } from '../services/osint'; + +const CAT_COLOR: Record = { + maritime_accident: '#ef4444', + fishing: '#22c55e', + maritime_traffic: '#3b82f6', + military: '#f97316', + shipping: '#eab308', +}; + +const CAT_ICON: Record = { + maritime_accident: '🚨', + fishing: '🐟', + maritime_traffic: '🚢', + military: '🎯', + shipping: '🚢', +}; + +function useTimeAgo() { + const { t } = useTranslation(); + return (ts: number): string => { + const diff = Date.now() - ts; + const m = Math.floor(diff / 60000); + if (m < 60) return t('time.minutesAgo', { count: m }); + const h = Math.floor(m / 60); + if (h < 24) return t('time.hoursAgo', { count: h }); + return t('time.daysAgo', { count: Math.floor(h / 24) }); + }; +} + +interface Props { + osintFeed: OsintItem[]; + currentTime: number; +} + +const THREE_HOURS = 3 * 60 * 60 * 1000; +const ONE_HOUR = 3600000; +const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffic', 'shipping', 'military']); + +export function OsintMapLayer({ osintFeed, currentTime }: Props) { + const [selected, setSelected] = useState(null); + const { t } = useTranslation(); + const timeAgo = useTimeAgo(); + + const geoItems = useMemo(() => osintFeed.filter( + (item): item is OsintItem & { lat: number; lng: number } => + item.lat != null && item.lng != null + && MAP_CATEGORIES.has(item.category) + && (currentTime - item.timestamp) < THREE_HOURS + ), [osintFeed, currentTime]); + + return ( + <> + {geoItems.map(item => { + const color = CAT_COLOR[item.category] || '#888'; + const isRecent = currentTime - item.timestamp < ONE_HOUR; + return ( + { e.originalEvent.stopPropagation(); setSelected(item); }}> +
+
+ {CAT_ICON[item.category] || '📰'} +
+ {isRecent && ( +
+ NEW +
+ )} +
+
+ ); + })} + + {selected && selected.lat != null && selected.lng != null && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ {CAT_ICON[selected.category] || '📰'} + OSINT +
+
+ {selected.title} +
+
+ + {selected.category.replace('_', ' ').toUpperCase()} + + + {selected.source} + + + {timeAgo(selected.timestamp)} + +
+ {selected.url && ( + {t('osintMap.viewOriginal')} + )} +
+
+ )} + + ); +} diff --git a/frontend/src/components/PiracyLayer.tsx b/frontend/src/components/PiracyLayer.tsx new file mode 100644 index 0000000..89bc2e8 --- /dev/null +++ b/frontend/src/components/PiracyLayer.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy'; +import type { PiracyZone } from '../services/piracy'; + +function SkullIcon({ color, size }: { color: string; size: number }) { + return ( + + + + + + + + + + ); +} + +export function PiracyLayer() { + const [selected, setSelected] = useState(null); + const { t } = useTranslation(); + + return ( + <> + {PIRACY_ZONES.map(zone => { + const color = PIRACY_LEVEL_COLOR[zone.level]; + const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20; + return ( + { e.originalEvent.stopPropagation(); setSelected(zone); }}> +
+ +
+ {PIRACY_LEVEL_LABEL[zone.level]} +
+
+
+ ); + })} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="340px" className="gl-popup"> +
+
+ ☠️ + {selected.nameKo} +
+ +
+ + {PIRACY_LEVEL_LABEL[selected.level]} + + + {selected.name} + + {selected.recentIncidents != null && ( + + {t('piracy.recentIncidents', { count: selected.recentIncidents })} + + )} +
+ +
+ {selected.description} +
+
+ {selected.detail} +
+
+ {selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/ReplayControls.tsx b/frontend/src/components/ReplayControls.tsx new file mode 100644 index 0000000..d90b05e --- /dev/null +++ b/frontend/src/components/ReplayControls.tsx @@ -0,0 +1,170 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface Props { + isPlaying: boolean; + speed: number; + startTime: number; + endTime: number; + onPlay: () => void; + onPause: () => void; + onReset: () => void; + onSpeedChange: (speed: number) => void; + onRangeChange: (start: number, end: number) => void; +} + +const SPEEDS = [1, 2, 4, 8, 16]; + +// Preset ranges relative to T0 (main strike moment) +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; + +const PRESETS = [ + { label: '24H', start: T0 - 12 * HOUR, end: T0 + 12 * HOUR }, + { label: '12H', start: T0 - 6 * HOUR, end: T0 + 6 * HOUR }, + { label: '6H', start: T0 - 3 * HOUR, end: T0 + 3 * HOUR }, + { label: '2H', start: T0 - HOUR, end: T0 + HOUR }, + { label: '30M', start: T0 - 15 * 60_000, end: T0 + 15 * 60_000 }, +]; + +const KST_OFFSET = 9 * 3600_000; // KST = UTC+9 + +function toKSTInput(ts: number): string { + // Format as datetime-local value in KST: YYYY-MM-DDTHH:MM + const d = new Date(ts + KST_OFFSET); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +} + +function fromKSTInput(val: string): number { + // Parse datetime-local as KST → convert to UTC + return new Date(val + 'Z').getTime() - KST_OFFSET; +} + +export function ReplayControls({ + isPlaying, + speed, + startTime, + endTime, + onPlay, + onPause, + onReset, + onSpeedChange, + onRangeChange, +}: Props) { + const { t } = useTranslation(); + const [showPicker, setShowPicker] = useState(false); + const [customStart, setCustomStart] = useState(toKSTInput(startTime)); + const [customEnd, setCustomEnd] = useState(toKSTInput(endTime)); + + const handlePreset = useCallback((preset: typeof PRESETS[number]) => { + onRangeChange(preset.start, preset.end); + setCustomStart(toKSTInput(preset.start)); + setCustomEnd(toKSTInput(preset.end)); + }, [onRangeChange]); + + const handleCustomApply = useCallback(() => { + const s = fromKSTInput(customStart); + const e = fromKSTInput(customEnd); + if (s < e) { + onRangeChange(s, e); + setShowPicker(false); + } + }, [customStart, customEnd, onRangeChange]); + + // Find which preset is active + const activePreset = PRESETS.find(p => p.start === startTime && p.end === endTime); + + return ( +
+ {/* Left: transport controls */} + + + + +
+ {SPEEDS.map(s => ( + + ))} +
+ + {/* Spacer */} +
+ + {/* Right: range presets + custom picker */} +
+
+ {PRESETS.map(p => ( + + ))} + +
+ + {showPicker && ( +
+
+ + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ReplayMap.tsx b/frontend/src/components/ReplayMap.tsx new file mode 100644 index 0000000..b924362 --- /dev/null +++ b/frontend/src/components/ReplayMap.tsx @@ -0,0 +1,432 @@ +import { useEffect, useMemo, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; +import type { MapRef } from 'react-map-gl/maplibre'; +import { AircraftLayer } from './AircraftLayer'; +import { SatelliteLayer } from './SatelliteLayer'; +import { ShipLayer } from './ShipLayer'; +import { DamagedShipLayer } from './DamagedShipLayer'; +import { OilFacilityLayer } from './OilFacilityLayer'; +import { AirportLayer } from './AirportLayer'; +import { iranOilFacilities } from '../data/oilFacilities'; +import { middleEastAirports } from '../data/airports'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; +import { countryLabelsGeoJSON } from '../data/countryLabels'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +export interface FlyToTarget { + lat: number; + lng: number; + zoom?: number; +} + +interface Props { + events: GeoEvent[]; + currentTime: number; + aircraft: Aircraft[]; + satellites: SatellitePosition[]; + ships: Ship[]; + layers: LayerVisibility; + flyToTarget?: FlyToTarget | null; + onFlyToDone?: () => void; + initialCenter?: { lng: number; lat: number }; + initialZoom?: number; +} + +// MarineTraffic-style: dark ocean + satellite land + nautical overlay +const MAP_STYLE = { + version: 8 as const, + sources: { + 'satellite': { + type: 'raster' as const, + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ], + tileSize: 256, + maxzoom: 19, + attribution: '© Esri, Maxar', + }, + 'carto-dark': { + type: 'raster' as const, + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + ], + tileSize: 256, + }, + 'opensea': { + type: 'raster' as const, + tiles: [ + 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', + ], + tileSize: 256, + maxzoom: 18, + }, + }, + layers: [ + { id: 'background', type: 'background' as const, paint: { 'background-color': '#0b1526' } }, + { id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.45, 'raster-saturation': -0.3, 'raster-contrast': 0.1 } }, + { id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.55 } }, + { id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.6 } }, + ], +}; + +const EVENT_COLORS: Record = { + airstrike: '#ef4444', + explosion: '#f97316', + missile_launch: '#eab308', + intercept: '#3b82f6', + alert: '#a855f7', + impact: '#ff0000', + osint: '#06b6d4', +}; + +const SOURCE_COLORS: Record = { + US: '#ef4444', + IL: '#22c55e', + IR: '#ff0000', + proxy: '#f59e0b', +}; + +function getEventColor(event: GeoEvent): string { + if (event.type === 'impact') return '#ff0000'; + if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source]; + return EVENT_COLORS[event.type]; +} + +const EVENT_RADIUS: Record = { + airstrike: 12, + explosion: 10, + missile_launch: 8, + intercept: 7, + alert: 6, + impact: 14, + osint: 8, +}; + +export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) { + const { t } = useTranslation(); + const mapRef = useRef(null); + const [selectedEventId, setSelectedEventId] = useState(null); + + useEffect(() => { + if (flyToTarget && mapRef.current) { + mapRef.current.flyTo({ + center: [flyToTarget.lng, flyToTarget.lat], + zoom: flyToTarget.zoom ?? 8, + duration: 1200, + }); + onFlyToDone?.(); + } + }, [flyToTarget, onFlyToDone]); + + const visibleEvents = useMemo( + () => events.filter(e => e.timestamp <= currentTime), + [events, currentTime], + ); + + const impactEvents = useMemo( + () => visibleEvents.filter(e => e.type === 'impact'), + [visibleEvents], + ); + const otherEvents = useMemo( + () => visibleEvents.filter(e => e.type !== 'impact'), + [visibleEvents], + ); + + const newEvents = useMemo( + () => visibleEvents.filter(e => { + const age = currentTime - e.timestamp; + return age >= 0 && age < 600_000; + }), + [visibleEvents, currentTime], + ); + + const justActivated = useMemo( + () => visibleEvents.filter(e => { + const age = currentTime - e.timestamp; + return age >= 0 && age < 120_000 && + (e.type === 'airstrike' || e.type === 'impact' || e.type === 'explosion'); + }), + [visibleEvents, currentTime], + ); + + const trajectoryData = useMemo(() => { + const launches = visibleEvents.filter(e => e.type === 'missile_launch'); + const targets = visibleEvents.filter(e => e.type === 'impact' || e.type === 'airstrike' || e.type === 'explosion'); + if (launches.length === 0 || targets.length === 0) { + return { type: 'FeatureCollection' as const, features: [] as GeoJSON.Feature[] }; + } + return { + type: 'FeatureCollection' as const, + features: launches.map(launch => ({ + type: 'Feature' as const, + properties: {}, + geometry: { + type: 'LineString' as const, + coordinates: [[launch.lng, launch.lat], [targets[0].lng, targets[0].lat]], + }, + })), + }; + }, [visibleEvents]); + + const selectedEvent = selectedEventId + ? visibleEvents.find(e => e.id === selectedEventId) ?? null + : null; + + return ( + + + + + + + + + + {layers.events && ( + <> + {trajectoryData.features.length > 0 && ( + + + + )} + + {newEvents.map(event => { + const color = getEventColor(event); + const size = EVENT_RADIUS[event.type] * 5; + return ( + +
+ + ); + })} + + {justActivated.map(event => { + const color = getEventColor(event); + const size = event.type === 'impact' ? 100 : 70; + return ( + +
+ + ); + })} + + {justActivated.map(event => { + const color = getEventColor(event); + const size = event.type === 'impact' ? 40 : 30; + return ( + +
+ + ); + })} + + {otherEvents.map(event => { + const ageMs = currentTime - event.timestamp; + const ageHours = ageMs / 3600_000; + const DAY_H = 24; + const isRecent = ageHours <= DAY_H; + const opacity = isRecent + ? Math.max(0.85, 1 - ageHours * 0.006) + : Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); + const color = getEventColor(event); + const isNew = ageMs >= 0 && ageMs < 600_000; + const baseR = EVENT_RADIUS[event.type]; + const r = isNew ? baseR * 1.3 : isRecent ? baseR * 1.1 : baseR * 0.85; + const size = r * 2; + + return ( + +
{ e.stopPropagation(); setSelectedEventId(event.id); }} + > + + + +
+
+ ); + })} + + {impactEvents.map(event => { + const ageMs = currentTime - event.timestamp; + const ageHours = ageMs / 3600_000; + const isRecent = ageHours <= 24; + const impactOpacity = isRecent + ? Math.max(0.8, 1 - ageHours * 0.008) + : Math.max(0.2, 0.45 - (ageHours - 24) * 0.005); + const s = isRecent ? 22 : 16; + const c = s / 2; + const sw = isRecent ? 1.5 : 1; + return ( + +
{ e.stopPropagation(); setSelectedEventId(event.id); }}> + + + + + + + + + +
+ {isRecent && NEW} + {event.label} +
+
+
+ ); + })} + + {selectedEvent && ( + setSelectedEventId(null)} + closeOnClick={false} + anchor="bottom" + maxWidth="320px" + className="gl-popup" + > +
+ {selectedEvent.source && ( + + {t(`source.${selectedEvent.source}`)} + + )} + {selectedEvent.type === 'impact' && ( +
+ {t('popup.impactSite')} +
+ )} +
{selectedEvent.label}
+ + {new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST + + {selectedEvent.description && ( +

{selectedEvent.description}

+ )} + {selectedEvent.imageUrl && ( +
+ {selectedEvent.imageCaption { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + {selectedEvent.imageCaption && ( +
{selectedEvent.imageCaption}
+ )} +
+ )} + {selectedEvent.type === 'impact' && ( +
+ {selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E +
+ )} +
+
+ )} + + )} + + {layers.aircraft && } + {layers.satellites && } + {layers.ships && } + {layers.ships && } + {layers.airports && } + {layers.oilFacilities && } + + ); +} diff --git a/frontend/src/components/SatelliteLayer.tsx b/frontend/src/components/SatelliteLayer.tsx new file mode 100644 index 0000000..0f247b3 --- /dev/null +++ b/frontend/src/components/SatelliteLayer.tsx @@ -0,0 +1,183 @@ +import { memo, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import type { SatellitePosition } from '../types'; + +interface Props { + satellites: SatellitePosition[]; +} + +const CAT_COLORS: Record = { + reconnaissance: '#ef4444', + communications: '#3b82f6', + navigation: '#22c55e', + weather: '#a855f7', + other: '#6b7280', +}; + +const CAT_LABELS: Record = { + reconnaissance: 'RECON', communications: 'COMMS', + navigation: 'NAV', weather: 'WX', other: 'SAT', +}; + +const SVG_RECON = ( + <> + + + + + + +); + +const SVG_COMMS = ( + <> + + + + + + +); + +const SVG_NAV = ( + <> + + + + + + +); + +const SVG_WEATHER = ( + <> + + + + + +); + +const SVG_OTHER = ( + <> + + + + +); + +const SVG_MAP: Record = { + reconnaissance: SVG_RECON, communications: SVG_COMMS, + navigation: SVG_NAV, weather: SVG_WEATHER, other: SVG_OTHER, +}; + +export function SatelliteLayer({ satellites }: Props) { + const trackData = useMemo(() => { + const features: GeoJSON.Feature[] = []; + for (const sat of satellites) { + if (!sat.groundTrack || sat.groundTrack.length < 2) continue; + const color = CAT_COLORS[sat.category]; + let segment: [number, number][] = []; + for (let i = 0; i < sat.groundTrack.length; i++) { + const [lat, lng] = sat.groundTrack[i]; + if (i > 0) { + const [, prevLng] = sat.groundTrack[i - 1]; + if (Math.abs(lng - prevLng) > 180) { + if (segment.length > 1) { + features.push({ + type: 'Feature', + properties: { color }, + geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) }, + }); + } + segment = []; + } + } + segment.push([lat, lng]); + } + if (segment.length > 1) { + features.push({ + type: 'Feature', + properties: { color }, + geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) }, + }); + } + } + return { type: 'FeatureCollection' as const, features }; + }, [satellites]); + + return ( + <> + {trackData.features.length > 0 && ( + + + + )} + {satellites.map(sat => ( + + ))} + + ); +} + +const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) { + const [showPopup, setShowPopup] = useState(false); + const { t } = useTranslation(); + const color = CAT_COLORS[sat.category]; + const svgBody = SVG_MAP[sat.category]; + const size = 22; + + return ( + <> + +
+
{ e.stopPropagation(); setShowPopup(true); }} + > + + {svgBody} + +
+
+ {sat.name} +
+
+
+ {showPopup && ( + setShowPopup(false)} closeOnClick={false} + anchor="bottom" maxWidth="200px" className="gl-popup"> +
+
+ + {CAT_LABELS[sat.category]} + + {sat.name} +
+ + + + + + + +
{t('satellite.norad')}{sat.noradId}
{t('satellite.lat')}{sat.lat.toFixed(2)}°
{t('satellite.lng')}{sat.lng.toFixed(2)}°
{t('satellite.alt')}{Math.round(sat.altitude)} km
+
+
+ )} + + ); +}); diff --git a/frontend/src/components/SatelliteMap.tsx b/frontend/src/components/SatelliteMap.tsx new file mode 100644 index 0000000..7905edb --- /dev/null +++ b/frontend/src/components/SatelliteMap.tsx @@ -0,0 +1,257 @@ +import { useMemo, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre'; +import type { MapRef } from 'react-map-gl/maplibre'; +import { AircraftLayer } from './AircraftLayer'; +import { SatelliteLayer } from './SatelliteLayer'; +import { ShipLayer } from './ShipLayer'; +import { DamagedShipLayer } from './DamagedShipLayer'; +import { OilFacilityLayer } from './OilFacilityLayer'; +import { AirportLayer } from './AirportLayer'; +import { iranOilFacilities } from '../data/oilFacilities'; +import { middleEastAirports } from '../data/airports'; +import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types'; +import { countryLabelsGeoJSON } from '../data/countryLabels'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +interface Props { + events: GeoEvent[]; + currentTime: number; + aircraft: Aircraft[]; + satellites: SatellitePosition[]; + ships: Ship[]; + layers: LayerVisibility; +} + +// ESRI World Imagery + ESRI boundaries overlay +const SATELLITE_STYLE = { + version: 8 as const, + sources: { + 'esri-satellite': { + type: 'raster' as const, + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + ], + tileSize: 256, + attribution: '© Esri, Maxar, Earthstar Geographics', + maxzoom: 19, + }, + 'esri-boundaries': { + type: 'raster' as const, + tiles: [ + 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', + ], + tileSize: 256, + maxzoom: 19, + }, + }, + layers: [ + { id: 'background', type: 'background' as const, paint: { 'background-color': '#000811' } }, + { id: 'satellite', type: 'raster' as const, source: 'esri-satellite' }, + { id: 'boundaries', type: 'raster' as const, source: 'esri-boundaries', paint: { 'raster-opacity': 0.65 } }, + ], +}; + +const EVENT_COLORS: Record = { + airstrike: '#ef4444', + explosion: '#f97316', + missile_launch: '#eab308', + intercept: '#3b82f6', + alert: '#a855f7', + impact: '#ff0000', + osint: '#06b6d4', +}; + +const SOURCE_COLORS: Record = { + US: '#ef4444', + IL: '#22c55e', + IR: '#ff0000', + proxy: '#f59e0b', +}; + +function getEventColor(event: GeoEvent): string { + if (event.type === 'impact') return '#ff0000'; + if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source]; + return EVENT_COLORS[event.type]; +} + +const EVENT_RADIUS: Record = { + airstrike: 12, + explosion: 10, + missile_launch: 8, + intercept: 7, + alert: 6, + impact: 14, + osint: 8, +}; + +export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) { + const { t } = useTranslation(); + const mapRef = useRef(null); + const [selectedEventId, setSelectedEventId] = useState(null); + + const visibleEvents = useMemo(() => { + if (!layers.events) return []; + return events.filter(e => e.timestamp <= currentTime); + }, [events, currentTime, layers.events]); + + const selectedEvent = useMemo( + () => visibleEvents.find(e => e.id === selectedEventId) || null, + [visibleEvents, selectedEventId], + ); + + const countryLabels = useMemo(() => countryLabelsGeoJSON(), []); + + return ( + + + + {/* Korean country labels */} + + + + + + + {/* Event markers */} + {visibleEvents.map(ev => ( + { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }} + > +
+ + ))} + + {/* Popup */} + {selectedEvent && ( + setSelectedEventId(null)} + closeOnClick={false} + maxWidth="320px" + className="event-popup" + > +
+ {selectedEvent.source && ( + + {t(`source.${selectedEvent.source}`)} + + )} + {selectedEvent.type === 'impact' && ( +
+ {t('popup.impactSite')} +
+ )} +
{selectedEvent.label}
+ + {new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST + + {selectedEvent.description && ( +

{selectedEvent.description}

+ )} + {selectedEvent.imageUrl && ( +
+ {selectedEvent.imageCaption { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + {selectedEvent.imageCaption && ( +
{selectedEvent.imageCaption}
+ )} +
+ )} +
+ {selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E +
+
+
+ )} + + {/* Overlay layers */} + {layers.aircraft && } + {layers.satellites && } + {layers.ships && } + + {layers.oilFacilities && } + {layers.airports && } + + ); +} diff --git a/frontend/src/components/SensorChart.tsx b/frontend/src/components/SensorChart.tsx new file mode 100644 index 0000000..6f4077f --- /dev/null +++ b/frontend/src/components/SensorChart.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, +} from 'recharts'; +import type { SensorLog } from '../types'; + +interface Props { + data: SensorLog[]; + currentTime: number; + startTime: number; + endTime: number; +} + +export function SensorChart({ data, currentTime, startTime }: Props) { + const { t } = useTranslation(); + + const visibleData = useMemo( + () => data.filter(d => d.timestamp <= currentTime), + [data, currentTime], + ); + + const chartData = useMemo( + () => + visibleData.map(d => ({ + ...d, + time: formatHour(d.timestamp, startTime), + })), + [visibleData, startTime], + ); + + return ( +
+

{t('sensor.title')}

+
+
+

{t('sensor.seismicActivity')}

+ + + + + + + + + + +
+ +
+

{t('sensor.noiseLevelDb')}

+ + + + + + + + + +
+ +
+

{t('sensor.airPressureHpa')}

+ + + + + + + + + +
+ +
+

{t('sensor.radiationUsv')}

+ + + + + + + + + +
+
+
+ ); +} + +function formatHour(timestamp: number, startTime: number): string { + const hours = (timestamp - startTime) / 3600_000; + const h = Math.floor(hours); + const m = Math.round((hours - h) * 60); + return `${h}:${m.toString().padStart(2, '0')}`; +} diff --git a/frontend/src/components/ShipLayer.tsx b/frontend/src/components/ShipLayer.tsx new file mode 100644 index 0000000..271538f --- /dev/null +++ b/frontend/src/components/ShipLayer.tsx @@ -0,0 +1,467 @@ +import { memo, useMemo, useState, useEffect } from 'react'; +import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; +import { useTranslation } from 'react-i18next'; +import type { Ship, ShipCategory } from '../types'; +import maplibregl from 'maplibre-gl'; + +interface Props { + ships: Ship[]; + militaryOnly: boolean; + koreanOnly?: boolean; +} + +// ── MarineTraffic-style vessel type colors (CSS variable references) ── +const MT_TYPE_COLORS: Record = { + 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 = { + cargo: '#f0a830', + tanker: '#e74c3c', + passenger: '#4caf50', + fishing: '#42a5f5', + pleasure: '#e91e8c', + military: '#d32f2f', + tug_special: '#2e7d32', + other: '#5c6bc0', + unknown: '#9e9e9e', +}; + +// Map our internal ShipCategory + typecode → MT visual type +function getMTType(ship: Ship): string { + const tc = (ship.typecode || '').toUpperCase(); + const cat = ship.category; + + // Military first + if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military'; + if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military'; + + // Tanker + if (cat === 'tanker') return 'tanker'; + if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker'; + if (tc.startsWith('A1')) return 'tanker'; + + // Cargo + if (cat === 'cargo') return 'cargo'; + if (tc === 'CONT' || tc === 'BULK') return 'cargo'; + if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo'; + + // Passenger + if (tc === 'PASS' || tc.startsWith('B')) return 'passenger'; + + // Fishing + if (tc.startsWith('C')) return 'fishing'; + + // Tug / Special + if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special'; + + // Pleasure + if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure'; + + if (cat === 'civilian') return 'other'; + return 'unknown'; +} + +// Legacy navy flag colors (for popup header accent only) +const NAVY_COLORS: Record = { + US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff', + IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43', +}; + +const FLAG_EMOJI: Record = { + US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', + KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', + AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}', + CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', + MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}', + BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}', + SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}', +}; + +// icon-size multiplier (symbol layer, base=64px) +const SIZE_MAP: Record = { + carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16, + tanker: 0.16, cargo: 0.16, civilian: 0.14, unknown: 0.12, +}; + +const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol']; + +function isMilitary(category: ShipCategory): boolean { + return MIL_CATEGORIES.includes(category); +} + +function getShipColor(ship: Ship): string { + return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown; +} + +function getShipHex(ship: Ship): string { + return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown; +} + +// ── Local Korean ship photos ── +const LOCAL_SHIP_PHOTOS: Record = { + '440034000': '/ships/440034000.jpg', + '440150000': '/ships/440150000.jpg', + '440272000': '/ships/440272000.jpg', + '440274000': '/ships/440274000.jpg', + '440323000': '/ships/440323000.jpg', + '440384000': '/ships/440384000.jpg', + '440880000': '/ships/440880000.jpg', + '441046000': '/ships/441046000.jpg', + '441345000': '/ships/441345000.jpg', + '441353000': '/ships/441353000.jpg', + '441393000': '/ships/441393000.jpg', + '441423000': '/ships/441423000.jpg', + '441548000': '/ships/441548000.jpg', + '441708000': '/ships/441708000.png', + '441866000': '/ships/441866000.jpg', +}; + +interface VesselPhotoData { url: string; } +const vesselPhotoCache = new Map(); + +type PhotoSource = 'signal-batch' | 'marinetraffic'; + +interface VesselPhotoProps { + mmsi: string; + imo?: string; + shipImagePath?: string | null; +} + +function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { + const { t } = useTranslation('ships'); + const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; + + // Determine available tabs + const hasSignalBatch = !!shipImagePath; + const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic'; + const [activeTab, setActiveTab] = useState(defaultTab); + + // MarineTraffic image state (lazy loaded) + const [mtPhoto, setMtPhoto] = useState(() => { + return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; + }); + + useEffect(() => { + if (activeTab !== 'marinetraffic') return; + if (mtPhoto !== undefined) return; + const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`; + const img = new Image(); + img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); }; + img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); }; + img.src = imgUrl; + }, [mmsi, activeTab, mtPhoto]); + + // Resolve current image URL + let currentUrl: string | null = null; + if (localUrl) { + currentUrl = localUrl; + } else if (activeTab === 'signal-batch' && shipImagePath) { + currentUrl = shipImagePath; + } else if (activeTab === 'marinetraffic' && mtPhoto) { + currentUrl = mtPhoto.url; + } + + // If local photo exists, show it directly without tabs + if (localUrl) { + return ( +
+ Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ ); + } + + return ( +
+
+ {hasSignalBatch && ( +
setActiveTab('signal-batch')} + > + signal-batch +
+ )} +
setActiveTab('marinetraffic')} + > + MarineTraffic +
+
+ {currentUrl ? ( + Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( + activeTab === 'marinetraffic' && mtPhoto === undefined + ?
{t('popup.loading')}
+ : null + )} +
+ ); +} + +function formatCoord(lat: number, lng: number): string { + const latDir = lat >= 0 ? 'N' : 'S'; + const lngDir = lng >= 0 ? 'E' : 'W'; + return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`; +} + +// Create triangle SDF image for MapLibre symbol layer +const TRIANGLE_SIZE = 64; + +function ensureTriangleImage(map: maplibregl.Map) { + if (map.hasImage('ship-triangle')) return; + const s = TRIANGLE_SIZE; + const canvas = document.createElement('canvas'); + canvas.width = s; + canvas.height = s; + const ctx = canvas.getContext('2d')!; + // Draw upward-pointing triangle (heading 0 = north) + ctx.beginPath(); + ctx.moveTo(s / 2, 2); // top center + ctx.lineTo(s * 0.12, s - 2); // bottom left + ctx.lineTo(s / 2, s * 0.62); // inner notch + ctx.lineTo(s * 0.88, s - 2); // bottom right + ctx.closePath(); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + const imgData = ctx.getImageData(0, 0, s, s); + map.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(imgData.data.buffer) }, { sdf: true }); +} + +// ── Main layer (WebGL symbol rendering — triangles) ── +export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { + const { current: map } = useMap(); + const [selectedMmsi, setSelectedMmsi] = useState(null); + const [imageReady, setImageReady] = useState(false); + + const filtered = useMemo(() => { + let result = ships; + if (koreanOnly) result = result.filter(s => s.flag === 'KR'); + if (militaryOnly) result = result.filter(s => isMilitary(s.category)); + return result; + }, [ships, militaryOnly, koreanOnly]); + + // Add triangle image to map + useEffect(() => { + if (!map) return; + const m = map.getMap(); + const addIcon = () => { + try { ensureTriangleImage(m); } catch { /* already added */ } + setImageReady(true); + }; + if (m.isStyleLoaded()) { addIcon(); } + else { m.once('load', addIcon); } + return () => { m.off('load', addIcon); }; + }, [map]); + + // Build GeoJSON for all ships + const shipGeoJson = useMemo(() => { + const features: GeoJSON.Feature[] = filtered.map(ship => ({ + type: 'Feature' as const, + properties: { + mmsi: ship.mmsi, + color: getShipHex(ship), + size: SIZE_MAP[ship.category], + isMil: isMilitary(ship.category) ? 1 : 0, + isKorean: ship.flag === 'KR' ? 1 : 0, + isCheonghae: ship.mmsi === '440001981' ? 1 : 0, + heading: ship.heading, + }, + geometry: { + type: 'Point' as const, + coordinates: [ship.lng, ship.lat], + }, + })); + return { type: 'FeatureCollection' as const, features }; + }, [filtered]); + + // Register click and cursor handlers + useEffect(() => { + if (!map) return; + const m = map.getMap(); + const layerId = 'ships-triangles'; + + const handleClick = (e: maplibregl.MapLayerMouseEvent) => { + if (e.features && e.features.length > 0) { + const mmsi = e.features[0].properties?.mmsi; + if (mmsi) setSelectedMmsi(mmsi); + } + }; + const handleEnter = () => { m.getCanvas().style.cursor = 'pointer'; }; + const handleLeave = () => { m.getCanvas().style.cursor = ''; }; + + m.on('click', layerId, handleClick); + m.on('mouseenter', layerId, handleEnter); + m.on('mouseleave', layerId, handleLeave); + + return () => { + m.off('click', layerId, handleClick); + m.off('mouseenter', layerId, handleEnter); + m.off('mouseleave', layerId, handleLeave); + }; + }, [map]); + + const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null; + + // Carrier labels — only a few, so DOM markers are fine + const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]); + + + + if (!imageReady) return null; + + return ( + <> + + {/* Korean ship outer ring (circle behind triangle) */} + + {/* Main ship triangles */} + + + + {/* Carrier labels as DOM markers (very few) */} + {carriers.map(ship => ( + +
+
+ {ship.name} +
+
+
+ ))} + + {/* Popup for selected ship */} + {selectedShip && ( + setSelectedMmsi(null)} /> + )} + + ); +} + +const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) { + const { t } = useTranslation('ships'); + const mtType = getMTType(ship); + const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown; + const isMil = isMilitary(ship.category); + const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined; + const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined; + const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; + + return ( + +
+
+ {flagEmoji && {flagEmoji}} + {ship.name} + {navyLabel && ( + {navyLabel} + )} +
+ +
+ {t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} + + {t(`categoryLabel.${ship.category}`)} + + {ship.typeDesc && ( + {ship.typeDesc} + )} +
+
+
+
{t('popup.mmsi')} : {ship.mmsi}
+ {ship.callSign &&
{t('popup.callSign')} : {ship.callSign}
} + {ship.imo &&
{t('popup.imo')} : {ship.imo}
} + {ship.status &&
{t('popup.status')} : {ship.status}
} + {ship.length &&
{t('popup.length')} : {ship.length}m
} + {ship.width &&
{t('popup.width')} : {ship.width}m
} + {ship.draught &&
{t('popup.draught')} : {ship.draught}m
} +
+
+
{t('popup.heading')} : {ship.heading.toFixed(1)}°
+
{t('popup.course')} : {ship.course.toFixed(1)}°
+
{t('popup.speed')} : {ship.speed.toFixed(1)} kn
+
{t('popup.lat')} : {formatCoord(ship.lat, 0).split(',')[0]}
+
{t('popup.lon')} : {formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}
+ {ship.destination &&
{t('popup.destination')} : {ship.destination}
} + {ship.eta &&
{t('popup.eta')} : {new Date(ship.eta).toLocaleString()}
} +
+
+
+ {t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()} +
+ +
+
+ ); +}); diff --git a/frontend/src/components/SubmarineCableLayer.tsx b/frontend/src/components/SubmarineCableLayer.tsx new file mode 100644 index 0000000..f832d3d --- /dev/null +++ b/frontend/src/components/SubmarineCableLayer.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../services/submarineCable'; +import type { SubmarineCable } from '../services/submarineCable'; + +export function SubmarineCableLayer() { + const [selectedCable, setSelectedCable] = useState(null); + const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null); + + // Build GeoJSON for all cables + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: KOREA_SUBMARINE_CABLES.map(cable => ({ + type: 'Feature' as const, + properties: { + id: cable.id, + name: cable.name, + color: cable.color, + }, + geometry: { + type: 'LineString' as const, + coordinates: cable.route, + }, + })), + }; + + return ( + <> + {/* Cable lines */} + + + + + + {/* Landing points */} + {KOREA_LANDING_POINTS.map(pt => ( + { e.originalEvent.stopPropagation(); setSelectedPoint(pt); setSelectedCable(null); }}> +
+ + ))} + + {/* Cable name labels along route (midpoint) */} + {KOREA_SUBMARINE_CABLES.map(cable => { + const mid = cable.route[Math.floor(cable.route.length / 3)]; + if (!mid) return null; + return ( + { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}> +
+ {cable.name} +
+
+ ); + })} + + {/* Landing point popup */} + {selectedPoint && ( + setSelectedPoint(null)} closeOnClick={false} + anchor="bottom" maxWidth="260px" className="gl-popup"> +
+
+ 📡 {selectedPoint.name} 해저케이블 기지 +
+
+ 연결 케이블: {selectedPoint.cables.length}개 +
+
+ {selectedPoint.cables.map(cid => { + const c = KOREA_SUBMARINE_CABLES.find(cc => cc.id === cid); + if (!c) return null; + return ( +
+ + {c.name} +
+ ); + })} +
+
+
+ )} + + {/* Cable info popup */} + {selectedCable && ( + setSelectedCable(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ 🔌 {selectedCable.name} +
+
+
+ 경유지: + {selectedCable.landingPoints.join(' → ')} +
+ {selectedCable.rfsYear && ( +
개통: {selectedCable.rfsYear}년
+ )} + {selectedCable.length && ( +
총 길이: {selectedCable.length}
+ )} + {selectedCable.owners && ( +
운영: {selectedCable.owners}
+ )} +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/TimelineSlider.tsx b/frontend/src/components/TimelineSlider.tsx new file mode 100644 index 0000000..23e4e23 --- /dev/null +++ b/frontend/src/components/TimelineSlider.tsx @@ -0,0 +1,163 @@ +import { useMemo, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GeoEvent } from '../types'; + +interface Props { + currentTime: number; + startTime: number; + endTime: number; + events: GeoEvent[]; + onSeek: (time: number) => void; + onEventFlyTo?: (event: GeoEvent) => void; +} + +const KST_OFFSET = 9 * 3600_000; + +const TYPE_COLORS: Record = { + airstrike: '#ef4444', + explosion: '#f97316', + missile_launch: '#eab308', + intercept: '#3b82f6', + alert: '#a855f7', + impact: '#ff0000', + osint: '#06b6d4', +}; + +const TYPE_I18N_KEYS: Record = { + airstrike: 'event.airstrike', + explosion: 'event.explosion', + missile_launch: 'event.missileLaunch', + intercept: 'event.intercept', + alert: 'event.alert', + impact: 'event.impact', + osint: 'event.osint', +}; + +const SOURCE_I18N_KEYS: Record = { + 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(null); + + const progress = ((currentTime - startTime) / (endTime - startTime)) * 100; + + const eventMarkers = useMemo(() => { + return events.map(e => ({ + id: e.id, + position: ((e.timestamp - startTime) / (endTime - startTime)) * 100, + type: e.type, + label: e.label, + })); + }, [events, startTime, endTime]); + + const formatTime = (ts: number) => { + const d = new Date(ts + KST_OFFSET); + return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; + }; + + const formatTimeShort = (ts: number) => { + const d = new Date(ts + KST_OFFSET); + return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + }; + + const handleTrackClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + onSeek(startTime + pct * (endTime - startTime)); + }; + + // When a marker is clicked: select it + seek to its time + const handleMarkerClick = useCallback((e: React.MouseEvent, ev: GeoEvent) => { + e.stopPropagation(); // don't trigger track seek + setSelectedId(prev => prev === ev.id ? null : ev.id); + onSeek(ev.timestamp); + }, [onSeek]); + + // Find events near the selected event (within 30 min) + const selectedCluster = useMemo(() => { + if (!selectedId) return []; + const sel = events.find(e => e.id === selectedId); + if (!sel) return []; + const WINDOW = 30 * 60_000; // 30 min + return events + .filter(e => Math.abs(e.timestamp - sel.timestamp) <= WINDOW) + .sort((a, b) => a.timestamp - b.timestamp); + }, [selectedId, events]); + + const handleEventCardClick = useCallback((ev: GeoEvent) => { + onSeek(ev.timestamp); + onEventFlyTo?.(ev); + }, [onSeek, onEventFlyTo]); + + return ( +
+
+ {formatTime(startTime)} + {formatTime(currentTime)} + {formatTime(endTime)} +
+
+
+
+ {eventMarkers.map(m => { + const ev = events.find(e => e.id === m.id)!; + const isSelected = selectedId === m.id; + const isInCluster = selectedCluster.some(c => c.id === m.id); + return ( +
handleMarkerClick(e, ev)} + /> + ); + })} +
+ + {/* Event detail strip — shown when a marker is selected */} + {selectedCluster.length > 0 && ( +
+ {selectedCluster.map(ev => { + const color = TYPE_COLORS[ev.type] || '#888'; + const isPast = ev.timestamp <= currentTime; + const isActive = ev.id === selectedId; + const sourceKey = ev.source ? SOURCE_I18N_KEYS[ev.source] : ''; + const source = sourceKey ? t(sourceKey) : ''; + const typeKey = TYPE_I18N_KEYS[ev.type]; + const typeLabel = typeKey ? t(typeKey) : ev.type; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..3a34ae7 --- /dev/null +++ b/frontend/src/components/auth/LoginPage.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface LoginPageProps { + onGoogleLogin: (credential: string) => Promise; + 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(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).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(null); + + const handleGoogleCredential = useCallback( + (credential: string) => { + setError(null); + onGoogleLogin(credential).catch(() => { + setError(t('auth.loginFailed')); + }); + }, + [onGoogleLogin, t], + ); + + const googleBtnRef = useGoogleIdentity(handleGoogleCredential); + + return ( +
+
+ {/* Title */} +
+
🛡️
+

+ {t('auth.title')} +

+

+ {t('auth.subtitle')} +

+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Google Login Button */} + {GOOGLE_CLIENT_ID && ( + <> +
+

+ {t('auth.domainNotice')} +

+ + )} + + {/* Dev Login */} + {IS_DEV && ( + <> +
+ + {t('auth.devNotice')} + +
+ + + )} +
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/data/airports.ts b/frontend/src/data/airports.ts new file mode 100644 index 0000000..761436d --- /dev/null +++ b/frontend/src/data/airports.ts @@ -0,0 +1,112 @@ +// Major airports in the Middle East / Horn of Africa region +// Reference: Flightradar24, OurAirports + +export interface Airport { + iata: string; // IATA code (e.g. "IKA") + icao: string; // ICAO code (e.g. "OIIE") + name: string; + nameKo?: string; // Korean name + lat: number; + lng: number; + type: 'large' | 'medium' | 'small' | 'military'; + country: string; // ISO 2-letter + city?: string; +} + +export const middleEastAirports: Airport[] = [ + // ── 이란 (Iran) ── + { iata: 'IKA', icao: 'OIIE', name: 'Imam Khomeini Intl', nameKo: '이맘 호메이니 국제공항', lat: 35.4161, lng: 51.1522, type: 'large', country: 'IR', city: 'Tehran' }, + { iata: 'THR', icao: 'OIII', name: 'Mehrabad Intl', nameKo: '메흐라바드 국제공항', lat: 35.6892, lng: 51.3134, type: 'large', country: 'IR', city: 'Tehran' }, + { iata: 'MHD', icao: 'OIMM', name: 'Mashhad Intl', nameKo: '마슈하드 국제공항', lat: 36.2352, lng: 59.6410, type: 'large', country: 'IR', city: 'Mashhad' }, + { iata: 'IFN', icao: 'OIFM', name: 'Isfahan Intl', nameKo: '이스파한 국제공항', lat: 32.7508, lng: 51.8613, type: 'large', country: 'IR', city: 'Isfahan' }, + { iata: 'SYZ', icao: 'OISS', name: 'Shiraz Intl', nameKo: '시라즈 국제공항', lat: 29.5392, lng: 52.5899, type: 'large', country: 'IR', city: 'Shiraz' }, + { iata: 'TBZ', icao: 'OITT', name: 'Tabriz Intl', nameKo: '타브리즈 국제공항', lat: 38.1339, lng: 46.2350, type: 'medium', country: 'IR', city: 'Tabriz' }, + { iata: 'BND', icao: 'OIKB', name: 'Bandar Abbas Intl', nameKo: '반다르 아바스 국제공항', lat: 27.2183, lng: 56.3778, type: 'medium', country: 'IR', city: 'Bandar Abbas' }, + { iata: 'AWZ', icao: 'OIAW', name: 'Ahvaz Intl', nameKo: '아흐바즈 국제공항', lat: 31.3374, lng: 48.7620, type: 'medium', country: 'IR', city: 'Ahvaz' }, + { iata: 'KIH', icao: 'OIBK', name: 'Kish Island Intl', nameKo: '키시섬 국제공항', lat: 26.5262, lng: 53.9802, type: 'medium', country: 'IR', city: 'Kish Island' }, + { iata: 'BUZ', icao: 'OIBB', name: 'Bushehr Airport', nameKo: '부셰르 공항', lat: 28.9448, lng: 50.8346, type: 'medium', country: 'IR', city: 'Bushehr' }, + { iata: 'KER', icao: 'OIKK', name: 'Kerman Airport', nameKo: '케르만 공항', lat: 30.2744, lng: 56.9511, type: 'medium', country: 'IR', city: 'Kerman' }, + { iata: 'ZAH', icao: 'OIZH', name: 'Zahedan Intl', nameKo: '자헤단 국제공항', lat: 29.4757, lng: 60.9062, type: 'medium', country: 'IR', city: 'Zahedan' }, + + // ── 이란 군사기지 (Iran Military) ── + { iata: '', icao: 'OIFH', name: 'Isfahan (Haft) AFB', nameKo: '이스파한 군사기지', lat: 32.5669, lng: 51.6916, type: 'military', country: 'IR', city: 'Isfahan' }, + { iata: '', icao: 'OIIA', name: 'Tehran Doshan Tappeh AFB', nameKo: '도샨 타페 공군기지', lat: 35.7030, lng: 51.4750, type: 'military', country: 'IR', city: 'Tehran' }, + { iata: '', icao: 'OINR', name: 'Nojeh AFB (Hamadan)', nameKo: '노제 공군기지', lat: 34.8919, lng: 48.2914, type: 'military', country: 'IR', city: 'Hamadan' }, + { iata: '', icao: 'OIBJ', name: 'Bandar Abbas (Havadarya) NAS', nameKo: '반다르 아바스 해군항공기지', lat: 27.1583, lng: 56.1725, type: 'military', country: 'IR', city: 'Bandar Abbas' }, + { iata: '', icao: 'OICC', name: 'Tabriz (Shahid Fakouri) AFB', nameKo: '타브리즈 공군기지', lat: 38.1500, lng: 46.2500, type: 'military', country: 'IR', city: 'Tabriz' }, + + // ── 이라크 (Iraq) ── + { iata: 'BGW', icao: 'ORBI', name: 'Baghdad Intl', nameKo: '바그다드 국제공항', lat: 33.2625, lng: 44.2346, type: 'large', country: 'IQ', city: 'Baghdad' }, + { iata: 'BSR', icao: 'ORMM', name: 'Basra Intl', nameKo: '바스라 국제공항', lat: 30.5491, lng: 47.6621, type: 'medium', country: 'IQ', city: 'Basra' }, + { iata: 'EBL', icao: 'ORER', name: 'Erbil Intl', nameKo: '에르빌 국제공항', lat: 36.2376, lng: 43.9632, type: 'medium', country: 'IQ', city: 'Erbil' }, + { iata: '', icao: 'ORAA', name: 'Al Asad Airbase', nameKo: '알 아사드 공군기지', lat: 33.7856, lng: 42.4412, type: 'military', country: 'IQ', city: 'Anbar' }, + { iata: '', icao: 'ORBD', name: 'Balad (Al Bakr) AB', nameKo: '발라드 공군기지', lat: 33.9402, lng: 44.3615, type: 'military', country: 'IQ', city: 'Balad' }, + + // ── 이스라엘 (Israel) ── + { iata: 'TLV', icao: 'LLBG', name: 'Ben Gurion Intl', nameKo: '벤 구리온 국제공항', lat: 32.0114, lng: 34.8867, type: 'large', country: 'IL', city: 'Tel Aviv' }, + { iata: '', icao: 'LLNV', name: 'Nevatim AFB', nameKo: '네바팀 공군기지', lat: 31.2083, lng: 34.9389, type: 'military', country: 'IL', city: 'Be\'er Sheva' }, + { iata: '', icao: 'LLRM', name: 'Ramon AFB', nameKo: '라몬 공군기지', lat: 30.7761, lng: 34.6667, type: 'military', country: 'IL', city: 'Negev' }, + { iata: '', icao: 'LLHA', name: 'Hatzerim AFB', nameKo: '하체림 공군기지', lat: 31.2333, lng: 34.6667, type: 'military', country: 'IL', city: 'Be\'er Sheva' }, + + // ── UAE ── + { iata: 'DXB', icao: 'OMDB', name: 'Dubai Intl', nameKo: '두바이 국제공항', lat: 25.2528, lng: 55.3644, type: 'large', country: 'AE', city: 'Dubai' }, + { iata: 'AUH', icao: 'OMAA', name: 'Abu Dhabi Intl', nameKo: '아부다비 국제공항', lat: 24.4430, lng: 54.6511, type: 'large', country: 'AE', city: 'Abu Dhabi' }, + { iata: 'SHJ', icao: 'OMSJ', name: 'Sharjah Intl', nameKo: '샤르자 국제공항', lat: 25.3286, lng: 55.5172, type: 'medium', country: 'AE', city: 'Sharjah' }, + { iata: '', icao: 'OMAD', name: 'Al Dhafra AFB', nameKo: '알 다프라 공군기지', lat: 24.2483, lng: 54.5481, type: 'military', country: 'AE', city: 'Abu Dhabi' }, + + // ── 사우디아라비아 (Saudi Arabia) ── + { iata: 'RUH', icao: 'OERK', name: 'King Khalid Intl', nameKo: '킹 칼리드 국제공항', lat: 24.9576, lng: 46.6988, type: 'large', country: 'SA', city: 'Riyadh' }, + { iata: 'JED', icao: 'OEJN', name: 'King Abdulaziz Intl', nameKo: '킹 압둘아지즈 국제공항', lat: 21.6796, lng: 39.1565, type: 'large', country: 'SA', city: 'Jeddah' }, + { iata: 'DMM', icao: 'OEDF', name: 'King Fahd Intl', nameKo: '킹 파흐드 국제공항', lat: 26.4712, lng: 49.7979, type: 'large', country: 'SA', city: 'Dammam' }, + { iata: '', icao: 'OEPS', name: 'Prince Sultan AB', nameKo: '프린스 술탄 공군기지', lat: 24.0625, lng: 47.5806, type: 'military', country: 'SA', city: 'Al Kharj' }, + + // ── 카타르 (Qatar) ── + { iata: 'DOH', icao: 'OTHH', name: 'Hamad Intl', nameKo: '하마드 국제공항', lat: 25.2731, lng: 51.6081, type: 'large', country: 'QA', city: 'Doha' }, + { iata: '', icao: 'OTBH', name: 'Al Udeid AB', nameKo: '알 우데이드 공군기지', lat: 25.1173, lng: 51.3150, type: 'military', country: 'QA', city: 'Doha' }, + + // ── 바레인 (Bahrain) ── + { iata: 'BAH', icao: 'OBBI', name: 'Bahrain Intl', nameKo: '바레인 국제공항', lat: 26.2708, lng: 50.6336, type: 'large', country: 'BH', city: 'Manama' }, + { iata: '', icao: 'OBBS', name: 'Isa AB (NSA Bahrain)', nameKo: '이사 공군기지', lat: 26.1572, lng: 50.5911, type: 'military', country: 'BH', city: 'Manama' }, + + // ── 쿠웨이트 (Kuwait) ── + { iata: 'KWI', icao: 'OKBK', name: 'Kuwait Intl', nameKo: '쿠웨이트 국제공항', lat: 29.2267, lng: 47.9689, type: 'large', country: 'KW', city: 'Kuwait City' }, + { iata: '', icao: 'OKAJ', name: 'Ali Al Salem AB', nameKo: '알리 알 살렘 공군기지', lat: 29.3467, lng: 47.5211, type: 'military', country: 'KW', city: 'Kuwait' }, + + // ── 오만 (Oman) ── + { iata: 'MCT', icao: 'OOMS', name: 'Muscat Intl', nameKo: '무스카트 국제공항', lat: 23.5933, lng: 58.2844, type: 'large', country: 'OM', city: 'Muscat' }, + { iata: '', icao: 'OMTH', name: 'Thumrait AB', nameKo: '튬라이트 공군기지', lat: 17.6660, lng: 54.0246, type: 'military', country: 'OM', city: 'Thumrait' }, + + // ── 터키 (Turkey) ── + { iata: 'IST', icao: 'LTFM', name: 'Istanbul Airport', nameKo: '이스탄불 공항', lat: 41.2753, lng: 28.7519, type: 'large', country: 'TR', city: 'Istanbul' }, + { iata: 'ESB', icao: 'LTAC', name: 'Ankara Esenboğa', nameKo: '앙카라 에센보아 공항', lat: 40.1281, lng: 32.9951, type: 'large', country: 'TR', city: 'Ankara' }, + { iata: 'ADA', icao: 'LTAF', name: 'Adana Şakirpaşa', nameKo: '아다나 공항', lat: 36.9822, lng: 35.2804, type: 'medium', country: 'TR', city: 'Adana' }, + { iata: '', icao: 'LTAG', name: 'Incirlik AB', nameKo: '인시를릭 공군기지', lat: 37.0021, lng: 35.4259, type: 'military', country: 'TR', city: 'Adana' }, + { iata: 'DYB', icao: 'LTCC', name: 'Diyarbakır Airport', nameKo: '디야르바키르 공항', lat: 37.8940, lng: 40.2010, type: 'medium', country: 'TR', city: 'Diyarbakır' }, + + // ── 요르단 (Jordan) ── + { iata: 'AMM', icao: 'OJAI', name: 'Queen Alia Intl', nameKo: '퀸 알리아 국제공항', lat: 31.7226, lng: 35.9932, type: 'large', country: 'JO', city: 'Amman' }, + + // ── 레바논 (Lebanon) ── + { iata: 'BEY', icao: 'OLBA', name: 'Beirut–Rafic Hariri Intl', nameKo: '베이루트 국제공항', lat: 33.8209, lng: 35.4884, type: 'large', country: 'LB', city: 'Beirut' }, + + // ── 시리아 (Syria) ── + { iata: 'DAM', icao: 'OSDI', name: 'Damascus Intl', nameKo: '다마스쿠스 국제공항', lat: 33.4114, lng: 36.5156, type: 'large', country: 'SY', city: 'Damascus' }, + + // ── 이집트 (Egypt) ── + { iata: 'CAI', icao: 'HECA', name: 'Cairo Intl', nameKo: '카이로 국제공항', lat: 30.1219, lng: 31.4056, type: 'large', country: 'EG', city: 'Cairo' }, + + // ── 파키스탄 (Pakistan) ── + { iata: 'KHI', icao: 'OPKC', name: 'Jinnah Intl', nameKo: '진나 국제공항', lat: 24.9065, lng: 67.1609, type: 'large', country: 'PK', city: 'Karachi' }, + + // ── 지부티 (Djibouti) ── + { iata: 'JIB', icao: 'HDAM', name: 'Djibouti–Ambouli Intl', nameKo: '지부티 국제공항', lat: 11.5473, lng: 43.1595, type: 'medium', country: 'DJ', city: 'Djibouti' }, + { iata: '', icao: 'HDCL', name: 'Camp Lemonnier', nameKo: '캠프 르모니에 (미군)', lat: 11.5474, lng: 43.1556, type: 'military', country: 'DJ', city: 'Djibouti' }, + + // ── 예멘 (Yemen) ── + { iata: 'ADE', icao: 'OYAA', name: 'Aden Intl', nameKo: '아덴 국제공항', lat: 12.8295, lng: 45.0288, type: 'medium', country: 'YE', city: 'Aden' }, + { iata: 'SAH', icao: 'OYSN', name: 'Sana\'a Intl', nameKo: '사나 국제공항', lat: 15.4763, lng: 44.2197, type: 'medium', country: 'YE', city: 'Sana\'a' }, + + // ── 소말리아 (Somalia) ── + { iata: 'BSA', icao: 'HCMF', name: 'Bosaso Airport', nameKo: '보사소 공항', lat: 11.2753, lng: 49.1494, type: 'small', country: 'SO', city: 'Bosaso' }, + { iata: 'MGQ', icao: 'HCMM', name: 'Aden Abdulle Intl', nameKo: '모가디슈 국제공항', lat: 2.0144, lng: 45.3047, type: 'medium', country: 'SO', city: 'Mogadishu' }, +]; diff --git a/frontend/src/data/countryLabels.ts b/frontend/src/data/countryLabels.ts new file mode 100644 index 0000000..29666d2 --- /dev/null +++ b/frontend/src/data/countryLabels.ts @@ -0,0 +1,126 @@ +// ═══ 한글 국가명 라벨 데이터 ═══ +// 중동 + 동아시아 지역 국가명 (지도 오버레이용) + +export interface CountryLabel { + name: string; // 한글 국가명 + nameEn: string; // 영문 (참고용) + lat: number; + lng: number; + rank: number; // 1=대국(큰글씨), 2=중간, 3=소국(작은글씨) +} + +export const countryLabels: CountryLabel[] = [ + // ── 중동 · 서아시아 ── + { name: '이란', nameEn: 'Iran', lat: 32.5, lng: 53.7, rank: 1 }, + { name: '이라크', nameEn: 'Iraq', lat: 33.2, lng: 43.7, rank: 1 }, + { name: '사우디아라비아', nameEn: 'Saudi Arabia', lat: 24.0, lng: 45.0, rank: 1 }, + { name: '튀르키예', nameEn: 'Turkey', lat: 39.0, lng: 35.2, rank: 1 }, + { name: '이집트', nameEn: 'Egypt', lat: 26.8, lng: 30.8, rank: 1 }, + { name: '시리아', nameEn: 'Syria', lat: 35.0, lng: 38.5, rank: 2 }, + { name: '요르단', nameEn: 'Jordan', lat: 31.3, lng: 36.5, rank: 2 }, + { name: '레바논', nameEn: 'Lebanon', lat: 33.9, lng: 35.9, rank: 3 }, + { name: '이스라엘', nameEn: 'Israel', lat: 31.5, lng: 34.8, rank: 3 }, + { name: '쿠웨이트', nameEn: 'Kuwait', lat: 29.3, lng: 47.5, rank: 3 }, + { name: '바레인', nameEn: 'Bahrain', lat: 26.07, lng: 50.55, rank: 3 }, + { name: '카타르', nameEn: 'Qatar', lat: 25.3, lng: 51.2, rank: 3 }, + { name: 'UAE', nameEn: 'UAE', lat: 24.0, lng: 54.0, rank: 2 }, + { name: '오만', nameEn: 'Oman', lat: 21.5, lng: 57.0, rank: 2 }, + { name: '예멘', nameEn: 'Yemen', lat: 15.6, lng: 48.5, rank: 2 }, + { name: '아프가니스탄', nameEn: 'Afghanistan', lat: 33.9, lng: 67.7, rank: 1 }, + { name: '파키스탄', nameEn: 'Pakistan', lat: 30.4, lng: 69.3, rank: 1 }, + { name: '투르크메니스탄', nameEn: 'Turkmenistan', lat: 39.0, lng: 59.6, rank: 2 }, + { name: '우즈베키스탄', nameEn: 'Uzbekistan', lat: 41.4, lng: 64.6, rank: 2 }, + { name: '아르메니아', nameEn: 'Armenia', lat: 40.1, lng: 44.5, rank: 3 }, + { name: '아제르바이잔', nameEn: 'Azerbaijan', lat: 40.4, lng: 49.9, rank: 3 }, + { name: '조지아', nameEn: 'Georgia', lat: 42.3, lng: 43.4, rank: 3 }, + { name: '수단', nameEn: 'Sudan', lat: 16.0, lng: 30.2, rank: 2 }, + { name: '에리트레아', nameEn: 'Eritrea', lat: 15.2, lng: 39.8, rank: 3 }, + { name: '에티오피아', nameEn: 'Ethiopia', lat: 9.1, lng: 40.5, rank: 1 }, + { name: '소말리아', nameEn: 'Somalia', lat: 5.2, lng: 46.2, rank: 2 }, + { name: '지부티', nameEn: 'Djibouti', lat: 11.6, lng: 43.1, rank: 3 }, + { name: '리비아', nameEn: 'Libya', lat: 27.0, lng: 17.2, rank: 2 }, + { name: '키프로스', nameEn: 'Cyprus', lat: 35.1, lng: 33.4, rank: 3 }, + + // ── 페르시아만 해역 라벨 ── + { name: '페르시아만', nameEn: 'Persian Gulf', lat: 27.0, lng: 51.5, rank: 2 }, + { name: '호르무즈 해협', nameEn: 'Strait of Hormuz', lat: 26.56, lng: 56.25, rank: 3 }, + { name: '오만만', nameEn: 'Gulf of Oman', lat: 24.5, lng: 58.5, rank: 3 }, + { name: '홍해', nameEn: 'Red Sea', lat: 20.0, lng: 38.5, rank: 2 }, + { name: '아덴만', nameEn: 'Gulf of Aden', lat: 12.5, lng: 47.0, rank: 3 }, + { name: '아라비아해', nameEn: 'Arabian Sea', lat: 16.0, lng: 62.0, rank: 2 }, + { name: '카스피해', nameEn: 'Caspian Sea', lat: 41.0, lng: 51.0, rank: 2 }, + { name: '지중해', nameEn: 'Mediterranean Sea', lat: 35.0, lng: 25.0, rank: 2 }, + + // ── 주요 도시 (이란) ── + { name: '테헤란', nameEn: 'Tehran', lat: 35.69, lng: 51.39, rank: 2 }, + { name: '이스파한', nameEn: 'Isfahan', lat: 32.65, lng: 51.68, rank: 3 }, + { name: '타브리즈', nameEn: 'Tabriz', lat: 38.08, lng: 46.29, rank: 3 }, + { name: '시라즈', nameEn: 'Shiraz', lat: 29.59, lng: 52.58, rank: 3 }, + { name: '마슈하드', nameEn: 'Mashhad', lat: 36.30, lng: 59.60, rank: 3 }, + { name: '반다르아바스', nameEn: 'Bandar Abbas', lat: 27.18, lng: 56.28, rank: 3 }, + { name: '부셰르', nameEn: 'Bushehr', lat: 28.97, lng: 50.84, rank: 3 }, + { name: '나탄즈', nameEn: 'Natanz', lat: 33.51, lng: 51.92, rank: 3 }, + { name: '아바즈', nameEn: 'Ahvaz', lat: 31.32, lng: 48.67, rank: 3 }, + { name: '케르만', nameEn: 'Kerman', lat: 30.28, lng: 57.08, rank: 3 }, + { name: '케슘섬', nameEn: 'Qeshm Island', lat: 26.85, lng: 55.90, rank: 3 }, + { name: '하르그섬', nameEn: 'Kharg Island', lat: 29.24, lng: 50.31, rank: 3 }, + + // ── 주요 도시 (중동 기타) ── + { name: '바그다드', nameEn: 'Baghdad', lat: 33.31, lng: 44.37, rank: 2 }, + { name: '에르빌', nameEn: 'Erbil', lat: 36.19, lng: 44.01, rank: 3 }, + { name: '다마스쿠스', nameEn: 'Damascus', lat: 33.51, lng: 36.28, rank: 3 }, + { name: '베이루트', nameEn: 'Beirut', lat: 33.89, lng: 35.50, rank: 3 }, + { name: '예루살렘', nameEn: 'Jerusalem', lat: 31.77, lng: 35.23, rank: 3 }, + { name: '텔아비브', nameEn: 'Tel Aviv', lat: 32.08, lng: 34.78, rank: 3 }, + { name: '리야드', nameEn: 'Riyadh', lat: 24.71, lng: 46.67, rank: 2 }, + { name: '두바이', nameEn: 'Dubai', lat: 25.20, lng: 55.27, rank: 3 }, + { name: '아부다비', nameEn: 'Abu Dhabi', lat: 24.45, lng: 54.65, rank: 3 }, + { name: '도하', nameEn: 'Doha', lat: 25.29, lng: 51.53, rank: 3 }, + { name: '앙카라', nameEn: 'Ankara', lat: 39.93, lng: 32.85, rank: 2 }, + { name: '인저를릭', nameEn: 'Incirlik AFB', lat: 37.00, lng: 35.43, rank: 3 }, + { name: '카이로', nameEn: 'Cairo', lat: 30.04, lng: 31.24, rank: 2 }, + { name: '무스카트', nameEn: 'Muscat', lat: 23.59, lng: 58.54, rank: 3 }, + { name: '사나', nameEn: 'Sanaa', lat: 15.37, lng: 44.19, rank: 3 }, + { name: '카불', nameEn: 'Kabul', lat: 34.53, lng: 69.17, rank: 3 }, + + // ── 주요 군사기지/시설 ── + { name: '알우데이드 기지', nameEn: 'Al Udeid AB', lat: 25.12, lng: 51.32, rank: 3 }, + { name: '제벨알리 항', nameEn: 'Jebel Ali Port', lat: 25.01, lng: 55.06, rank: 3 }, + { name: '디에고가르시아', nameEn: 'Diego Garcia', lat: -7.32, lng: 72.42, rank: 3 }, + + // ── 동아시아 ── + { name: '대한민국', nameEn: 'South Korea', lat: 36.0, lng: 127.8, rank: 1 }, + { name: '북한', nameEn: 'North Korea', lat: 40.0, lng: 127.0, rank: 1 }, + { name: '일본', nameEn: 'Japan', lat: 36.2, lng: 138.3, rank: 1 }, + { name: '중국', nameEn: 'China', lat: 35.9, lng: 104.2, rank: 1 }, + { name: '대만', nameEn: 'Taiwan', lat: 23.7, lng: 121.0, rank: 2 }, + { name: '러시아', nameEn: 'Russia', lat: 55.0, lng: 105.0, rank: 1 }, + { name: '몽골', nameEn: 'Mongolia', lat: 46.9, lng: 103.8, rank: 1 }, + { name: '필리핀', nameEn: 'Philippines', lat: 12.9, lng: 121.8, rank: 2 }, + { name: '베트남', nameEn: 'Vietnam', lat: 14.1, lng: 108.3, rank: 2 }, + + // ── 동해/서해/남해 해역 ── + { name: '동해', nameEn: 'East Sea', lat: 38.5, lng: 132.0, rank: 2 }, + { name: '서해(황해)', nameEn: 'Yellow Sea', lat: 35.5, lng: 124.0, rank: 2 }, + { name: '남해', nameEn: 'South Sea', lat: 33.0, lng: 128.0, rank: 3 }, + { name: '동중국해', nameEn: 'East China Sea', lat: 28.0, lng: 126.0, rank: 2 }, + { name: '남중국해', nameEn: 'South China Sea', lat: 15.0, lng: 115.0, rank: 2 }, + { name: '태평양', nameEn: 'Pacific Ocean', lat: 25.0, lng: 155.0, rank: 1 }, + + // ── 인도양/아프리카 동부 ── + { name: '인도', nameEn: 'India', lat: 20.6, lng: 79.0, rank: 1 }, + { name: '인도양', nameEn: 'Indian Ocean', lat: 0.0, lng: 75.0, rank: 1 }, +]; + +/** GeoJSON FeatureCollection 변환 */ +export function countryLabelsGeoJSON(): GeoJSON.FeatureCollection { + return { + type: 'FeatureCollection', + features: countryLabels.map((c, i) => ({ + type: 'Feature' as const, + id: i, + geometry: { type: 'Point' as const, coordinates: [c.lng, c.lat] }, + properties: { name: c.name, nameEn: c.nameEn, rank: c.rank }, + })), + }; +} diff --git a/frontend/src/data/damagedShips.ts b/frontend/src/data/damagedShips.ts new file mode 100644 index 0000000..6f6b54d --- /dev/null +++ b/frontend/src/data/damagedShips.ts @@ -0,0 +1,148 @@ +// ═══ 피격 선박 데이터 ═══ +// sampleData.ts의 해상 공격 이벤트와 연동 + +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; +const DAY = 24 * HOUR; + +export interface DamagedShip { + id: string; + name: string; + flag: string; // 국적 코드 + type: string; // VLCC, LNG, Container 등 + lat: number; + lng: number; + damagedAt: number; // unix ms — 피격 시각 + cause: string; // 기뢰, 드론, 대함미사일 등 + damage: 'sunk' | 'severe' | 'moderate' | 'minor'; + description: string; + eventId: string; // 연관 GeoEvent id +} + +export const damagedShips: DamagedShip[] = [ + // DAY 3 — 3월 3일 + { + id: 'ds-1', + name: 'ATHENA GLORY', + flag: 'GR', + type: 'VLCC', + lat: 26.5500, lng: 56.3500, + damagedAt: T0 + 2 * DAY, + cause: '기뢰 접촉', + damage: 'severe', + description: '그리스 국적 VLCC, 호르무즈 해협 기뢰 접촉. 원유 유출.', + eventId: 'd3-sea1', + }, + // DAY 6 — 3월 6일: IRGC 고속정 + { + id: 'ds-2', + name: 'IRGC FAST BOAT x4', + flag: 'IR', + type: 'MILITARY', + lat: 26.4000, lng: 56.4000, + damagedAt: T0 + 2 * DAY + 3 * HOUR, + cause: '미 구축함 함포 교전', + damage: 'sunk', + description: 'IRGC 고속정 4척, 미 구축함 교전 중 격침.', + eventId: 'd3-sea2', + }, + // DAY 11 — 3월 11일: 호르무즈 기뢰 자폭 + { + id: 'ds-3', + name: 'IRGC MINESWEEPER', + flag: 'IR', + type: 'MILITARY', + lat: 26.5667, lng: 56.2500, + damagedAt: T0 + 10 * DAY + 4 * HOUR, + cause: '자체 기뢰 폭발', + damage: 'sunk', + description: 'IRGC 소해정 1척, 자체 배치 기뢰 폭발로 침몰.', + eventId: 'd11-ir1', + }, + // DAY 12 — 3월 12일 + { + id: 'ds-4', + name: 'SHOWA MARU', + flag: 'JP', + type: 'VLCC', + lat: 26.3500, lng: 56.5000, + damagedAt: T0 + 11 * DAY, + cause: '기뢰 접촉', + damage: 'severe', + description: '일본 국적 VLCC, 호르무즈 해협 기뢰 접촉. 선체 파공, 원유 유출.', + eventId: 'd12-sea1', + }, + { + id: 'ds-5', + name: 'SK INNOVATION', + flag: 'KR', + type: 'LNG', + lat: 26.2000, lng: 56.6000, + damagedAt: T0 + 11 * DAY + 2 * HOUR, + cause: 'IRGC 자폭드론', + damage: 'minor', + description: '한국행 LNG 운반선, 드론 2대 피격. 경미 손상.', + eventId: 'd12-sea2', + }, + { + id: 'ds-6', + name: 'ATHENS EXPRESS', + flag: 'GR', + type: 'CONTAINER', + lat: 25.8000, lng: 56.8000, + damagedAt: T0 + 11 * DAY + 3 * HOUR, + cause: 'IRGC 누르 대함미사일', + damage: 'moderate', + description: '그리스 컨테이너선, 대함미사일 피격. 화재, 승조원 부상.', + eventId: 'd12-sea3', + }, + { + id: 'ds-7', + name: 'IRGC FAST BOAT x5', + flag: 'IR', + type: 'MILITARY', + lat: 26.6000, lng: 56.1000, + damagedAt: T0 + 11 * DAY + 5 * HOUR, + cause: '미 구축함 함포/CIWS', + damage: 'sunk', + description: 'IRGC 고속정 5척, USS 마이클 머피 교전 중 격침.', + eventId: 'd12-sea4', + }, + // DAY 12 후반 — 3월 12일 오후 + { + id: 'ds-8', + name: 'IRGC MINE LAYER', + flag: 'IR', + type: 'MILITARY', + lat: 26.4500, lng: 56.3500, + damagedAt: T0 + 11 * DAY + 14 * HOUR, + cause: '자체 기뢰 폭발', + damage: 'sunk', + description: 'IRGC 기뢰부설정, 자체 배치 기뢰 접촉 폭발로 침몰. 승조원 12명 사망 추정.', + eventId: 'd12-sea5', + }, + { + id: 'ds-9', + name: 'PACIFIC PIONEER', + flag: 'PA', + type: 'BULK', + lat: 26.3000, lng: 56.7000, + damagedAt: T0 + 11 * DAY + 16 * HOUR, + cause: '부유기뢰 접촉', + damage: 'moderate', + description: '파나마 국적 벌크선, 오만만 북부 부유기뢰 접촉. 선수 파공, 느린 침수. 오만 해군 구조.', + eventId: 'd12-sea6', + }, + { + id: 'ds-10', + name: 'IRGC FAST BOAT x20+', + flag: 'IR', + type: 'MILITARY', + lat: 26.9800, lng: 56.0800, + damagedAt: T0 + 11 * DAY + 14 * HOUR, + cause: '미 F/A-18F 공습', + damage: 'sunk', + description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.', + eventId: 'd12-us5', + }, +]; diff --git a/frontend/src/data/iranBorder.ts b/frontend/src/data/iranBorder.ts new file mode 100644 index 0000000..4ecd6e3 --- /dev/null +++ b/frontend/src/data/iranBorder.ts @@ -0,0 +1,105 @@ +// Simplified Iran border polygon (GeoJSON) +// ~60 points tracing the approximate boundary +export const iranBorderGeoJSON: GeoJSON.Feature = { + type: 'Feature', + properties: { name: 'Iran' }, + geometry: { + type: 'Polygon', + coordinates: [[ + // Northwest — Turkey/Armenia/Azerbaijan border + [44.0, 39.4], + [44.8, 39.7], + [45.5, 39.0], + [46.0, 38.9], + [47.0, 39.2], + [48.0, 38.8], + [48.5, 38.5], + [48.9, 38.4], + // Caspian Sea coast (south shore) + [49.0, 38.4], + [49.5, 37.5], + [50.0, 37.4], + [50.5, 37.0], + [51.0, 36.8], + [51.5, 36.8], + [52.0, 36.9], + [53.0, 36.9], + [53.9, 37.1], + [54.7, 37.3], + [55.4, 37.2], + [56.0, 37.4], + [57.0, 37.4], + [57.4, 37.6], + // Northeast — Turkmenistan border + [58.0, 37.6], + [58.8, 37.6], + [59.3, 37.5], + [60.0, 36.7], + [60.5, 36.5], + [61.0, 36.6], + [61.2, 36.6], + // East — Afghanistan border + [61.2, 35.6], + [61.2, 34.7], + [61.0, 34.0], + [60.5, 33.7], + [60.5, 33.1], + [60.8, 32.2], + [60.8, 31.5], + // Southeast — Pakistan border + [61.7, 31.4], + [61.8, 30.8], + [61.4, 29.8], + [60.9, 29.4], + [60.6, 28.5], + [61.0, 27.2], + [62.0, 26.4], + [63.2, 25.2], + // South coast — Gulf of Oman / Persian Gulf + [61.6, 25.2], + [60.0, 25.3], + [58.5, 25.6], + [57.8, 25.7], + [57.3, 26.0], + [56.4, 26.2], + [56.1, 26.0], + [55.5, 26.0], + [54.8, 26.5], + [54.3, 26.5], + [53.5, 26.6], + [52.5, 27.2], + [51.5, 27.9], + [50.8, 28.3], + [50.5, 28.8], + [50.2, 29.1], + [50.0, 29.3], + [49.5, 29.6], + [49.0, 29.8], + [48.6, 29.9], + // Southwest — Iraq border (Shatt al-Arab and west) + [48.4, 30.4], + [48.0, 30.5], + [47.7, 30.9], + [47.6, 31.4], + [47.1, 31.6], + [46.5, 32.0], + [46.1, 32.2], + [45.6, 32.9], + [45.4, 33.4], + [45.5, 33.9], + [45.6, 34.2], + [45.4, 34.5], + [45.2, 35.0], + [45.1, 35.4], + [45.4, 35.8], + [45.1, 36.0], + [44.8, 36.4], + [44.5, 37.0], + [44.3, 37.5], + [44.2, 38.0], + [44.0, 38.4], + [44.0, 39.0], + [44.0, 39.4], // close polygon + ]], + }, +}; diff --git a/frontend/src/data/oilFacilities.ts b/frontend/src/data/oilFacilities.ts new file mode 100644 index 0000000..e181239 --- /dev/null +++ b/frontend/src/data/oilFacilities.ts @@ -0,0 +1,397 @@ +import type { OilFacility } from '../types'; + +// T0 = 이란 보복 공격 기준시각 +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; + +// 이란 주요 석유·가스 시설 데이터 +// 출처: NIOC, EIA, IEA 공개 데이터 기반 +export const iranOilFacilities: OilFacility[] = [ + // ═══ 주요 정유시설 (Refineries) ═══ + { + id: 'ref-abadan', + name: 'Abadan Refinery', + nameKo: '아바단 정유소', + lat: 30.3358, lng: 48.2870, + type: 'refinery', + capacityBpd: 400_000, + operator: 'NIOC', + description: '이란 최대·최고(最古) 정유시설. 1912년 건설. 일 40만 배럴 처리.', + planned: true, + plannedLabel: 'D+17 B-2 정밀폭격 예정 — 이란 최대 정유능력 무력화 목표', + }, + { + id: 'ref-isfahan', + name: 'Isfahan Refinery', + nameKo: '이스파한 정유소', + lat: 32.6100, lng: 51.7300, + type: 'refinery', + capacityBpd: 375_000, + operator: 'NIOC', + description: '이란 2위 정유소. 일 37.5만 배럴 처리. 중부 이란 핵심 시설.', + planned: true, + plannedLabel: 'D+18 F-35 편대 공격 예정 — 중부 정유능력 차단', + }, + { + id: 'ref-bandarabbas', + name: 'Bandar Abbas Refinery (Persian Gulf Star)', + nameKo: '반다르아바스 정유소 (페르시안걸프스타르)', + lat: 27.1700, lng: 56.2200, + type: 'refinery', + capacityBpd: 360_000, + operator: 'PGPIC', + description: '2017년 완공 최신 정유소. 가스 응축액 처리. 일 36만 배럴.', + damaged: true, + damagedAt: T0 - 3 * HOUR, // IL airstrike il5 at 09:01 UTC + }, + { + id: 'ref-tehran', + name: 'Tehran Refinery', + nameKo: '테헤란 정유소', + lat: 35.5700, lng: 51.4100, + type: 'refinery', + capacityBpd: 250_000, + operator: 'NIOC', + description: '수도 에너지 공급 핵심. 일 25만 배럴.', + planned: true, + plannedLabel: 'D+19 테헤란 에너지 고립 작전 — 수도 연료공급 차단', + }, + { + id: 'ref-tabriz', + name: 'Tabriz Refinery', + nameKo: '타브리즈 정유소', + lat: 38.0100, lng: 46.2700, + type: 'refinery', + capacityBpd: 110_000, + operator: 'NIOC', + description: '북서부 이란 주요 정유소. 일 11만 배럴.', + damaged: true, + damagedAt: T0 - 6.5 * HOUR, // US airstrike us12 at 05:31 UTC + }, + { + id: 'ref-arak', + name: 'Arak Refinery (Imam Khomeini)', + nameKo: '아라크 정유소 (이맘 호메이니)', + lat: 34.0700, lng: 49.7100, + type: 'refinery', + capacityBpd: 250_000, + operator: 'NIOC', + description: '중부 이란 전략적 위치. 일 25만 배럴.', + planned: true, + plannedLabel: 'D+18 F-15E 공격 예정 — 중부 연료보급 거점 파괴', + }, + { + id: 'ref-shiraz', + name: 'Shiraz Refinery', + nameKo: '시라즈 정유소', + lat: 29.5500, lng: 52.4800, + type: 'refinery', + capacityBpd: 60_000, + operator: 'NIOC', + description: '남부 이란 정유소. 일 6만 배럴.', + }, + { + id: 'ref-lavan', + name: 'Lavan Refinery', + nameKo: '라반 정유소', + lat: 26.8100, lng: 53.3500, + type: 'refinery', + capacityBpd: 60_000, + operator: 'NIOC', + description: '라반섬 해상 정유소. 일 6만 배럴. 페르시아만 원유 수출.', + }, + + // ═══ 주요 유전 (Oil Fields) ═══ + { + id: 'oil-ahwaz', + name: 'Ahwaz-Asmari Oil Field', + nameKo: '아흐바즈-아스마리 유전', + lat: 31.3200, lng: 48.6700, + type: 'oilfield', + capacityBpd: 750_000, + reservesBbl: 25.5, + operator: 'NIOC', + description: '이란 최대 유전. 확인매장량 255억 배럴. 후제스탄 주.', + }, + { + id: 'oil-marunfield', + name: 'Marun Oil Field', + nameKo: '마룬 유전', + lat: 31.6500, lng: 49.2000, + type: 'oilfield', + capacityBpd: 520_000, + reservesBbl: 16.0, + operator: 'NIOC', + description: '이란 2위 유전. 확인매장량 160억 배럴.', + }, + { + id: 'oil-gachsaran', + name: 'Gachsaran Oil Field', + nameKo: '가치사란 유전', + lat: 30.3600, lng: 50.8000, + type: 'oilfield', + capacityBpd: 560_000, + reservesBbl: 15.0, + operator: 'NIOC', + description: '이란 3위 유전. 매장량 150억 배럴. 자그로스 산맥 서남.', + }, + { + id: 'oil-agha-jari', + name: 'Aghajari Oil Field', + nameKo: '아가자리 유전', + lat: 30.7500, lng: 49.8300, + type: 'oilfield', + capacityBpd: 200_000, + reservesBbl: 14.0, + operator: 'NIOC', + description: '역사적 대형 유전. 매장량 140억 배럴.', + }, + { + id: 'oil-karangoil', + name: 'Karanj Oil Field', + nameKo: '카란즈 유전', + lat: 31.9500, lng: 49.0500, + type: 'oilfield', + capacityBpd: 180_000, + reservesBbl: 5.0, + operator: 'NIOC', + description: '후제스탄 주 주요 유전. 매장량 50억 배럴.', + }, + { + id: 'oil-yadavaran', + name: 'Yadavaran Oil Field', + nameKo: '야다바란 유전', + lat: 31.0200, lng: 47.8500, + type: 'oilfield', + capacityBpd: 300_000, + reservesBbl: 17.0, + operator: 'NIOC / Sinopec', + description: '이라크 국경 인근 초대형 유전. 매장량 170억 배럴. 중국 합작.', + }, + { + id: 'oil-azadegan', + name: 'Azadegan Oil Field', + nameKo: '아자데간 유전', + lat: 31.5000, lng: 47.6000, + type: 'oilfield', + capacityBpd: 220_000, + reservesBbl: 33.0, + operator: 'NIOC', + description: '이란 최대 미개발 유전. 매장량 330억 배럴.', + }, + + // ═══ 가스전 (Gas Fields) ═══ + { + id: 'gas-southpars', + name: 'South Pars Gas Field', + nameKo: '사우스파르스 가스전', + lat: 27.0000, lng: 52.0000, + type: 'gasfield', + capacityMcfd: 20_000, + reservesTcf: 500, + operator: 'Pars Oil & Gas Co.', + description: '세계 최대 가스전 (카타르 노스돔과 공유). 매장량 500조 입방피트. 이란 가스 수출 핵심.', + }, + { + id: 'gas-northpars', + name: 'North Pars Gas Field', + nameKo: '노스파르스 가스전', + lat: 27.5000, lng: 52.5000, + type: 'gasfield', + capacityMcfd: 2_500, + reservesTcf: 50, + operator: 'NIOC', + description: '페르시아만 해상 가스전. 매장량 50조 입방피트.', + }, + { + id: 'gas-kish', + name: 'Kish Gas Field', + nameKo: '키시 가스전', + lat: 26.5500, lng: 53.9800, + type: 'gasfield', + capacityMcfd: 3_000, + reservesTcf: 58, + operator: 'NIOC', + description: '키시섬 인근 해상 가스전. 매장량 58조 입방피트.', + }, + + // ═══ 수출 터미널 (Export Terminals) ═══ + { + id: 'term-kharg', + name: 'Kharg Island Terminal', + nameKo: '하르그섬 수출터미널', + lat: 29.2300, lng: 50.3200, + type: 'terminal', + capacityBpd: 5_000_000, + operator: 'NIOC', + description: '이란 원유 수출의 90% 처리. 일 500만 배럴 수출 능력. 전략적 최핵심 시설.', + planned: true, + plannedLabel: 'D+16 최우선 타격 예정 — 이란 원유 수출 90% 차단 목표. B-2·F-35 합동 공격.', + }, + { + id: 'term-lavan', + name: 'Lavan Island Terminal', + nameKo: '라반섬 수출터미널', + lat: 26.7900, lng: 53.3600, + type: 'terminal', + capacityBpd: 200_000, + operator: 'NIOC', + description: '라반섬 원유 수출터미널. 일 20만 배럴.', + }, + { + id: 'term-jask', + name: 'Jask Oil Terminal', + nameKo: '자스크 수출터미널', + lat: 25.6400, lng: 57.7700, + type: 'terminal', + capacityBpd: 1_000_000, + operator: 'NIOC', + description: '호르무즈 해협 우회 수출터미널. 2021년 개장. 일 100만 배럴.', + planned: true, + plannedLabel: 'D+17 우회 수출로 차단 작전 — 오만만 경유 원유수출 봉쇄', + }, + + // ═══ 석유화학단지 (Petrochemical) ═══ + { + id: 'petro-assaluyeh', + name: 'Assaluyeh Petrochemical Complex', + nameKo: '아살루예 석유화학단지', + lat: 27.4800, lng: 52.6100, + type: 'petrochemical', + operator: 'NPC', + description: '사우스파르스 육상 가스처리 허브. 세계 최대급 석유화학 단지.', + planned: true, + plannedLabel: 'D+20 가스수출 차단 작전 — 사우스파르스 육상 처리시설 타격', + }, + { + id: 'petro-mahshahr', + name: 'Mahshahr Petrochemical Zone', + nameKo: '마흐샤르 석유화학단지', + lat: 30.4600, lng: 49.1700, + type: 'petrochemical', + operator: 'NPC', + description: '반다르 이맘 호메이니 인근 대규모 석유화학 단지.', + }, + + // ═══ 담수화 시설 (Desalination Plants) — 호르무즈 해협 인근 ═══ + { + id: 'desal-jebel-ali', + name: 'Jebel Ali Desalination Plant', + nameKo: '제벨알리 담수화시설', + lat: 25.0547, lng: 55.0272, + type: 'desalination', + capacityMgd: 636, + operator: 'DEWA', + description: '세계 최대 담수화시설. 일 636만 갤런. 두바이 수돗물 98% 공급.', + }, + { + id: 'desal-taweelah', + name: 'Taweelah Desalination Plant', + nameKo: '타위라 담수화시설', + lat: 24.6953, lng: 54.7428, + type: 'desalination', + capacityMgd: 200, + operator: 'EWEC / ACWA Power', + description: '세계 최대 RO 담수화시설. 일 2억 갤런. 아부다비 핵심 수자원.', + }, + { + id: 'desal-fujairah', + name: 'Fujairah Desalination Plant', + nameKo: '푸자이라 담수화시설', + lat: 25.1288, lng: 56.3264, + type: 'desalination', + capacityMgd: 130, + operator: 'FEWA', + description: '호르무즈 해협 동측. 일 1.3억 갤런. 동부 에미리트 수자원.', + }, + { + id: 'desal-sohar', + name: 'Sohar Desalination Plant', + nameKo: '소하르 담수화시설', + lat: 24.3476, lng: 56.7492, + type: 'desalination', + capacityMgd: 63, + operator: 'Sohar Power / Suez', + description: '오만 북부 산업지대 수자원. 일 6,300만 갤런.', + }, + { + id: 'desal-barka', + name: 'Barka Desalination Plant', + nameKo: '바르카 담수화시설', + lat: 23.6850, lng: 57.8900, + type: 'desalination', + capacityMgd: 42, + operator: 'Oman Power & Water', + description: '오만 수도 무스카트 인근. 일 4,200만 갤런.', + }, + { + id: 'desal-ghubrah', + name: 'Al Ghubrah Desalination Plant', + nameKo: '알구브라 담수화시설', + lat: 23.6000, lng: 58.4200, + type: 'desalination', + capacityMgd: 68, + operator: 'PAEW', + description: '무스카트 시내 위치. 오만 최대 담수화시설. 일 6,800만 갤런.', + }, + { + id: 'desal-ras-al-khair', + name: 'Ras Al Khair Desalination Plant', + nameKo: '라스 알 카이르 담수화시설', + lat: 27.1500, lng: 49.2300, + type: 'desalination', + capacityMgd: 228, + operator: 'SWCC', + description: '세계 최대 하이브리드 담수화시설. 사우디 동부 해안. 일 2.28억 갤런.', + }, + { + id: 'desal-jubail', + name: 'Jubail Desalination Plant', + nameKo: '주바일 담수화시설', + lat: 26.9598, lng: 49.5687, + type: 'desalination', + capacityMgd: 400, + operator: 'SWCC', + description: '사우디 동부 주바일 산업도시. 일 4억 갤런. 리야드까지 송수.', + }, + { + id: 'desal-hidd', + name: 'Al Hidd Desalination Plant', + nameKo: '알 히드 담수화시설', + lat: 26.1500, lng: 50.6600, + type: 'desalination', + capacityMgd: 90, + operator: 'EWA Bahrain', + description: '바레인 주요 수자원. 일 9,000만 갤런. 국가 물 수요 80% 담당.', + }, + { + id: 'desal-ras-laffan', + name: 'Ras Laffan Desalination Plant', + nameKo: '라스 라판 담수화시설', + lat: 25.9140, lng: 51.5260, + type: 'desalination', + capacityMgd: 63, + operator: 'Kahramaa', + description: '카타르 북부 LNG 허브 인접. 일 6,300만 갤런.', + }, + { + id: 'desal-azzour', + name: 'Az-Zour Desalination Plant', + nameKo: '아즈주르 담수화시설', + lat: 28.7200, lng: 48.3700, + type: 'desalination', + capacityMgd: 107, + operator: 'MEW Kuwait', + description: '쿠웨이트 남부. 일 1.07억 갤런. 쿠웨이트시 수자원.', + }, + { + id: 'desal-bandarabbas', + name: 'Bandar Abbas Desalination', + nameKo: '반다르아바스 담수화시설', + lat: 27.1800, lng: 56.2700, + type: 'desalination', + capacityMgd: 18, + operator: 'ABFA Iran', + description: '이란 호르무즈간 주. 일 1,800만 갤런. 이란 최대 해수담수화.', + }, +]; diff --git a/frontend/src/data/sampleData.ts b/frontend/src/data/sampleData.ts new file mode 100644 index 0000000..44bc8df --- /dev/null +++ b/frontend/src/data/sampleData.ts @@ -0,0 +1,1495 @@ +import type { GeoEvent, SensorLog } from '../types'; + +// 기준 시간: 리플레이 시작 3월 1일 00:01 UTC +// T0 (이란 보복 공격파) 12:01 UTC = 시작 후 12시간 +// 배경: 미국-이스라엘 합동 이란 공습 2월 28일 개시 +// 3월 1일, 이란 대규모 보복 공격 개시 +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; +const MIN = 60_000; +const DAY = 24 * HOUR; + +export const REPLAY_START = new Date('2026-03-01T00:01:00Z').getTime(); +// REPLAY_END: 오늘 23:59 UTC (항상 최신 상태 유지) +const _today = new Date(); +_today.setUTCHours(23, 59, 0, 0); +export const REPLAY_END = Math.max( + new Date('2026-03-12T23:59:00Z').getTime(), // 최소 D+12 + _today.getTime(), +); + +export const sampleEvents: GeoEvent[] = [ + // ═══════════════════════════════════════════════════════════════════ + // 1단계: 미국-이스라엘의 이란 공습 (2월 28일부터 계속) + // ═══════════════════════════════════════════════════════════════════ + + // ─── 00:00–03:00 UTC: 테헤란 및 군사시설 야간 공습 ─── + { + id: 'us1', timestamp: T0 - 11.5 * HOUR, + lat: 35.6997, lng: 51.4038, type: 'airstrike', source: 'US', + label: '테헤란 — 최고지도자 관저 공습', + description: '미국-이스라엘 합동공격으로 최고지도자 관저 단지 파괴. 하메네이 암살 보도.', + intensity: 100, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Tehran_skyline_may_2007.jpg/800px-Tehran_skyline_may_2007.jpg', + imageCaption: '테헤란 시가지 전경 (Wikimedia Commons)', + }, + { + id: 'us2', timestamp: T0 - 11 * HOUR, + lat: 35.7018, lng: 51.4223, type: 'airstrike', source: 'US', + label: '테헤란 — IRGC 본부 (말렉아슈타르)', + description: 'IRGC 말렉아슈타르 건물, 순항미사일 공격으로 완전 파괴.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Sarallah_Base.jpg/800px-Sarallah_Base.jpg', + imageCaption: 'IRGC 사령부 (Wikimedia Commons)', + }, + { + id: 'us3', timestamp: T0 - 10.5 * HOUR, + lat: 35.7150, lng: 51.3900, type: 'airstrike', source: 'US', + label: '테헤란 — 대통령 관저 타격', + description: '페제시키안 대통령 집무실 타격. 건물 심각한 손상.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Sa%27dAbad_Palace.jpg/800px-Sa%27dAbad_Palace.jpg', + imageCaption: '테헤란 대통령 관저 (Wikimedia Commons)', + }, + { + id: 'us4', timestamp: T0 - 10 * HOUR, + lat: 35.5162, lng: 51.7621, type: 'airstrike', source: 'US', + label: '파르친 — 미사일 생산시설', + description: '파르친 군사단지(테헤란 남동 40km) 탄도미사일 고체연료 혼합동 3개 파괴.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Parchin_Possible_Location_of_Large_Explosion_Containment_Vessel.jpg/800px-Parchin_Possible_Location_of_Large_Explosion_Containment_Vessel.jpg', + imageCaption: '파르친 군사단지 위성사진 (Wikimedia Commons)', + }, + { + id: 'us5', timestamp: T0 - 9.5 * HOUR, + lat: 35.7384, lng: 51.5778, type: 'airstrike', source: 'US', + label: '호지르 — 지하 터널 단지', + description: '호지르 군사기지(테헤란 동쪽 20km) 공습. 지하 미사일 생산 터널 타격.', + intensity: 90, + }, + { + id: 'us6', timestamp: T0 - 9 * HOUR, + lat: 35.8400, lng: 50.9391, type: 'airstrike', source: 'US', + label: '카라즈 — 군수산업 시설', + description: '카라즈 군수산업 시설 타격. 방공 레이더 파괴.', + intensity: 80, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Karaj_panorama.jpg/800px-Karaj_panorama.jpg', + imageCaption: '카라즈 시 전경 (Wikimedia Commons)', + }, + + // ─── 03:00–06:00 UTC: 핵시설 및 전략시설 타격 ─── + { + id: 'us7', timestamp: T0 - 9 * HOUR, + lat: 33.7219, lng: 51.7276, type: 'airstrike', source: 'US', + label: '나탄즈 — 핵시설 타격', + description: '미국 벙커버스터 폭탄으로 나탄즈 우라늄 농축시설 타격. IAEA 피해 확인.', + intensity: 100, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Natanz_nuclear.jpg/800px-Natanz_nuclear.jpg', + imageCaption: '나탄즈 핵시설 위성사진 (Wikimedia Commons)', + }, + { + id: 'us8', timestamp: T0 - 8.5 * HOUR, + lat: 32.6546, lng: 51.6680, type: 'airstrike', source: 'US', + label: '이스파한 — 핵연구센터', + description: '이스파한 핵기술센터 타격. 위성 이미지로 심각한 피해 확인.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Isfahan_Nuclear_Facility.jpg/800px-Isfahan_Nuclear_Facility.jpg', + imageCaption: '이스파한 핵기술센터 (Wikimedia Commons)', + }, + { + id: 'us9', timestamp: T0 - 8 * HOUR, + lat: 28.8297, lng: 50.8862, type: 'airstrike', source: 'US', + label: '부셰르 — 원자력발전소 인근', + description: '부셰르 원전 인근 타격. 이란은 원자로 무사 주장; 공항 터미널 파괴.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Bushehr_Nuclear_Power_Plant_aerial_view.jpg/800px-Bushehr_Nuclear_Power_Plant_aerial_view.jpg', + imageCaption: '부셰르 원자력발전소 항공사진 (Wikimedia Commons)', + }, + { + id: 'us10', timestamp: T0 - 7.5 * HOUR, + lat: 34.3460, lng: 47.1581, type: 'airstrike', source: 'US', + label: '케르만샤 — 공군기지', + description: '케르만샤 공군기지 타격. 다수 IRGC 공군 항공기 지상 파괴.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Kermanshah_city.jpg/800px-Kermanshah_city.jpg', + imageCaption: '케르만샤 시 전경 (Wikimedia Commons)', + }, + { + id: 'us11', timestamp: T0 - 7 * HOUR, + lat: 34.6416, lng: 50.8746, type: 'airstrike', source: 'US', + label: '쿰 — 미사일 저장고', + description: 'IRGC 탄도미사일 저장 시설(쿰 인근) 파괴.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/09/Qom_from_above.jpg/800px-Qom_from_above.jpg', + imageCaption: '쿰 시 항공사진 (Wikimedia Commons)', + }, + { + id: 'us12', timestamp: T0 - 6.5 * HOUR, + lat: 38.1289, lng: 46.2350, type: 'airstrike', source: 'US', + label: '타브리즈 — 공군기지', + description: '타브리즈 샤히드 파쿠리 공군기지 타격. F-14 전투기 및 레이더 파괴.', + intensity: 80, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/77/Tabriz_Panorama.jpg/800px-Tabriz_Panorama.jpg', + imageCaption: '타브리즈 시 전경 (Wikimedia Commons)', + }, + { + id: 'us13', timestamp: T0 - 6 * HOUR, + lat: 36.0716, lng: 55.0164, type: 'airstrike', source: 'US', + label: '샤흐루드 — 우주/미사일 센터', + description: '샤흐루드 우주센터 타격. 미사일 시험 및 고체연료 모터 조립에 사용.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Shahroud_in_Semnan.jpg/800px-Shahroud_in_Semnan.jpg', + imageCaption: '샤흐루드 시 위치 (Wikimedia Commons)', + }, + { + id: 'us14', timestamp: T0 - 5.5 * HOUR, + lat: 29.5392, lng: 52.5900, type: 'airstrike', source: 'US', + label: '시라즈 — 공군기지', + description: '시라즈 샤히드 다스트가이브 공군기지 피해. IRIAF Su-24 항공기 파괴.', + intensity: 80, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7b/Shiraz_city.jpg/800px-Shiraz_city.jpg', + imageCaption: '시라즈 시 전경 (Wikimedia Commons)', + }, + { + id: 'us15', timestamp: T0 - 5 * HOUR, + lat: 33.6374, lng: 46.4227, type: 'airstrike', source: 'US', + label: '일람 — 군사기지', + description: '일람 주(이라크 국경 인근) IRGC 지상군 기지 타격.', + intensity: 75, + }, + { + id: 'us16', timestamp: T0 - 4.5 * HOUR, + lat: 35.7100, lng: 52.0700, type: 'airstrike', source: 'US', + label: '다마반드 — 미사일 기지', + description: '테헤란 동쪽 다마반드 지하 미사일 기지, 벙커버스터로 타격.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Mount_Damavand_in_winter.jpg/800px-Mount_Damavand_in_winter.jpg', + imageCaption: '다마반드 산 전경 (Wikimedia Commons)', + }, + + // ═══════════════════════════════════════════════════════════════════ + // 1-B단계: 이스라엘 독자 공습 (IAF F-35I Adir 편대) + // ═══════════════════════════════════════════════════════════════════ + { + id: 'il1', timestamp: T0 - 10 * HOUR, + lat: 33.7219, lng: 51.7276, type: 'airstrike', source: 'IL', + label: '나탄즈 — 이스라엘 F-35I 공습', + description: 'IAF F-35I 아디르 편대, 나탄즈 핵시설 지하 원심분리기실 정밀타격. GBU-28 벙커버스터 사용.', + intensity: 100, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/F-35I_%22Adir%22_%2842%29_%28cropped%29.jpg/800px-F-35I_%22Adir%22_%2842%29_%28cropped%29.jpg', + imageCaption: 'IAF F-35I 아디르 전투기 (Wikimedia Commons)', + }, + { + id: 'il2', timestamp: T0 - 9 * HOUR, + lat: 32.6546, lng: 51.6680, type: 'airstrike', source: 'IL', + label: '이스파한 — 이스라엘 공습', + description: 'IAF 편대, 이스파한 핵기술센터 UCF(우라늄전환시설) 타격. 위성영상 확인.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Isfahan_Naqsh-e_Jahan_square.jpg/800px-Isfahan_Naqsh-e_Jahan_square.jpg', + imageCaption: '이스파한 시 전경 (Wikimedia Commons)', + }, + { + id: 'il3', timestamp: T0 - 7 * HOUR, + lat: 34.3460, lng: 47.1581, type: 'airstrike', source: 'IL', + label: '케르만샤 — 이스라엘 F-15I 공습', + description: 'IAF F-15I 라암 편대, 케르만샤 IRGC 공군기지 무장해제 공습. 이라크 영공 경유.', + intensity: 85, + }, + { + id: 'il4', timestamp: T0 - 5 * HOUR, + lat: 35.7384, lng: 51.5778, type: 'airstrike', source: 'IL', + label: '호지르 — 이스라엘 정밀타격', + description: 'IAF, 호지르 지하 미사일 생산터널 입구 정밀폭격. 고체연료 생산라인 파괴 추정.', + intensity: 90, + }, + { + id: 'il5', timestamp: T0 - 3 * HOUR, + lat: 27.1832, lng: 56.2764, type: 'airstrike', source: 'IL', + label: '반다르아바스 — 이스라엘 해군기지 타격', + description: 'IAF, 반다르아바스 IRGC 해군기지 초계정 계류시설 및 미사일 저장고 타격.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Bandar_Abbas_Iran_port.jpg/800px-Bandar_Abbas_Iran_port.jpg', + imageCaption: '반다르아바스 항구 (Wikimedia Commons)', + }, + { + id: 'il6', timestamp: T0 + 1 * HOUR, + lat: 34.6416, lng: 50.8746, type: 'airstrike', source: 'IL', + label: '쿰 — 이스라엘 2차 공습', + description: 'IAF F-35I, 쿰 인근 잔존 미사일 발사대 및 지하 사일로 2차 타격.', + intensity: 90, + }, + { + id: 'il7', timestamp: T0 + 3 * HOUR, + lat: 36.0716, lng: 55.0164, type: 'airstrike', source: 'IL', + label: '샤흐루드 — 이스라엘 우주센터 타격', + description: 'IAF 장거리 공습, 샤흐루드 우주센터 미사일 시험시설 및 발사대 파괴.', + intensity: 80, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 2단계: 이란 경보 및 보복 준비 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'a1', timestamp: T0 - 11 * HOUR, + lat: 32.4279, lng: 53.6880, type: 'alert', + label: '이란 영공 폐쇄', + description: '이란 전 민간 영공 폐쇄. IRGC 전면 군사 동원 명령.', + }, + { + id: 'a2', timestamp: T0 - 8 * HOUR, + lat: 26.5667, lng: 56.2500, type: 'alert', + label: '호르무즈 해협 봉쇄', + description: 'IRGC 해군, 호르무즈 해협 봉쇄 선언. 기뢰 배치. 유가 40% 급등.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/Strait_of_Hormuz.jpg/800px-Strait_of_Hormuz.jpg', + imageCaption: '호르무즈 해협 위성사진 (Wikimedia Commons)', + }, + { + id: 'a3', timestamp: T0 - 6 * HOUR, + lat: 33.3152, lng: 44.3661, type: 'alert', + label: '이라크 — 미군기지 경보 격상', + description: '이라크 내 모든 미군기지 THREATCON DELTA 발령. 이라크 PMF 동원.', + }, + { + id: 'a4', timestamp: T0 - 5 * HOUR, + lat: 25.1175, lng: 51.3150, type: 'alert', + label: '알우데이드 — DEFCON 2', + description: 'CENTCOM, 카타르 알우데이드 공군기지 방호태세 DEFCON 2 격상.', + }, + { + id: 'a5', timestamp: T0 - 4 * HOUR, + lat: 26.2235, lng: 50.6009, type: 'alert', + label: '바레인 제5함대 — 경보', + description: '바레인 주파이르 미 해군 제5함대 사령부 최대 방호태세 발령.', + }, + { + id: 'a6', timestamp: T0 - 3 * HOUR, + lat: 32.0853, lng: 34.7818, type: 'alert', + label: '이스라엘 — 전국 대피 명령', + description: 'IDF 후방사령부 전국 대피소 대피 명령. 애로우, 다윗의 투석기, 아이언돔 전면 가동.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // 3단계: 이란 보복 발사 (T0-6h ~ T0) + // IRGC 주장: 첫 4일간 미사일 500발 + 드론 2,000대 + // ═══════════════════════════════════════════════════════════════════ + + // ─── 드론 공격파 (느린 속도, 먼저 발사) ─── + { + id: 'ir1', timestamp: T0 - 6 * HOUR, + lat: 29.9792, lng: 52.8906, type: 'missile_launch', source: 'IR', + label: '샤헤드-136 드론 1차파 — 파르스 주', + description: 'IRGC, 파르스 주에서 샤헤드-136 자폭드론 500대 이상 발사. 이스라엘 및 걸프 기지 향.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/Shahed_136_Geran-2_drone.jpg/800px-Shahed_136_Geran-2_drone.jpg', + imageCaption: '샤헤드-136 자폭드론 (Wikimedia Commons)', + }, + { + id: 'ir2', timestamp: T0 - 5.5 * HOUR, + lat: 34.3142, lng: 47.0650, type: 'missile_launch', source: 'IR', + label: '드론 2차파 — 케르만샤', + description: '케르만샤에서 추가 드론 편대 발사. 이라크/요르단 회랑 경유 이스라엘행.', + intensity: 85, + }, + + // ─── 순항미사일 ─── + { + id: 'ir3', timestamp: T0 - 3 * HOUR, + lat: 33.7219, lng: 51.4215, type: 'missile_launch', source: 'IR', + label: '순항미사일 — 중부 이란', + description: 'IRGC, 중부 이란에서 수마르·호베이제 순항미사일 이스라엘 향 발사.', + intensity: 90, + }, + { + id: 'ir4', timestamp: T0 - 2.5 * HOUR, + lat: 32.3256, lng: 48.6692, type: 'missile_launch', source: 'IR', + label: '순항미사일 — 후제스탄', + description: '후제스탄에서 추가 순항미사일 발사. 바레인 미 제5함대 겨냥.', + intensity: 85, + }, + + // ─── 탄도미사일 ─── + { + id: 'ir5', timestamp: T0 - 90 * MIN, + lat: 27.1832, lng: 56.2764, type: 'missile_launch', source: 'IR', + label: '탄도미사일 — 반다르아바스', + description: '반다르아바스 일대에서 대규모 탄도미사일 발사. 에마드, 가드르, 세질 미사일 이스라엘행.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Emad_missile.jpg/800px-Emad_missile.jpg', + imageCaption: '이란 에마드 탄도미사일 (Wikimedia Commons)', + }, + { + id: 'ir6', timestamp: T0 - 80 * MIN, + lat: 34.6416, lng: 50.8746, type: 'missile_launch', source: 'IR', + label: '탄도미사일 — 쿰', + description: 'IRGC, 쿰 인근 지하 사일로에서 파타 극초음속 미사일 발사.', + intensity: 95, + }, + { + id: 'ir7', timestamp: T0 - 70 * MIN, + lat: 35.7100, lng: 52.0700, type: 'missile_launch', source: 'IR', + label: '탄도미사일 — 다마반드', + description: '다마반드 잔존 발사대에서 헤이바르 셰칸 중거리 탄도미사일 발사.', + intensity: 90, + }, + + // ─── 대리세력 발사 ─── + { + id: 'ir8', timestamp: T0 - 80 * MIN, + lat: 33.3, lng: 44.4, type: 'missile_launch', source: 'proxy', + label: '이라크 이슬람저항세력 발사', + description: '친이란 PMF/카타이브 헤즈볼라, 바그다드 일대에서 미군기지 향 탄도미사일 발사.', + intensity: 80, + }, + { + id: 'ir9', timestamp: T0 - 70 * MIN, + lat: 15.3694, lng: 44.1910, type: 'missile_launch', source: 'proxy', + label: '후티 반군 발사 — 사나', + description: '안사르 알라(후티), 사나에서 탄도미사일·사마드-3 드론 발사. 이스라엘 및 홍해 선박 겨냥.', + intensity: 85, + }, + { + id: 'ir10', timestamp: T0 - 60 * MIN, + lat: 33.5138, lng: 36.2765, type: 'missile_launch', source: 'proxy', + label: '헤즈볼라 — 다마스쿠스 회랑', + description: '시리아 주둔 헤즈볼라 부대, 이스라엘 북부 향 추가 로켓 발사.', + intensity: 75, + }, + + // ─── IRGC 걸프국가 타격 (전례 없는 공격) ─── + { + id: 'ir11', timestamp: T0 - 50 * MIN, + lat: 30.4000, lng: 49.0000, type: 'missile_launch', source: 'IR', + label: '걸프 미군기지 향 미사일 발사', + description: 'IRGC, 중동 전역 미군기지 27곳 및 전 GCC 국가 동시 미사일 발사.', + intensity: 95, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 4단계: 요격 작전 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'int1', timestamp: T0 - 40 * MIN, + lat: 32.0, lng: 36.5, type: 'intercept', + label: '요르단, 이란 드론 요격', + description: '요르단 공군 F-16, 요르단 영공 내 이란 드론 요격.', + }, + { + id: 'int2', timestamp: T0 - 35 * MIN, + lat: 29.3467, lng: 47.5208, type: 'intercept', + label: '패트리어트 — 알리알살렘, 쿠웨이트', + description: '미군 패트리어트 PAC-3, 쿠웨이트 상공에서 탄도미사일 요격.', + }, + { + id: 'int3', timestamp: T0 - 30 * MIN, + lat: 33.8, lng: 32.5, type: 'intercept', + label: 'USS 카니 (DDG-64) — 동지중해', + description: '미 해군 이지스 구축함, 동지중해 상공에서 다수 탄도미사일 요격.', + }, + { + id: 'int4', timestamp: T0 - 28 * MIN, + lat: 26.0, lng: 56.5, type: 'intercept', + label: 'HMS 다이아몬드 — 페르시아만', + description: '영국 구축함 HMS 다이아몬드, 씨바이퍼 미사일로 페르시아만 상공 드론 격추.', + }, + { + id: 'int5', timestamp: T0 - 25 * MIN, + lat: 24.2478, lng: 54.5475, type: 'intercept', + label: 'THAAD — 알다프라, UAE', + description: '미군 THAAD, 알다프라 공군기지에서 UAE 겨냥 중거리 탄도미사일 요격.', + }, + { + id: 'int6', timestamp: T0 - 22 * MIN, + lat: 25.1175, lng: 51.3150, type: 'intercept', + label: '패트리어트 — 알우데이드, 카타르', + description: '패트리어트 포대, 알우데이드 공군기지 방어. 다수 요격 확인.', + }, + { + id: 'int7', timestamp: T0 - 20 * MIN, + lat: 31.0461, lng: 34.8516, type: 'intercept', + label: '아이언돔 — 이스라엘 남부', + description: '아이언돔, 이스라엘 남부 전역에서 수백 대의 드론·로켓 요격.', + intensity: 90, + }, + { + id: 'int8', timestamp: T0 - 15 * MIN, + lat: 32.0853, lng: 34.7818, type: 'intercept', + label: '다윗의 투석기 — 텔아비브', + description: '다윗의 투석기, 텔아비브 수도권 접근 순항미사일 요격.', + intensity: 85, + }, + { + id: 'int9', timestamp: T0 - 10 * MIN, + lat: 29.5581, lng: 34.9482, type: 'intercept', + label: '애로우-3 — 대기권 밖 요격', + description: '애로우-3, 이스라엘 남부 상공 대기권 밖에서 탄도미사일 요격.', + intensity: 95, + }, + { + id: 'int10', timestamp: T0 - 8 * MIN, + lat: 26.2235, lng: 50.6009, type: 'intercept', + label: '패트리어트 — 주파이르, 바레인', + description: '미군 패트리어트, 바레인 제5함대 사령부 방어. 다수 미사일 교전.', + intensity: 85, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 5단계: 피격 지점 — T0 이후 + // 이란의 이스라엘, 걸프국가, 미군기지 타격 + // ═══════════════════════════════════════════════════════════════════ + + // ─── 이스라엘 피격 ─── + { + id: 'imp1', timestamp: T0, + lat: 31.2083, lng: 34.8622, type: 'impact', source: 'IR', + label: '네바팀 공군기지 — 피격', + description: '이란 파타 극초음속 미사일, 네바팀 공군기지 타격. 활주로 파손, F-35I 2대 손상. 주요 표적.', + intensity: 100, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/PikiWiki_Israel_32121_Nevatim_Airbase.JPG/800px-PikiWiki_Israel_32121_Nevatim_Airbase.JPG', + imageCaption: '네바팀 공군기지 (Wikimedia Commons)', + }, + { + id: 'imp2', timestamp: T0 + 1 * MIN, + lat: 30.7761, lng: 34.6667, type: 'impact', source: 'IR', + label: '라몬 공군기지 — 피격', + description: '탄도미사일, 네게브 사막 라몬 공군기지 타격. 다수 강화 엄체호 피격.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Ramon_Airbase_Negev_Israel.jpg/800px-Ramon_Airbase_Negev_Israel.jpg', + imageCaption: '라몬 공군기지 위성사진 (Wikimedia Commons)', + }, + { + id: 'imp3', timestamp: T0 + 2 * MIN, + lat: 32.0853, lng: 34.7818, type: 'impact', source: 'IR', + label: '텔아비브 — 주거지역 피격', + description: '순항미사일, 바트얌/텔아비브 지역 방어망 관통. 9명 사망, 약 200명 부상. 건물 피해.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Tel_Aviv-Yafo_Skyline_%28cropped%29.jpg/800px-Tel_Aviv-Yafo_Skyline_%28cropped%29.jpg', + imageCaption: '텔아비브 스카이라인 (Wikimedia Commons)', + }, + { + id: 'imp4', timestamp: T0 + 3 * MIN, + lat: 31.8940, lng: 34.8110, type: 'impact', source: 'IR', + label: '레호보트 — 쇼핑몰 피격', + description: '이란 탄도미사일, 레호보트 쇼핑센터 타격. 상당한 민간인 사상자.', + intensity: 85, + }, + { + id: 'imp5', timestamp: T0 + 4 * MIN, + lat: 33.4167, lng: 35.8571, type: 'impact', source: 'IR', + label: '헤르몬산 정보기지 — 피격', + description: '미사일, 골란고원 헤르몬산 IDF 정보초소 타격.', + intensity: 75, + }, + { + id: 'imp6', timestamp: T0 + 5 * MIN, + lat: 31.2589, lng: 35.2126, type: 'impact', source: 'IR', + label: '아라드 — 파편 낙하', + description: '아라드 지역에 미사일 파편 낙하. 다수 민간인 부상 보고.', + intensity: 60, + }, + { + id: 'imp7', timestamp: T0 + 6 * MIN, + lat: 31.0667, lng: 35.0333, type: 'impact', source: 'IR', + label: '디모나 — 근접 피격 실패', + description: '디모나 원자력시설 겨냥 미사일 요격; 시설 주변 넓은 파편 분포.', + intensity: 70, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Dimona_Nuclear_Power_Plant.jpg/800px-Dimona_Nuclear_Power_Plant.jpg', + imageCaption: '디모나 원자력시설 (Wikimedia Commons)', + }, + { + id: 'imp8', timestamp: T0 + 7 * MIN, + lat: 33.1, lng: 35.85, type: 'impact', source: 'proxy', + label: '골란고원 — 다수 피격', + description: '헤즈볼라 로켓·이란 미사일, 이스라엘 점령 골란고원 전역 타격.', + intensity: 65, + }, + + // ─── 바레인 피격 ─── + { + id: 'imp9', timestamp: T0 + 3 * MIN, + lat: 26.2235, lng: 50.6009, type: 'impact', source: 'IR', + label: '바레인 — 제5함대 사령부 피격', + description: '이란 미사일, 바레인 주파이르 미 해군 제5함대 사령부 인근 타격. 미군 다수 사상.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/US_Navy_040723-N-0000X-001_Aerial_view_of_the_U.S._Naval_Support_Activity_%28NSA%29_Bahrain.jpg/800px-US_Navy_040723-N-0000X-001_Aerial_view_of_the_U.S._Naval_Support_Activity_%28NSA%29_Bahrain.jpg', + imageCaption: '바레인 미 해군 지원시설 항공사진 (US Navy)', + }, + { + id: 'imp10', timestamp: T0 + 5 * MIN, + lat: 26.2700, lng: 50.6336, type: 'impact', source: 'IR', + label: '바레인 공항 — 드론 피격', + description: '이란 드론, 바레인 국제공항 터미널 타격. 물적 피해, 운항 중단.', + intensity: 75, + }, + { + id: 'imp11', timestamp: T0 + 8 * MIN, + lat: 26.2285, lng: 50.5500, type: 'impact', source: 'IR', + label: '마나마 — 주거 건물 피격', + description: '이란 드론, 바레인 마나마 주거용 타워 타격. 민간인 사상자 보고.', + intensity: 70, + }, + + // ─── 카타르 피격 ─── + { + id: 'imp12', timestamp: T0 + 4 * MIN, + lat: 25.1175, lng: 51.3150, type: 'impact', source: 'IR', + label: '알우데이드 공군기지 — 피격', + description: '탄도미사일, 카타르 알우데이드 공군기지 패트리어트 방어 관통. 미군 3명 전사. CENTCOM CAOC 손상.', + intensity: 95, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Al_Udeid_Air_Base.jpg/800px-Al_Udeid_Air_Base.jpg', + imageCaption: '알우데이드 공군기지 항공사진 (US Air Force)', + }, + + // ─── UAE 피격 ─── + { + id: 'imp13', timestamp: T0 + 6 * MIN, + lat: 24.2478, lng: 54.5475, type: 'impact', source: 'IR', + label: '알다프라 공군기지 — 근접 피격', + description: '요격된 미사일 파편, UAE 알다프라 공군기지 인근 낙하. 경미한 피해.', + intensity: 55, + }, + + // ─── 이라크 미군기지 피격 ─── + { + id: 'imp14', timestamp: T0 + 5 * MIN, + lat: 33.7856, lng: 42.4441, type: 'impact', source: 'proxy', + label: '알아사드 공군기지, 이라크 — 피격', + description: 'PMF/카타이브 헤즈볼라 탄도미사일, 알아사드 공군기지 타격. 미군 사상자 발생.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/Ain_al-Asad_base_2020.jpg/800px-Ain_al-Asad_base_2020.jpg', + imageCaption: '알아사드 공군기지 위성사진 (Wikimedia Commons)', + }, + { + id: 'imp15', timestamp: T0 + 7 * MIN, + lat: 36.1911, lng: 44.0094, type: 'impact', source: 'IR', + label: '에르빌, 이라크 — 피격', + description: '미사일, 쿠르디스탄 에르빌 미 영사관 및 군사시설 인근 타격.', + intensity: 75, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Erbil_Citadel.jpg/800px-Erbil_Citadel.jpg', + imageCaption: '에르빌 시타델 (Wikimedia Commons)', + }, + + // ─── 튀르키예 피격 (인저를릭·쿠레지크) ─── + { + id: 'imp-tr1', timestamp: T0 + 4 * MIN, + lat: 37.0017, lng: 35.4253, type: 'impact', source: 'IR', + label: '인저를릭 공군기지, 튀르키예 — 탄도미사일 피격', + description: 'IRGC 세질-3 탄도미사일 3발, 인저를릭(Incirlik) 공군기지 타격. 활주로 2개소 손상, 미군 F-16 격납고 파괴. NATO 즉각 비상.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/06/Incirlik_flightline.jpg/800px-Incirlik_flightline.jpg', + imageCaption: '인저를릭 공군기지 (US Air Force)', + }, + { + id: 'imp-tr2', timestamp: T0 + 6 * MIN, + lat: 37.8200, lng: 37.7500, type: 'impact', source: 'IR', + label: '쿠레지크 레이더기지, 튀르키예 — 미사일 피격', + description: 'IRGC 에마드 탄도미사일, 쿠레지크(Kürecik) AN/TPY-2 X밴드 레이더 기지 타격. 레이더 돔 파손, 미군 요원 부상.', + intensity: 85, + }, + { + id: 'int-tr1', timestamp: T0 + 4 * MIN, + lat: 37.1000, lng: 35.5000, type: 'intercept', + label: '인저를릭 상공 — 패트리어트 요격', + description: '인저를릭 주둔 패트리어트 PAC-3, 이란 탄도미사일 2발 요격 성공. 1발 요격 실패로 활주로 피격.', + intensity: 80, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 6단계: 폭발 / 2차 피해 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'ex1', timestamp: T0 + 10 * MIN, + lat: 30.6500, lng: 34.7700, type: 'explosion', + label: '네게브 — 요격 파편 낙하', + description: '수백 발의 요격 미사일 파편이 네게브 사막 전역에 낙하.', + intensity: 50, + }, + { + id: 'ex2', timestamp: T0 + 12 * MIN, + lat: 32.0, lng: 34.8, type: 'explosion', + label: '텔아비브 — 2차 화재', + description: '텔아비브 광역에서 미사일 타격·파편으로 인한 다수 화재 발생. 소방당국 역량 초과.', + intensity: 60, + }, + { + id: 'ex3', timestamp: T0 + 15 * MIN, + lat: 31.9522, lng: 34.8070, type: 'explosion', + label: '바트얌 — 건물 붕괴', + description: '바트얌 61개 건물 피해. 시장, 다수 구조물 붕괴 보고.', + intensity: 70, + }, + { + id: 'ex4', timestamp: T0 + 20 * MIN, + lat: 31.8630, lng: 34.8210, type: 'explosion', + label: '키르얏 에크론 — 쇼핑몰 화재', + description: '키르얏 에크론 쇼핑몰, 미사일 타격 후 대형 화재.', + intensity: 65, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 7단계: 미국-이스라엘 이란 추가 공습 (T0 이후) + // ═══════════════════════════════════════════════════════════════════ + { + id: 'us17', timestamp: T0 + 30 * MIN, + lat: 35.7450, lng: 51.4650, type: 'airstrike', source: 'US', + label: '테헤란 라비잔 — IRGC 지휘소 추가 타격', + description: '보복 B-2 공습, 테헤란 북동부 라비잔 IRGC 지휘통제 거점 파괴.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/B-2_Spirit_%28cropped%29.jpg/800px-B-2_Spirit_%28cropped%29.jpg', + imageCaption: 'B-2 스피릿 스텔스 폭격기 (US Air Force)', + }, + { + id: 'us18', timestamp: T0 + 1 * HOUR, + lat: 32.7500, lng: 51.8617, type: 'airstrike', source: 'IL', + label: '이스파한 — 제8전술공군기지', + description: '이스라엘 F-35I, 이스파한 공군기지(제8TAB) 타격. IRIAF 항공기 활주로 위 파괴.', + intensity: 85, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/Isfahan_Naqsh-e_Jahan_square.jpg/800px-Isfahan_Naqsh-e_Jahan_square.jpg', + imageCaption: '이스파한 시 (Wikimedia Commons)', + }, + { + id: 'us19', timestamp: T0 + 1.5 * HOUR, + lat: 37.3500, lng: 49.6000, type: 'airstrike', source: 'US', + label: '라슈트 — IRGC 해군기지', + description: '카스피해 연안 라슈트 IRGC 해군기지 타격.', + intensity: 75, + }, + { + id: 'us20', timestamp: T0 + 2 * HOUR, + lat: 27.1832, lng: 56.2764, type: 'airstrike', source: 'US', + label: '반다르아바스 — 해군/미사일 시설', + description: 'B-2 및 F-35, 반다르아바스 IRGC 해군 자산 및 탄도미사일 발사대 타격.', + intensity: 90, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Bandar_Abbas_Iran_port.jpg/800px-Bandar_Abbas_Iran_port.jpg', + imageCaption: '반다르아바스 항구 (Wikimedia Commons)', + }, + + // ═══════════════════════════════════════════════════════════════════ + // 8단계: 미국의 후티 반군 타격 (예멘) + // ═══════════════════════════════════════════════════════════════════ + { + id: 'ym1', timestamp: T0 + 2 * HOUR, + lat: 15.3694, lng: 44.1910, type: 'airstrike', source: 'US', + label: '사나 — 후티 군사본부', + description: '미국 토마호크, 사나 후티 군사본부 및 미사일 저장시설 타격.', + intensity: 80, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Sanaa_HDR_%286834654486%29.jpg/800px-Sanaa_HDR_%286834654486%29.jpg', + imageCaption: '사나 구시가지 전경 (Wikimedia Commons)', + }, + { + id: 'ym2', timestamp: T0 + 2.5 * HOUR, + lat: 14.7980, lng: 42.9540, type: 'airstrike', source: 'US', + label: '호데이다 — 항구/군사시설', + description: '미-영 합동, 호데이다 항구 후티 군사시설 타격.', + intensity: 80, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Al_Hudaydah_Port.jpg/800px-Al_Hudaydah_Port.jpg', + imageCaption: '호데이다 항구 (Wikimedia Commons)', + }, + { + id: 'ym3', timestamp: T0 + 3 * HOUR, + lat: 16.9400, lng: 43.7700, type: 'airstrike', source: 'US', + label: '사아다 — 후티 발사기지', + description: '사아다 주 후티 드론·미사일 발사기지 타격.', + intensity: 75, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 9단계: 미국의 이라크 민병대 타격 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'iq1', timestamp: T0 + 1.5 * HOUR, + lat: 34.4500, lng: 40.9200, type: 'airstrike', source: 'US', + label: '데이르에조르 — 친이란 민병대, 시리아', + description: '미군, 시리아 동부 데이르에조르 이란 지원 민병대 거점 공습.', + intensity: 75, + }, + { + id: 'iq2', timestamp: T0 + 2 * HOUR, + lat: 34.4600, lng: 40.5400, type: 'airstrike', source: 'US', + label: '알부카말 — 국경지대 타격', + description: '시리아-이라크 국경 알부카말, 카타이브 헤즈볼라 무기고 정밀타격.', + intensity: 80, + }, + + // ═══════════════════════════════════════════════════════════════════ + // 10단계: 피해 평가 및 외교적 대응 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'p1', timestamp: T0 + 30 * MIN, + lat: 31.7683, lng: 35.2137, type: 'alert', + label: 'IDF 피해 평가 개시', + description: 'IDF, 이스라엘 11명 사망 확인. 네바팀 공군기지 피해 있으나 부분 운용 가능.', + }, + { + id: 'p2', timestamp: T0 + 1 * HOUR, + lat: 26.2235, lng: 50.6009, type: 'alert', + label: '바레인 사상자 보고', + description: '바레인 정부, 이란 드론/미사일 공격으로 마나마 민간인 사상자 확인.', + }, + { + id: 'p3', timestamp: T0 + 2 * HOUR, + lat: 38.8977, lng: -77.0365, type: 'alert', + label: '백악관 성명', + description: '트럼프: "이란은 매우 큰 대가를 치를 것." 알우데이드 미군 3명 전사, 추가 가능성 확인.', + }, + { + id: 'p4', timestamp: T0 + 3 * HOUR, + lat: 48.8566, lng: 2.3522, type: 'alert', + label: 'UN 안보리 긴급회의', + description: 'UN 안전보장이사회 이란 사태 긴급회의 개최. 러시아·중국 결의안 거부.', + }, + { + id: 'p5', timestamp: T0 + 4 * HOUR, + lat: 50.8503, lng: 4.3517, type: 'alert', + label: 'NATO 제5조 협의', + description: 'NATO 회원국, 이란의 인저를릭(튀르키예)·바레인 공격 관련 제5조 적용 논의. 튀르키예 NATO 지원 요청.', + }, + { + id: 'p5-tr', timestamp: T0 + 4.5 * HOUR, + lat: 39.9334, lng: 32.8597, type: 'alert', + label: '튀르키예 앙카라 — 이란 대사 초치', + description: '튀르키예 외무부, 이란 대사 초치. 인저를릭·쿠레지크 공격 규탄. 에르도안 "주권 침해 용납 불가" 성명.', + }, + { + id: 'p6', timestamp: T0 + 5 * HOUR, + lat: 35.6550, lng: 51.4250, type: 'alert', + label: 'IRGC — 미군기지 27곳 타격 주장', + description: 'IRGC 대변인, 미군기지 27곳 공격 주장. 첫 4일간 미사일 500발, 드론 2,000대 발사 보고.', + }, + { + id: 'p7', timestamp: T0 + 6 * HOUR, + lat: 24.7136, lng: 46.6753, type: 'alert', + label: '사우디아라비아 — 영공 폐쇄', + description: '사우디, 영공 폐쇄. 동부 주 상공에서 이란 미사일 요격 보고.', + }, + { + id: 'p8', timestamp: T0 + 8 * HOUR, + lat: 25.2048, lng: 55.2708, type: 'alert', + label: '두바이 — 팜 주메이라 대피', + description: 'UAE, 인근 알다프라 공군기지 향 이란 미사일 위협으로 두바이 해안가 일부 대피.', + }, + { + id: 'p9', timestamp: T0 + 10 * HOUR, + lat: 51.5074, lng: -0.1278, type: 'alert', + label: '영국 의회 긴급토론', + description: '영국 하원 이란 사태 긴급토론. 총리, HMS 다이아몬드 적대 표적 교전 확인.', + }, + { + id: 'p10', timestamp: T0 + 11 * HOUR, + lat: 37.5665, lng: 126.9780, type: 'alert', + label: '한국 — 청해부대 최고경보', + description: '해군 청해부대(아덴만) 최고 경계태세 격상. 걸프 지역 한국 국민 대피 명령.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 2 — 3월 2일: 이란 2차 보복파 + 미국 대규모 추가 공습 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd2-ir1', timestamp: T0 + 1 * DAY, + lat: 27.1832, lng: 56.2764, type: 'missile_launch', source: 'IR', + label: '이란 2차 탄도미사일 발사 — 반다르아바스', + description: 'IRGC, 잔존 이동식 발사대에서 세질-2 탄도미사일 추가 발사. UAE/바레인 겨냥.', + intensity: 90, + }, + { + id: 'd2-ir2', timestamp: T0 + 1 * DAY + 2 * HOUR, + lat: 26.5667, lng: 56.2500, type: 'alert', source: 'IR', + label: '호르무즈 해협 — 기뢰 추가 배치', + description: 'IRGC 해군, 호르무즈 해협 해저 기뢰 추가 배치. 국제유가 배럴당 $185 돌파.', + intensity: 95, + }, + { + id: 'd2-us1', timestamp: T0 + 1 * DAY + 4 * HOUR, + lat: 35.6750, lng: 51.3500, type: 'airstrike', source: 'US', + label: '테헤란 서부 — 통신인프라 타격', + description: 'B-2 폭격기, 테헤란 서부 IRGC 통신센터·방송국 타격. 이란 내부 통신 대부분 두절.', + intensity: 85, + }, + { + id: 'd2-us2', timestamp: T0 + 1 * DAY + 6 * HOUR, + lat: 30.2744, lng: 56.9511, type: 'airstrike', source: 'US', + label: '케르만 — 미사일 생산시설 2차 타격', + description: 'F-35 편대, 케르만 IRGC 미사일 고체연료 제조공장 타격.', + intensity: 80, + }, + { + id: 'd2-imp1', timestamp: T0 + 1 * DAY + 1 * HOUR, + lat: 24.2478, lng: 54.5475, type: 'impact', source: 'IR', + label: '알다프라 AFB — 활주로 피격', + description: '이란 탄도미사일, UAE 알다프라 공군기지 활주로 타격. F-35A 1대 손상, 미군 2명 부상.', + intensity: 85, + }, + { + id: 'd2-imp2', timestamp: T0 + 1 * DAY + 1.5 * HOUR, + lat: 26.1572, lng: 50.5911, type: 'impact', source: 'IR', + label: '이사 AFB, 바레인 — 2차 피격', + description: '순항미사일, 바레인 이사 공군기지 격납고 타격. 미군 헬기 2대 파괴.', + intensity: 80, + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 3 — 3월 3일: 호르무즈 해전 + 유조선 피격 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd3-sea1', timestamp: T0 + 2 * DAY, + lat: 26.5500, lng: 56.3500, type: 'explosion', + label: '호르무즈 해협 — 유조선 기뢰 접촉', + description: '그리스 국적 VLCC "아테나 글로리", 호르무즈 해협에서 기뢰 접촉. 원유 유출 시작.', + intensity: 85, + }, + { + id: 'd3-sea2', timestamp: T0 + 2 * DAY + 3 * HOUR, + lat: 26.4000, lng: 56.4000, type: 'explosion', + label: 'IRGC 고속정 — 미 구축함 교전', + description: 'IRGC 고속정 편대, USN 알레이버크급 구축함 접근. 함포 교전 — IRGC 고속정 4척 격침.', + intensity: 90, + }, + { + id: 'd3-us1', timestamp: T0 + 2 * DAY + 5 * HOUR, + lat: 26.9400, lng: 56.8500, type: 'airstrike', source: 'US', + label: '자스크 — IRGC 해군기지 타격', + description: 'F/A-18, 자스크 IRGC 해군기지 초계정 및 대함미사일 저장고 파괴.', + intensity: 85, + }, + { + id: 'd3-kr1', timestamp: T0 + 2 * DAY + 8 * HOUR, + lat: 25.5000, lng: 57.0000, type: 'alert', + label: '한국 선박 — 오만만 대피 중', + description: '호르무즈 해협 인근 한국 국적 선박 12척, 청해부대 호위 하 오만만으로 긴급 대피.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 4 — 3월 4일: 이란 3차 공격파 + 헤즈볼라 전면전 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd4-ir1', timestamp: T0 + 3 * DAY, + lat: 33.8547, lng: 35.8623, type: 'missile_launch', source: 'proxy', + label: '헤즈볼라 — 전면 로켓 공격', + description: '헤즈볼라, 레바논 남부에서 이스라엘 북부 향 로켓 수백 발 발사. 하이파·키르얏슈모나 타격.', + intensity: 90, + }, + { + id: 'd4-imp1', timestamp: T0 + 3 * DAY + 20 * MIN, + lat: 32.7940, lng: 34.9896, type: 'impact', source: 'proxy', + label: '하이파 — 로켓 다수 피격', + description: '헤즈볼라 로켓, 하이파 항구 및 정유시설 인근 타격. 대형 화재 발생.', + intensity: 85, + }, + { + id: 'd4-il1', timestamp: T0 + 3 * DAY + 2 * HOUR, + lat: 33.8547, lng: 35.5018, type: 'airstrike', source: 'IL', + label: '레바논 남부 — IAF 대규모 공습', + description: 'IAF, 레바논 남부 헤즈볼라 거점 200개소 이상 동시 공습.', + intensity: 90, + }, + { + id: 'd4-ir2', timestamp: T0 + 3 * DAY + 6 * HOUR, + lat: 35.5161, lng: 51.7621, type: 'missile_launch', source: 'IR', + label: '이란 3차 미사일 발사 — 잔존 발사대', + description: 'IRGC, 이동식 발사대에서 파타 극초음속 미사일 추가 발사. 이스라엘 디모나 겨냥.', + intensity: 95, + }, + { + id: 'd4-imp2', timestamp: T0 + 3 * DAY + 6.5 * HOUR, + lat: 31.0667, lng: 35.0333, type: 'impact', source: 'IR', + label: '디모나 — 핵시설 인근 피격', + description: '극초음속 미사일 1발, 디모나 원자력시설 2km 이내 착탄. 이스라엘 방사능 유출 부인.', + intensity: 100, + }, + + // ─── DAY 4: 튀르키예 2차 피격 + 보복 ─── + { + id: 'd4-tr1', timestamp: T0 + 3 * DAY + 4 * HOUR, + lat: 37.0017, lng: 35.4253, type: 'impact', source: 'IR', + label: '인저를릭 공군기지 — 2차 미사일 공격', + description: 'IRGC 세질-2 탄도미사일 4발, 인저를릭 기지 2차 공격. 패트리어트 3발 요격, 1발 연료저장시설 타격. 대형 화재.', + intensity: 85, + }, + { + id: 'd4-tr-int1', timestamp: T0 + 3 * DAY + 4 * HOUR, + lat: 37.0500, lng: 35.4500, type: 'intercept', + label: '인저를릭 상공 — PAC-3 요격 (3/4발)', + description: '패트리어트 PAC-3, 이란 탄도미사일 4발 중 3발 요격. 1발 관통하여 기지 내 연료저장시설 피격.', + intensity: 80, + }, + { + id: 'd4-tr2', timestamp: T0 + 3 * DAY + 8 * HOUR, + lat: 37.7500, lng: 29.0900, type: 'impact', source: 'IR', + label: '데니즐리, 튀르키예 — 이란 드론 공격', + description: 'IRGC 샤헤드-136 자폭드론 6대, 튀르키예 서부 데니즐리 NATO 통신중계기지 공격. 3대 요격, 3대 명중. 시설 부분 파손.', + intensity: 70, + }, + { + id: 'd4-us-tr1', timestamp: T0 + 3 * DAY + 10 * HOUR, + lat: 38.7000, lng: 43.4000, type: 'airstrike', source: 'US', + label: '이란-튀르키예 국경 — IRGC 발사대 타격', + description: 'F-15E(인저를릭 발진), 이란-튀르키예 국경 인근 IRGC 이동식 미사일 발사대 3기 파괴.', + intensity: 80, + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 5 — 3월 5일: 사이버전 + 에너지 위기 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd5-cy1', timestamp: T0 + 4 * DAY, + lat: 35.6300, lng: 51.3600, type: 'alert', + label: '이란 — 대규모 사이버 공격 감행', + description: '이란 APT 그룹, 이스라엘 전력망·수자원 시스템 사이버 공격. 텔아비브 일대 정전.', + }, + { + id: 'd5-oil1', timestamp: T0 + 4 * DAY + 4 * HOUR, + lat: 26.3000, lng: 50.2000, type: 'explosion', + label: '사우디 라스타누라 — 드론 공격', + description: '후티/이란 연계 드론, 사우디 라스타누라 석유터미널 공격. WTI 유가 $200 돌파.', + intensity: 90, + }, + { + id: 'd5-p1', timestamp: T0 + 4 * DAY + 8 * HOUR, + lat: 39.9042, lng: 116.4074, type: 'alert', + label: '중국 — 호르무즈 해협 개방 촉구', + description: '시진핑, 유엔 총회 긴급연설. 호르무즈 해협 봉쇄 해제 및 즉각 휴전 촉구.', + }, + { + id: 'd5-kr2', timestamp: T0 + 4 * DAY + 10 * HOUR, + lat: 37.5665, lng: 126.9780, type: 'alert', + label: '한국 — 비상경제대책 발동', + description: '정부, 유류비 급등 대응 비상경제대책 발동. 전략비축유 방출 개시. 걸프 한국 교민 1,200명 대피 완료.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 6 — 3월 6일: 미 항모전단 전투 + 이란 해군 교전 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd6-nav1', timestamp: T0 + 5 * DAY, + lat: 25.0000, lng: 58.0000, type: 'explosion', + label: 'USS 아이젠하워 — 대함미사일 교전', + description: 'IRGC, 아라비아해 USS 아이젠하워 항모전단 향 누르 대함미사일 발사. 이지스 시스템 전탄 요격.', + intensity: 95, + }, + { + id: 'd6-us1', timestamp: T0 + 5 * DAY + 2 * HOUR, + lat: 27.1500, lng: 56.2000, type: 'airstrike', source: 'US', + label: '반다르아바스 — 이란 해군 잔존 전력 파괴', + description: 'B-1B 편대, 반다르아바스 IRGC 해군 잔존 전력(잠수정 4척, 고속정 12척) 완전 파괴.', + intensity: 90, + }, + { + id: 'd6-ir1', timestamp: T0 + 5 * DAY + 6 * HOUR, + lat: 29.9792, lng: 52.8906, type: 'missile_launch', source: 'IR', + label: '샤헤드 드론 4차파 — 파르스', + description: 'IRGC, 잔존 드론 300대 추가 발사. 이스라엘·사우디·튀르키예 걸프 인프라 겨냥.', + intensity: 80, + }, + { + id: 'd6-tr1', timestamp: T0 + 5 * DAY + 8 * HOUR, + lat: 39.9334, lng: 32.8597, type: 'alert', + label: '튀르키예 — NATO 제5조 공식 발동 요청', + description: '에르도안, 인저를릭·쿠레지크 반복 공격에 NATO 제5조 공식 발동 요청. NATO 긴급 회의 개최.', + }, + { + id: 'd6-tr2', timestamp: T0 + 5 * DAY + 10 * HOUR, + lat: 37.0017, lng: 35.4253, type: 'intercept', + label: '인저를릭 — 드론 12대 요격', + description: '인저를릭 방공체계, 이란 샤헤드 드론 4차파 중 12대 요격. 기지 방공 완벽 방어.', + intensity: 75, + }, + { + id: 'd6-us-tr1', timestamp: T0 + 5 * DAY + 12 * HOUR, + lat: 38.5000, lng: 44.5000, type: 'airstrike', source: 'US', + label: '이란 서북부 — 튀르키예 향 미사일 발사대 파괴', + description: 'F-16(인저를릭), 이란 서북부 타브리즈 인근 IRGC 이동식 발사대 5기 정밀타격. 튀르키예 향 미사일 위협 제거.', + intensity: 85, + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 7 — 3월 7일: 이란 내부 동요 + 미국 최후통첩 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd7-p1', timestamp: T0 + 6 * DAY, + lat: 35.6980, lng: 51.3380, type: 'alert', + label: '테헤란 — 대규모 반전 시위', + description: '테헤란·이스파한·시라즈 시민 수십만 명 반전 시위. IRGC 시위대 향 발포 보도.', + }, + { + id: 'd7-us1', timestamp: T0 + 6 * DAY + 4 * HOUR, + lat: 38.8977, lng: -77.0365, type: 'alert', + label: '트럼프 — 이란 최후통첩', + description: '트럼프: "48시간 내 모든 미사일 발사 중단하지 않으면 정권 교체 작전 개시."', + }, + { + id: 'd7-ir1', timestamp: T0 + 6 * DAY + 8 * HOUR, + lat: 35.6700, lng: 51.4500, type: 'alert', source: 'IR', + label: 'IRGC — 최후통첩 거부', + description: 'IRGC 사령관: "미국 침략에 끝까지 저항. 전면전 불사." 추가 미사일 발사 위협.', + }, + { + id: 'd7-us2', timestamp: T0 + 6 * DAY + 12 * HOUR, + lat: 36.0716, lng: 55.0164, type: 'airstrike', source: 'US', + label: '샤흐루드 — ICBM 시설 완전 파괴', + description: '대규모 공습, 샤흐루드 우주센터 ICBM 개발시설 완전 파괴. 이란 장거리 미사일 능력 무력화.', + intensity: 95, + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 8 — 3월 8일: 교착 + 인도적 위기 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd8-p1', timestamp: T0 + 7 * DAY, + lat: 46.2044, lng: 6.1432, type: 'alert', + label: '제네바 — 적십자 긴급 호소', + description: 'ICRC, 이란·이스라엘·바레인 민간인 피해 긴급 호소. 이란 내 의약품·식수 부족 심각.', + }, + { + id: 'd8-ir1', timestamp: T0 + 7 * DAY + 4 * HOUR, + lat: 32.6546, lng: 51.6680, type: 'explosion', + label: '이스파한 — 잔존 핵물질 유출 우려', + description: 'IAEA, 이스파한 핵시설 피격 후 방사성 물질 유출 가능성 경고. 주민 대피 권고.', + intensity: 85, + }, + { + id: 'd8-us1', timestamp: T0 + 7 * DAY + 8 * HOUR, + lat: 35.7200, lng: 51.4400, type: 'airstrike', source: 'US', + label: '테헤란 북부 — 정보부(VAJA) 본부 타격', + description: 'B-2, 테헤란 북부 VAJA(정보보안부) 본부 타격. 이란 정보 수집 능력 마비.', + intensity: 80, + }, + { + id: 'd8-kr3', timestamp: T0 + 7 * DAY + 10 * HOUR, + lat: 25.2528, lng: 55.3644, type: 'alert', + label: '한국 교민 — 두바이 경유 철수', + description: '걸프 잔류 한국 교민 350명, 두바이 경유 군 수송기로 긴급 귀국. 청해부대 한국 선박 호위 지속.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 9 — 3월 9일 (오늘): 휴전 압력 + 이란 미사일 능력 소진 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd9-p1', timestamp: T0 + 8 * DAY, + lat: 55.7558, lng: 37.6173, type: 'alert', + label: '러시아 — 이란에 휴전 권고', + description: '푸틴, 이란 지도부에 비공식 휴전 수용 권고. 러시아, 추가 무기 지원 거부.', + }, + { + id: 'd9-ir1', timestamp: T0 + 8 * DAY + 2 * HOUR, + lat: 35.6580, lng: 51.4100, type: 'alert', source: 'IR', + label: 'IRGC — 미사일 재고 소진 보도', + description: '미 정보기관 분석: 이란 탄도미사일 재고 80% 소진. 잔존 이동식 발사대 10기 이하.', + }, + { + id: 'd9-us1', timestamp: T0 + 8 * DAY + 6 * HOUR, + lat: 33.7219, lng: 51.7276, type: 'airstrike', source: 'US', + label: '나탄즈 — 3차 벙커버스터 타격', + description: 'B-2, 나탄즈 지하 우라늄 농축시설 GBU-57 벙커버스터 3차 타격. 지하 터널 완전 붕괴.', + intensity: 100, + }, + { + id: 'd9-p2', timestamp: T0 + 8 * DAY + 10 * HOUR, + lat: 48.8566, lng: 2.3522, type: 'alert', + label: 'UN — 72시간 휴전 결의안 채택', + description: 'UN 안보리, 72시간 인도적 휴전 결의안 채택 (찬성 13, 기권 2). 미국·이란 모두 입장 미정.', + }, + { + id: 'd9-kr4', timestamp: T0 + 8 * DAY + 12 * HOUR, + lat: 37.5665, lng: 126.9780, type: 'alert', + label: '한국 — NSC 긴급회의', + description: 'NSC, 호르무즈 해협 봉쇄 장기화 대비 에너지 비상계획 수립. 석유 비축량 90일분 확인.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 10 — 3월 10일: 72시간 휴전 개시 직전 최후 공습 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd10-us1', timestamp: T0 + 9 * DAY, + lat: 32.6546, lng: 51.6680, type: 'airstrike', source: 'US', + label: '이스파한 — 핵시설 최종 타격', + description: 'B-2, 이스파한 핵기술센터 잔존 시설 GBU-57 최종 타격. IAEA 긴급 성명 발표.', + intensity: 95, + }, + { + id: 'd10-il1', timestamp: T0 + 9 * DAY + 2 * HOUR, + lat: 33.8547, lng: 35.5018, type: 'airstrike', source: 'IL', + label: '레바논 남부 — 헤즈볼라 지휘부 타격', + description: 'IAF F-35I, 레바논 남부 헤즈볼라 고위 지휘관 거점 정밀폭격. 2명 사살 보도.', + intensity: 85, + }, + { + id: 'd10-ir1', timestamp: T0 + 9 * DAY + 4 * HOUR, + lat: 29.9792, lng: 52.8906, type: 'missile_launch', source: 'IR', + label: '이란 잔존 드론 5차파 — 최후 발사', + description: 'IRGC, 파르스 주 잔존 샤헤드-136 드론 80대 발사. 이란 측 "최후 보복" 선언.', + intensity: 70, + }, + { + id: 'd10-imp1', timestamp: T0 + 9 * DAY + 5 * HOUR, + lat: 31.2083, lng: 34.8622, type: 'impact', source: 'IR', + label: '네바팀 공군기지 — 2차 드론 피격', + description: '샤헤드 드론 12대 아이언돔 관통, 네바팀 기지 활주로 추가 피격. 복구 중 피해.', + intensity: 75, + }, + { + id: 'd10-int1', timestamp: T0 + 9 * DAY + 5 * HOUR, + lat: 29.5581, lng: 34.9482, type: 'intercept', + label: '애로우-3 — 잔여 탄도미사일 요격', + description: '애로우-3, 이란 잔존 발사대에서 발사된 세질-2 탄도미사일 3발 대기권 외 요격.', + intensity: 85, + }, + { + id: 'd10-us2', timestamp: T0 + 9 * DAY + 8 * HOUR, + lat: 35.5161, lng: 51.7621, type: 'airstrike', source: 'US', + label: '파르친 — 잔존 미사일 시설 최종 파괴', + description: 'F-35A 편대, 파르친 군사단지 잔존 고체연료 저장시설 최종 타격. 대형 2차 폭발.', + intensity: 90, + }, + { + id: 'd10-p1', timestamp: T0 + 9 * DAY + 10 * HOUR, + lat: 35.6820, lng: 51.3200, type: 'alert', source: 'IR', + label: '이란 — 72시간 휴전 조건부 수용', + description: '이란 외무부, UN 72시간 휴전 결의안 "조건부 수용" 발표. 조건: 이스라엘 핵시설 공습 중단.', + }, + { + id: 'd10-p2', timestamp: T0 + 9 * DAY + 12 * HOUR, + lat: 38.8977, lng: -77.0365, type: 'alert', + label: '트럼프 — 휴전 조건 거부', + description: '트럼프: "이란의 조건부 휴전은 받아들일 수 없다. 무조건 항복만이 답이다."', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 11 — 3월 11일: 이란 군사력 사실상 궤멸 + 비공식 교전 중단 + // ═══════════════════════════════════════════════════════════════════ + { + id: 'd11-us1', timestamp: T0 + 10 * DAY, + lat: 35.8400, lng: 50.9391, type: 'airstrike', source: 'US', + label: '카라즈 — IRGC 잔존 지휘소 타격', + description: 'B-2, 카라즈 지하 IRGC 비상지휘소 벙커버스터 타격. IRGC 통신 체계 사실상 마비.', + intensity: 90, + }, + { + id: 'd11-il1', timestamp: T0 + 10 * DAY + 2 * HOUR, + lat: 34.6416, lng: 50.8746, type: 'airstrike', source: 'IL', + label: '쿰 — 이스라엘 3차 공습', + description: 'IAF F-35I, 쿰 인근 잔존 지하 사일로 3차 타격. 이란 중거리 탄도미사일 능력 완전 무력화.', + intensity: 85, + }, + { + id: 'd11-ir1', timestamp: T0 + 10 * DAY + 4 * HOUR, + lat: 26.5667, lng: 56.2500, type: 'explosion', + label: '호르무즈 해협 — 기뢰 자폭 사고', + description: 'IRGC 배치 해저기뢰 3개 자체 폭발. 이란 해군 소해정 1척 침몰. 해협 부분 개방 가능성.', + intensity: 70, + }, + { + id: 'd11-us2', timestamp: T0 + 10 * DAY + 6 * HOUR, + lat: 27.1832, lng: 56.2764, type: 'airstrike', source: 'US', + label: '반다르아바스 — IRGC 해군 최종 소탕', + description: 'F/A-18·B-1B 합동, 반다르아바스 잔존 IRGC 해군 자산(소해정 2척, 미사일정 5척) 최종 파괴.', + intensity: 85, + }, + { + id: 'd11-imp1', timestamp: T0 + 10 * DAY + 5 * HOUR, + lat: 26.2235, lng: 50.6009, type: 'impact', source: 'IR', + label: '바레인 — 잔존 순항미사일 피격', + description: '이란 잔존 호베이제 순항미사일, 바레인 주파이르 미 해군시설 인근 재차 피격. 미군 1명 부상.', + intensity: 65, + }, + { + id: 'd11-p1', timestamp: T0 + 10 * DAY + 8 * HOUR, + lat: 35.7100, lng: 51.3700, type: 'alert', + label: 'IRGC — 비공식 교전 중단 보도', + description: '미 정보당국: IRGC 잔존 미사일 재고 5% 이하. 사실상 교전 능력 소진. 비공식 교전 중단 징후.', + }, + { + id: 'd11-p2', timestamp: T0 + 10 * DAY + 10 * HOUR, + lat: 48.8566, lng: 2.3522, type: 'alert', + label: 'UN — 휴전 연장 결의안 논의', + description: 'UN 안보리, 72시간 휴전 연장 및 영구적 정전 합의 결의안 논의 개시. 러시아·중국 "조건부 찬성".', + }, + { + id: 'd11-kr1', timestamp: T0 + 10 * DAY + 12 * HOUR, + lat: 37.5665, lng: 126.9780, type: 'alert', + label: '한국 — 유류비 안정화 조치 발표', + description: '정부, 유류세 한시 인하 + 전략비축유 2차 방출 발표. WTI $175로 소폭 하락. 호르무즈 부분 개방 기대.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 12 — 3월 12일: 해상 전투 격화 + 유조선 추가 피격 + 소규모 교전 + // ═══════════════════════════════════════════════════════════════════ + + // ─── 선박/해상 공격 ─── + { + id: 'd12-sea1', timestamp: T0 + 11 * DAY, + lat: 26.3500, lng: 56.5000, type: 'explosion', + label: '호르무즈 해협 — 일본 유조선 기뢰 피격', + description: '일본 국적 VLCC "쇼와 마루" 호르무즈 해협 동측 항로에서 기뢰 접촉. 선체 파공, 원유 유출. 승조원 전원 구조.', + intensity: 80, + }, + { + id: 'd12-sea2', timestamp: T0 + 11 * DAY + 2 * HOUR, + lat: 26.2000, lng: 56.6000, type: 'explosion', + label: '오만만 — 한국 LNG선 드론 피격', + description: '한국행 LNG 운반선 "SK 이노베이션호" IRGC 자폭드론 2대 피격. 선체 경미 손상, 화물 무사. 청해부대 호위 전환.', + intensity: 75, + }, + { + id: 'd12-sea3', timestamp: T0 + 11 * DAY + 3 * HOUR, + lat: 25.8000, lng: 56.8000, type: 'impact', source: 'IR', + label: '오만만 — 그리스 컨테이너선 피격', + description: 'IRGC 누르 대함미사일, 그리스 국적 컨테이너선 "아테네 익스프레스" 타격. 화재 발생, 승조원 2명 부상.', + intensity: 70, + }, + { + id: 'd12-sea4', timestamp: T0 + 11 * DAY + 5 * HOUR, + lat: 26.6000, lng: 56.1000, type: 'explosion', + label: '호르무즈 해협 — IRGC 고속정 미 해군 교전', + description: 'IRGC 고속정 6척, USN 알레이버크급 구축함 USS 마이클 머피 접근. 함포·CIWS 교전 — 고속정 5척 격침.', + intensity: 85, + }, + + // ─── 공습 ─── + { + id: 'd12-us1', timestamp: T0 + 11 * DAY + 4 * HOUR, + lat: 26.9400, lng: 56.8500, type: 'airstrike', source: 'US', + label: '자스크 — IRGC 대함미사일 진지 파괴', + description: 'F/A-18E, 자스크 해안 IRGC 누르 대함미사일 발사진지 4개소 정밀타격. 호르무즈 해상 위협 제거.', + intensity: 85, + }, + { + id: 'd12-us2', timestamp: T0 + 11 * DAY + 6 * HOUR, + lat: 25.4000, lng: 57.8000, type: 'airstrike', source: 'US', + label: '차바하르 — IRGC 해군기지 타격', + description: 'B-1B, 차바하르 IRGC 해군기지 잠수정 계류시설 및 무기고 파괴. 이란 남동부 해군 전력 무력화.', + intensity: 80, + }, + { + id: 'd12-il1', timestamp: T0 + 11 * DAY + 7 * HOUR, + lat: 33.5138, lng: 36.2765, type: 'airstrike', source: 'IL', + label: '다마스쿠스 — 이란 무기 수송 차량 타격', + description: 'IAF, 다마스쿠스 인근 이란→헤즈볼라 무기 수송 차량 행렬 정밀타격. 차량 8대 파괴.', + intensity: 75, + }, + + // ─── 요격 ─── + { + id: 'd12-int1', timestamp: T0 + 11 * DAY + 3 * HOUR, + lat: 24.2478, lng: 54.5475, type: 'intercept', + label: 'THAAD — UAE 상공 미사일 요격', + description: 'THAAD, 알다프라 공군기지 방어. 이란 잔존 세질-2 미사일 2발 고고도 요격 성공.', + intensity: 80, + }, + + // ─── 피격/피해 ─── + { + id: 'd12-imp1', timestamp: T0 + 11 * DAY + 8 * HOUR, + lat: 29.3467, lng: 47.5208, type: 'impact', source: 'IR', + label: '쿠웨이트 — 알리알살렘 기지 드론 피격', + description: '샤헤드 드론 3대, 쿠웨이트 알리알살렘 공군기지 격납고 타격. 미군 MQ-9 리퍼 1대 파괴.', + intensity: 65, + }, + + // ─── 외교/상황 ─── + { + id: 'd12-p1', timestamp: T0 + 11 * DAY + 9 * HOUR, + lat: 26.5667, lng: 56.2500, type: 'alert', + label: '호르무즈 해협 — 미 해군 소해 작전 개시', + description: '미 해군 제5함대, 호르무즈 해협 기뢰 소해 작전 개시. 영국·프랑스 해군 합류. 상선 통항 일부 재개 목표.', + }, + { + id: 'd12-p2', timestamp: T0 + 11 * DAY + 10 * HOUR, + lat: 35.6650, lng: 51.4800, type: 'alert', source: 'IR', + label: '이란 — IRGC 해군 사령관 사망 확인', + description: '이란 국영 IRNA, IRGC 해군 사령관 알리레자 탕시리 제독 반다르아바스 공습 중 사망 확인. 후임 미지정.', + }, + { + id: 'd12-kr1', timestamp: T0 + 11 * DAY + 11 * HOUR, + lat: 37.5665, lng: 126.9780, type: 'alert', + label: '한국 — 한국행 LNG선 피격 관련 긴급 NSC', + description: 'NSC 긴급소집, SK 이노베이션호 피격 대응 논의. 청해부대 호르무즈 해협 진입 금지 명령. 대체 항로 검토.', + }, + + // ═══════════════════════════════════════════════════════════════════ + // DAY 12 후반 — 3월 12일 오후~밤: 호르무즈 해협 전면 소탕전 + // ═══════════════════════════════════════════════════════════════════ + + // ─── 호르무즈 해협 기뢰 소해 + 해안 포대 제압 ─── + { + id: 'd12-us3', timestamp: T0 + 11 * DAY + 12 * HOUR, + lat: 26.5200, lng: 56.3000, type: 'airstrike', source: 'US', + label: '호르무즈 해협 — MH-53E 기뢰 소해 작전', + description: '미 해군 MH-53E 소해헬기 편대, 호르무즈 해협 주항로 기뢰 14기 제거. 상선 제한적 통항 재개.', + intensity: 70, + }, + { + id: 'd12-us4', timestamp: T0 + 11 * DAY + 13 * HOUR, + lat: 27.1800, lng: 56.2800, type: 'airstrike', source: 'US', + label: '반다르아바스 — IRGC 해안 대함미사일 기지 파괴', + description: 'B-2 스피릿, 반다르아바스 서측 IRGC C-802 대함미사일 발사대 6기 및 지하 탄약고 정밀폭격.', + intensity: 90, + }, + { + id: 'd12-us5', timestamp: T0 + 11 * DAY + 14 * HOUR, + lat: 26.9800, lng: 56.0800, type: 'airstrike', source: 'US', + label: '게쉼섬 — IRGC 고속정 기지 파괴', + description: 'F/A-18F, 게쉼섬 IRGC 고속정 은신처 12개소 타격. 고속정 20여 척 파괴/대파.', + intensity: 85, + }, + { + id: 'd12-us6', timestamp: T0 + 11 * DAY + 15 * HOUR, + lat: 26.6700, lng: 55.9000, type: 'airstrike', source: 'US', + label: '라라크섬 — IRGC 감시레이더 파괴', + description: '토마호크 순항미사일 4발, 라라크섬 IRGC 해상감시레이더 및 통신중계시설 파괴.', + intensity: 75, + }, + + // ─── 해상 전투 ─── + { + id: 'd12-sea5', timestamp: T0 + 11 * DAY + 14 * HOUR, + lat: 26.4500, lng: 56.3500, type: 'explosion', + label: '호르무즈 해협 — IRGC 기뢰부설정 자폭', + description: 'IRGC 소해정 1척, 자체 배치한 기뢰에 접촉 폭발. 승조원 12명 사망 추정. 이란측 기뢰전 역량 추가 손실.', + intensity: 70, + }, + { + id: 'd12-sea6', timestamp: T0 + 11 * DAY + 16 * HOUR, + lat: 26.3000, lng: 56.7000, type: 'explosion', + label: '오만만 — 파나마 벌크선 기뢰 피격', + description: '파나마 국적 벌크선 "Pacific Pioneer" 오만만 북부에서 부유기뢰 접촉. 선수 파공, 느린 침수. 오만 해군 구조.', + intensity: 65, + }, + + // ─── 인접국가 관련 공습 ─── + { + id: 'd12-il2', timestamp: T0 + 11 * DAY + 13 * HOUR, + lat: 33.8900, lng: 35.5000, type: 'airstrike', source: 'IL', + label: '베이루트 남부 — 헤즈볼라 미사일 저장소 타격', + description: 'IAF F-35I, 베이루트 남부 다히에 지구 헤즈볼라 지하 미사일 저장시설 정밀타격. 2차 폭발 발생.', + intensity: 80, + }, + { + id: 'd12-us7', timestamp: T0 + 11 * DAY + 15 * HOUR, + lat: 34.8000, lng: 36.7000, type: 'airstrike', source: 'US', + label: '시리아 홈스 — 이란 무기 이송 거점 파괴', + description: 'B-1B, 시리아 홈스 인근 이란 혁명수비대 무기 이송 허브 및 주변 방공진지 파괴.', + intensity: 75, + }, + { + id: 'd12-us8', timestamp: T0 + 11 * DAY + 17 * HOUR, + lat: 33.3100, lng: 44.3700, type: 'airstrike', source: 'US', + label: '이라크 바그다드 — 친이란 민병대 거점 타격', + description: 'F-15E, 바그다드 남부 카타이브 헤즈볼라 무기고 2개소 정밀타격. 알아사드 기지 로켓공격 보복.', + intensity: 70, + }, + + // ─── 요격 ─── + { + id: 'd12-int2', timestamp: T0 + 11 * DAY + 14 * HOUR, + lat: 26.2200, lng: 50.5500, type: 'intercept', + label: '바레인 — 패트리어트 미사일 요격', + description: '바레인 주둔 미군 패트리어트, IRGC 잔존 단거리 탄도미사일 1발 요격. 미 5함대 사령부 방어.', + intensity: 75, + }, + { + id: 'd12-int3', timestamp: T0 + 11 * DAY + 16 * HOUR, + lat: 29.0000, lng: 48.0000, type: 'intercept', + label: '쿠웨이트 — SM-6 순항미사일 요격', + description: 'USS 할시 (DDG-97), 쿠웨이트 해상에서 이란발 순항미사일 2발 SM-6로 요격.', + intensity: 70, + }, + + // ─── 이란 보복 ─── + { + id: 'd12-ir1', timestamp: T0 + 11 * DAY + 16 * HOUR, + lat: 25.2500, lng: 55.3600, type: 'impact', source: 'IR', + label: 'UAE 두바이 — 제벨알리 항구 드론 공격', + description: 'IRGC 샤헤드-136 자폭드론 5대, 제벨알리 자유무역항 컨테이너 야적장 타격. 화재 발생, 항구 일시 폐쇄.', + intensity: 80, + }, + { + id: 'd12-ir2', timestamp: T0 + 11 * DAY + 18 * HOUR, + lat: 29.3800, lng: 47.9900, type: 'impact', source: 'IR', + label: '쿠웨이트 — 알자흐라 석유터미널 미사일 피격', + description: 'IRGC 파테-110 단거리 미사일, 쿠웨이트 알자흐라 석유 수출터미널 타격. 저장탱크 1기 화재. 원유 수출 중단.', + intensity: 75, + }, + + // ─── 상황/외교 ─── + { + id: 'd12-p3', timestamp: T0 + 11 * DAY + 18 * HOUR, + lat: 26.5667, lng: 56.2500, type: 'alert', + label: '호르무즈 해협 — 미/영/불 연합 해상안전회랑 설정', + description: '미 5함대·영국 해군·프랑스 해군 합동, 호르무즈 해협 남측 안전항행 회랑 설정. 상선 호위 개시.', + }, + { + id: 'd12-p4', timestamp: T0 + 11 * DAY + 20 * HOUR, + lat: 27.1800, lng: 56.2800, type: 'alert', source: 'IR', + label: '반다르아바스 — 이란 해군 항복적 후퇴', + description: 'IRGC 해군 잔여 함정, 반다르아바스 항 깊숙이 후퇴. 호르무즈 해협 수상 위협 사실상 종료. 기뢰 위협 지속.', + }, + { + id: 'd12-p5', timestamp: T0 + 11 * DAY + 22 * HOUR, + lat: 40.7128, lng: -74.0060, type: 'alert', + label: 'UN 안보리 — 호르무즈 해협 긴급회의 소집', + description: 'UN 안보리, 호르무즈 해협 상선 피격 관련 긴급회의 소집. 중국·러시아 즉각 휴전 촉구, 미국 항행자유 강조.', + }, +]; + +// 24시간 동안 10분 간격 센서 데이터 생성 +export function generateSensorData(): SensorLog[] { + const data: SensorLog[] = []; + const steps = (24 * 60) / 10; // 10분 간격 + + for (let i = 0; i <= steps; i++) { + const t = REPLAY_START + i * 10 * MIN; + const hoursFromT0 = (t - T0) / HOUR; + const distFromStrike = Math.abs(hoursFromT0); + + // 지진파: 다중 스파이크 — 초기 미국 공습, T0 이란 타격 + const seismicBase = 5 + Math.random() * 3; + const earlyStrikeSpike = hoursFromT0 < -8 && hoursFromT0 > -12 + ? 30 + Math.random() * 15 + : 0; + const mainSpike = distFromStrike < 0.5 + ? 85 + Math.random() * 15 + : distFromStrike < 1 + ? 50 + Math.random() * 15 + : distFromStrike < 2 + ? 20 + Math.random() * 10 + : 0; + + // 기압: 양측 공습 시 하락 + const pressureBase = 1013; + const pressureDrop = distFromStrike < 0.3 + ? -20 - Math.random() * 10 + : distFromStrike < 1 + ? -8 - Math.random() * 5 + : (hoursFromT0 < -8 && hoursFromT0 > -12) + ? -5 - Math.random() * 3 + : Math.random() * 2 - 1; + + // 소음: 분쟁 중 지속적 고수준 + const noiseBase = 35 + Math.random() * 5; + const noiseSpike = distFromStrike < 0.5 + ? 95 + Math.random() * 30 + : distFromStrike < 1 + ? 55 + Math.random() * 15 + : distFromStrike < 3 + ? 15 + Math.random() * 8 + : (hoursFromT0 < -8 && hoursFromT0 > -12) + ? 25 + Math.random() * 10 + : 0; + + // 방사능: 핵시설 타격 후 상승 + const radBase = 0.1 + Math.random() * 0.05; + const radSpike = hoursFromT0 > -8 && hoursFromT0 < 8 + ? 0.08 + Math.random() * 0.05 // 나탄즈/부셰르 타격으로 상승 + : 0; + + data.push({ + timestamp: t, + seismic: Math.min(100, seismicBase + earlyStrikeSpike + mainSpike), + airPressure: Math.round((pressureBase + pressureDrop) * 10) / 10, + noiseLevel: Math.min(140, Math.round(noiseBase + noiseSpike)), + radiationLevel: Math.round((radBase + radSpike) * 1000) / 1000, + }); + } + + return data; +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..d1719dc --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SPG_API_KEY?: string; + readonly VITE_GOOGLE_CLIENT_ID?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..40d77bb --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -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({ + 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, + }; +} diff --git a/frontend/src/hooks/useMonitor.ts b/frontend/src/hooks/useMonitor.ts new file mode 100644 index 0000000..0cdebd7 --- /dev/null +++ b/frontend/src/hooks/useMonitor.ts @@ -0,0 +1,36 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; + +const TICK_INTERVAL = 1000; // update every 1 second in live mode + +export interface MonitorState { + currentTime: number; // always Date.now() + historyMinutes: number; // how far back to show (default 60) +} + +export function useMonitor() { + const [state, setState] = useState({ + currentTime: Date.now(), + historyMinutes: 60, + }); + + const intervalRef = useRef(null); + + // Start ticking immediately + useEffect(() => { + intervalRef.current = window.setInterval(() => { + setState(prev => ({ ...prev, currentTime: Date.now() })); + }, TICK_INTERVAL); + return () => { + if (intervalRef.current !== null) clearInterval(intervalRef.current); + }; + }, []); + + const setHistoryMinutes = useCallback((minutes: number) => { + setState(prev => ({ ...prev, historyMinutes: minutes })); + }, []); + + const startTime = state.currentTime - state.historyMinutes * 60_000; + const endTime = state.currentTime; + + return { state, startTime, endTime, setHistoryMinutes }; +} diff --git a/frontend/src/hooks/useReplay.ts b/frontend/src/hooks/useReplay.ts new file mode 100644 index 0000000..68c4ac6 --- /dev/null +++ b/frontend/src/hooks/useReplay.ts @@ -0,0 +1,85 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { ReplayState } from '../types'; +import { REPLAY_START, REPLAY_END } from '../data/sampleData'; + +const TICK_INTERVAL = 200; // ms between updates +const TIME_STEP = 240_000; // 4 minutes of replay time per tick at 1x speed (same visual speed) + +export function useReplay() { + const [state, setState] = useState({ + isPlaying: false, + currentTime: REPLAY_START, + startTime: REPLAY_START, + endTime: REPLAY_END, + speed: 1, + }); + + const intervalRef = useRef(null); + + const stop = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const play = useCallback(() => { + setState(prev => ({ ...prev, isPlaying: true })); + }, []); + + const pause = useCallback(() => { + stop(); + setState(prev => ({ ...prev, isPlaying: false })); + }, [stop]); + + const seek = useCallback((time: number) => { + setState(prev => ({ + ...prev, + currentTime: Math.max(prev.startTime, Math.min(prev.endTime, time)), + })); + }, []); + + const setSpeed = useCallback((speed: number) => { + setState(prev => ({ ...prev, speed })); + }, []); + + const reset = useCallback(() => { + stop(); + setState(prev => ({ + ...prev, + isPlaying: false, + currentTime: prev.startTime, + })); + }, [stop]); + + const setRange = useCallback((start: number, end: number) => { + stop(); + setState(prev => ({ + ...prev, + isPlaying: false, + startTime: start, + endTime: end, + currentTime: Math.max(start, Math.min(end, prev.currentTime)), + })); + }, [stop]); + + useEffect(() => { + if (state.isPlaying) { + stop(); + intervalRef.current = window.setInterval(() => { + setState(prev => { + const next = prev.currentTime + TIME_STEP * prev.speed; + if (next >= prev.endTime) { + return { ...prev, currentTime: prev.endTime, isPlaying: false }; + } + return { ...prev, currentTime: next }; + }); + }, TICK_INTERVAL); + } else { + stop(); + } + return stop; + }, [state.isPlaying, state.speed, stop]); + + return { state, play, pause, seek, setSpeed, reset, setRange }; +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..8c906dd --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,47 @@ +import { useState, useCallback, useEffect } from 'react'; + +type Theme = 'dark' | 'light'; + +const STORAGE_KEY = 'kcg:theme'; +const DEFAULT_THEME: Theme = 'dark'; + +function readStoredTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'dark' || stored === 'light') return stored; + } catch { + // localStorage unavailable + } + return DEFAULT_THEME; +} + +function applyTheme(theme: Theme) { + document.documentElement.dataset.theme = theme; +} + +export function useTheme() { + const [theme, setThemeState] = useState(() => { + const t = readStoredTheme(); + applyTheme(t); + return t; + }); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + const setTheme = useCallback((t: Theme) => { + setThemeState(t); + try { + localStorage.setItem(STORAGE_KEY, t); + } catch { + // localStorage unavailable + } + }, []); + + const toggleTheme = useCallback(() => { + setTheme(theme === 'dark' ? 'light' : 'dark'); + }, [theme, setTheme]); + + return { theme, setTheme, toggleTheme } as const; +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..ce87462 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,44 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import koCommon from './locales/ko/common.json'; +import koEvents from './locales/ko/events.json'; +import koShips from './locales/ko/ships.json'; +import enCommon from './locales/en/common.json'; +import enEvents from './locales/en/events.json'; +import enShips from './locales/en/ships.json'; + +const STORAGE_KEY = 'kcg:lang'; + +function readStoredLang(): string { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'ko' || stored === 'en') return stored; + } catch { + // localStorage unavailable + } + return 'ko'; +} + +i18n.use(initReactI18next).init({ + resources: { + ko: { common: koCommon, events: koEvents, ships: koShips }, + en: { common: enCommon, events: enEvents, ships: enShips }, + }, + lng: readStoredLang(), + fallbackLng: 'ko', + defaultNS: 'common', + ns: ['common', 'events', 'ships'], + interpolation: { escapeValue: false }, +}); + +// Persist language changes +i18n.on('languageChanged', (lng) => { + try { + localStorage.setItem(STORAGE_KEY, lng); + } catch { + // localStorage unavailable + } +}); + +export default i18n; diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json new file mode 100644 index 0000000..3daf053 --- /dev/null +++ b/frontend/src/i18n/locales/en/common.json @@ -0,0 +1,227 @@ +{ + "tabs": { + "iran": "Iran Situation", + "korea": "Korea Status" + }, + "mode": { + "live": "LIVE", + "replay": "REPLAY" + }, + "mapMode": { + "flat": "FLAT MAP", + "globe": "GLOBE", + "satellite": "SATELLITE" + }, + "filters": { + "illegalFishing": "Illegal Fishing", + "illegalTransship": "Illegal Transship", + "darkVessel": "Dark Vessel", + "cableWatch": "Subsea Cable", + "dokdoWatch": "Dokdo Watch", + "ferryWatch": "Ferry Watch", + "illegalFishingMonitor": "Illegal Fishing Watch", + "illegalTransshipMonitor": "Illegal Transship Watch", + "darkVesselMonitor": "Dark Vessel Watch", + "cableWatchMonitor": "Subsea Cable Watch", + "dokdoWatchMonitor": "Dokdo Watch", + "ferryWatchMonitor": "Ferry Watch" + }, + "header": { + "ac": "AC", + "mil": "MIL", + "ship": "SHIP", + "sat": "SAT", + "live": "LIVE", + "replaying": "REPLAYING", + "paused": "PAUSED" + }, + "time": { + "justNow": "Just now", + "minutesAgo": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "daysAgo": "{{count}}d ago", + "currentTime": "Current Time", + "history": "History" + }, + "units": { + "knots": "kn", + "meters": "m", + "km": "km", + "vessels": "vessels", + "aircraft": "aircraft" + }, + "theme": { + "dark": "Dark", + "light": "Light" + }, + "language": { + "ko": "한국어", + "en": "English" + }, + "controls": { + "play": "Play", + "pause": "Pause", + "reset": "Reset", + "speed": "Speed", + "customRange": "Custom Range", + "from": "FROM (KST)", + "to": "TO (KST)", + "apply": "APPLY" + }, + "layers": { + "events": "Events", + "aircraft": "Aircraft", + "satellites": "Satellites", + "ships": "Ships", + "koreanShips": "Korean Ships", + "airports": "Airports", + "sensorCharts": "Sensor Charts", + "oilFacilities": "Oil Facilities", + "militaryOnly": "Military Only", + "infra": "Power/Substation", + "cables": "Subsea Cable", + "cctv": "CCTV", + "coastGuard": "Coast Guard", + "navWarning": "Nav Warning", + "osint": "OSINT Incident", + "eez": "EEZ / NLL", + "piracy": "Piracy Zone" + }, + "korea": { + "transshipSuspect": "Transship Suspect", + "cableDanger": "Cable Danger", + "dokdoIntrusion": "Dokdo Intrusion", + "dokdoApproach": "Dokdo Approach", + "dokdoAlerts": "Dokdo Watch Alerts", + "territorialIntrusion": "Territorial Intrusion", + "approachWarning": "Approach Warning", + "dokdoDistance": "Dokdo {{dist}}km", + "detected": "{{count}} vessels detected", + "filterMonitoring": "Monitoring" + }, + "legend": { + "altitude": "ALTITUDE", + "military": "MILITARY", + "vesselType": "VESSEL TYPE" + }, + "popup": { + "impactSite": "IMPACT SITE", + "coordinates": "{{lat}}°N, {{lng}}°E" + }, + "sensor": { + "title": "Sensor Data", + "seismic": "Seismic", + "seismicActivity": "Seismic Activity", + "airPressure": "Air Pressure", + "airPressureHpa": "Air Pressure (hPa)", + "noiseLevel": "Noise Level", + "noiseLevelDb": "Noise Level (dB)", + "radiation": "Radiation", + "radiationUsv": "Radiation (uSv/h)" + }, + "event": { + "airstrike": "Airstrike", + "explosion": "Explosion", + "missileLaunch": "Missile Launch", + "intercept": "Intercept", + "alert": "Alert", + "impact": "Impact", + "osint": "OSINT" + }, + "source": { + "US": "United States", + "IL": "Israel", + "IR": "Iran", + "proxy": "Proxy" + }, + "timeline": { + "flyToTooltip": "Click to fly to this location on the map" + }, + "dayNames": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + }, + "airport": { + "international": "International", + "domestic": "Domestic" + }, + "coastGuard": { + "agency": "Korea Coast Guard" + }, + "piracy": { + "recentIncidents": "{{count}} incidents in past year" + }, + "navWarning": { + "altitude": "Altitude", + "source": "Source", + "khoaLink": "KHOA Nav Warning Dashboard" + }, + "osintMap": { + "viewOriginal": "View Original Article" + }, + "satellite": { + "norad": "NORAD", + "lat": "Lat", + "lng": "Lng", + "alt": "Alt" + }, + "airportPopup": { + "iata": "IATA", + "icao": "ICAO", + "city": "City", + "country": "Country" + }, + "damagedShip": { + "sunk": "Sunk", + "severe": "Severe", + "moderate": "Moderate", + "minor": "Minor", + "shipType": "Type", + "flag": "Flag", + "cause": "Cause", + "damagedAt": "Damaged" + }, + "cable": { + "landingStation": "{{name}} Cable Landing Station", + "connectedCables": "Connected Cables: {{count}}", + "waypoints": "Waypoints", + "rfsYear": "RFS", + "totalLength": "Total Length", + "operator": "Operator", + "yearSuffix": "" + }, + "auth": { + "title": "KCG Monitoring Dashboard", + "subtitle": "Maritime Situational Awareness", + "googleLogin": "Sign in with Google", + "domainNotice": "Only @gcsc.co.kr accounts are allowed", + "devLogin": "DEV LOGIN (Bypass Auth)", + "devNotice": "Development only", + "loading": "Checking authentication...", + "loginFailed": "Login failed", + "domainError": "Only gcsc.co.kr domain accounts can access", + "logout": "Logout" + }, + "infra": { + "nuclear": "Nuclear", + "coal": "Coal", + "gas": "LNG", + "oil": "Oil", + "hydro": "Hydro", + "solar": "Solar", + "wind": "Wind", + "biomass": "Biomass", + "substation": "Substation", + "plant": "Power Plant", + "powerPlant": "Power Plant", + "output": "Output", + "voltage": "Voltage", + "operator": "Operator", + "fuel": "Fuel" + } +} diff --git a/frontend/src/i18n/locales/en/events.json b/frontend/src/i18n/locales/en/events.json new file mode 100644 index 0000000..d2b5994 --- /dev/null +++ b/frontend/src/i18n/locales/en/events.json @@ -0,0 +1,65 @@ +{ + "type": { + "airstrike": "Airstrike", + "explosion": "Explosion", + "missile_launch": "Missile Launch", + "intercept": "Intercept", + "alert": "Alert", + "impact": "Impact", + "osint": "OSINT" + }, + "source": { + "US": "United States", + "IL": "Israel", + "IR": "Iran", + "proxy": "Proxy Forces" + }, + "category": { + "military": "Military", + "shipping": "Shipping", + "oil": "Oil & Gas", + "nuclear": "Nuclear", + "diplomacy": "Diplomacy", + "economy": "Economy", + "cyber": "Cyber", + "humanitarian": "Humanitarian", + "political": "Political" + }, + "news": { + "breaking": "Breaking", + "breakingTitle": "Breaking / Major News", + "koreaTitle": "Korea Major News", + "liveUpdates": "Live Updates", + "source": "Source", + "readMore": "Read More", + "categoryLabel": { + "trump": "Trump", + "oil": "Oil", + "diplomacy": "Diplomacy", + "economy": "Economy" + } + }, + "osint": { + "title": "OSINT Feed", + "noData": "No OSINT data collected", + "liveTitle": "OSINT LIVE FEED", + "koreaLiveTitle": "OSINT LIVE", + "loading": "Loading...", + "categoryLabel": { + "military": "Military", + "oil": "Energy", + "diplomacy": "Diplomacy", + "shipping": "Shipping", + "nuclear": "Nuclear", + "maritime_accident": "Maritime Accident", + "fishing": "Fishing", + "maritime_traffic": "Maritime Traffic", + "general": "General" + } + }, + "log": { + "title": "Event Log", + "noEvents": "No events yet. Press play to start replay.", + "new": "NEW" + } +} diff --git a/frontend/src/i18n/locales/en/ships.json b/frontend/src/i18n/locales/en/ships.json new file mode 100644 index 0000000..f042d7e --- /dev/null +++ b/frontend/src/i18n/locales/en/ships.json @@ -0,0 +1,197 @@ +{ + "category": { + "warship": "Warship", + "carrier": "Aircraft Carrier", + "destroyer": "Destroyer", + "submarine": "Submarine", + "cargo": "Cargo", + "tanker": "Tanker", + "patrol": "Patrol", + "civilian": "Civilian", + "unknown": "Unknown" + }, + "categoryLabel": { + "carrier": "CARRIER", + "destroyer": "DDG", + "warship": "WARSHIP", + "submarine": "SUB", + "patrol": "PATROL", + "tanker": "TANKER", + "cargo": "CARGO", + "civilian": "CIV", + "unknown": "N/A" + }, + "mtType": { + "cargo": "Cargo", + "tanker": "Tanker", + "passenger": "Passenger", + "fishing": "Fishing", + "military": "Military", + "tug_special": "Tug/Special", + "high_speed": "High Speed", + "pleasure": "Pleasure", + "other": "Other", + "unspecified": "Unspecified", + "unknown": "Unknown" + }, + "mtTypeLabel": { + "cargo": "Cargo", + "tanker": "Tanker", + "passenger": "Passenger", + "fishing": "Fishing", + "pleasure": "Yacht", + "military": "Military", + "tug_special": "Tug/Special", + "other": "Other", + "unknown": "Unknown" + }, + "flag": { + "KR": "South Korea", + "US": "United States", + "JP": "Japan", + "CN": "China", + "IR": "Iran", + "UK": "United Kingdom", + "FR": "France", + "DE": "Germany", + "PA": "Panama", + "LR": "Liberia", + "MH": "Marshall Islands", + "AU": "Australia", + "IN": "India", + "HK": "Hong Kong", + "SG": "Singapore", + "BZ": "Belize", + "OM": "Oman", + "AE": "UAE", + "SA": "Saudi Arabia", + "BH": "Bahrain", + "QA": "Qatar" + }, + "navy": { + "US": "US NAVY", + "KR": "ROKN", + "JP": "JMSDF", + "CN": "PLAN", + "IR": "IRIN" + }, + "navyLabel": { + "US": "USN", + "UK": "RN", + "FR": "MN", + "KR": "ROKN", + "IR": "IRIN", + "JP": "JMSDF", + "AU": "RAN", + "DE": "DM", + "IN": "IN" + }, + "popup": { + "mmsi": "MMSI", + "imo": "IMO", + "callSign": "Call Sign", + "heading": "Heading", + "course": "Course", + "speed": "Speed", + "lat": "Lat", + "lon": "Lon", + "destination": "Dest", + "eta": "ETA", + "status": "Status", + "length": "Length", + "width": "Width", + "draught": "Draught", + "lastUpdate": "Last Update", + "loading": "Loading..." + }, + "status": { + "anchored": "Anchored", + "underway": "Underway", + "moored": "Moored", + "total": "Total" + }, + "monitoring": { + "illegalFishing": "Suspected Illegal Fishing", + "illegalTransship": "Suspected Illegal Transshipment", + "darkVessel": "Suspected AIS Off", + "cableWatch": "Near Subsea Cable", + "dokdoWatch": "Dokdo Territorial Approach", + "ferryWatch": "Ferry Location" + }, + "aircraft": { + "fighter": "Fighter", + "military": "Military", + "surveillance": "Surveillance", + "tanker": "Tanker", + "cargo": "Transport", + "civilian": "Civilian", + "unknown": "Unknown" + }, + "aircraftLabel": { + "fighter": "FIGHTER", + "tanker": "TANKER", + "surveillance": "ISR", + "cargo": "CARGO", + "military": "MIL", + "civilian": "CIV", + "unknown": "???" + }, + "aircraftPopup": { + "hex": "Hex", + "reg": "Reg.", + "operator": "Operator", + "type": "Type", + "squawk": "Squawk", + "alt": "Alt", + "speed": "Speed", + "hdg": "Hdg", + "verticalSpeed": "V/S", + "ground": "GROUND", + "loadingPhoto": "Loading photo..." + }, + "shipStatus": { + "koreanTitle": "Korean Ship Status", + "chineseTitle": "Chinese Ship Status", + "chineseFishingAlert": "{{count}} Chinese fishing vessels near our waters" + }, + "cctv": { + "region": { + "jeju": "Jeju", + "south": "South Sea", + "west": "West Sea", + "east": "East Sea" + }, + "type": { + "tide": "Tide Observation", + "fog": "Fog Observation" + }, + "live": "LIVE", + "khoa": "KHOA", + "viewStream": "View Live Stream", + "connecting": "Connecting", + "connectionFailed": "Connection Failed", + "viewOnBadatime": "View on badatime.com", + "rec": "REC", + "khoaFull": "KHOA National Ocean Survey", + "connectingEllipsis": "Connecting..." + }, + "facility": { + "type": { + "refinery": "Refinery", + "oilfield": "Oilfield", + "gasfield": "Gas Field", + "terminal": "Export Terminal", + "petrochemical": "Petrochemical", + "desalination": "Desalination" + }, + "damaged": "Damaged", + "plannedStrike": "Planned Strike", + "production": "Production", + "desalProduction": "Desalination", + "gasProduction": "Gas Production", + "reserveOil": "Reserves (Oil)", + "reserveGas": "Reserves (Gas)", + "operator": "Operator", + "barrels": "barrels" + } +} diff --git a/frontend/src/i18n/locales/ko/common.json b/frontend/src/i18n/locales/ko/common.json new file mode 100644 index 0000000..a20e3f4 --- /dev/null +++ b/frontend/src/i18n/locales/ko/common.json @@ -0,0 +1,227 @@ +{ + "tabs": { + "iran": "이란 상황", + "korea": "한국 현황" + }, + "mode": { + "live": "LIVE", + "replay": "REPLAY" + }, + "mapMode": { + "flat": "FLAT MAP", + "globe": "GLOBE", + "satellite": "위성지도" + }, + "filters": { + "illegalFishing": "불법어선", + "illegalTransship": "불법환적", + "darkVessel": "다크베셀", + "cableWatch": "해저케이블", + "dokdoWatch": "독도감시", + "ferryWatch": "여객선감시", + "illegalFishingMonitor": "불법어선 감시", + "illegalTransshipMonitor": "불법환적 감시", + "darkVesselMonitor": "다크베셀 감시", + "cableWatchMonitor": "해저케이블 감시", + "dokdoWatchMonitor": "독도감시", + "ferryWatchMonitor": "여객선감시" + }, + "header": { + "ac": "AC", + "mil": "MIL", + "ship": "SHIP", + "sat": "SAT", + "live": "LIVE", + "replaying": "REPLAYING", + "paused": "PAUSED" + }, + "time": { + "justNow": "방금", + "minutesAgo": "{{count}}분 전", + "hoursAgo": "{{count}}시간 전", + "daysAgo": "{{count}}일 전", + "currentTime": "현재 시각", + "history": "히스토리" + }, + "units": { + "knots": "kn", + "meters": "m", + "km": "km", + "vessels": "척", + "aircraft": "대" + }, + "theme": { + "dark": "다크", + "light": "라이트" + }, + "language": { + "ko": "한국어", + "en": "English" + }, + "controls": { + "play": "재생", + "pause": "일시정지", + "reset": "초기화", + "speed": "배속", + "customRange": "사용자 범위", + "from": "시작 (KST)", + "to": "종료 (KST)", + "apply": "적용" + }, + "layers": { + "events": "이벤트", + "aircraft": "항공기", + "satellites": "위성", + "ships": "선박", + "koreanShips": "한국 선박", + "airports": "공항", + "sensorCharts": "센서 차트", + "oilFacilities": "유전시설", + "militaryOnly": "군용기만", + "infra": "발전/변전", + "cables": "해저케이블", + "cctv": "CCTV", + "coastGuard": "해경", + "navWarning": "항행경보", + "osint": "OSINT 사고", + "eez": "EEZ / NLL", + "piracy": "해적 위험해역" + }, + "korea": { + "transshipSuspect": "환적의심", + "cableDanger": "케이블위험", + "dokdoIntrusion": "독도침범", + "dokdoApproach": "독도접근", + "dokdoAlerts": "독도감시 알림", + "territorialIntrusion": "영해침범", + "approachWarning": "접근경고", + "dokdoDistance": "독도 {{dist}}km", + "detected": "{{count}}척 탐지", + "filterMonitoring": "감시" + }, + "legend": { + "altitude": "ALTITUDE", + "military": "MILITARY", + "vesselType": "VESSEL TYPE" + }, + "popup": { + "impactSite": "IMPACT SITE", + "coordinates": "{{lat}}°N, {{lng}}°E" + }, + "sensor": { + "title": "센서 데이터", + "seismic": "지진파", + "seismicActivity": "지진파 활동", + "airPressure": "기압", + "airPressureHpa": "기압 (hPa)", + "noiseLevel": "소음", + "noiseLevelDb": "소음 수준 (dB)", + "radiation": "방사선", + "radiationUsv": "방사선 (uSv/h)" + }, + "event": { + "airstrike": "공습", + "explosion": "폭발", + "missileLaunch": "미사일 발사", + "intercept": "요격", + "alert": "경보", + "impact": "피격", + "osint": "OSINT" + }, + "source": { + "US": "미국", + "IL": "이스라엘", + "IR": "이란", + "proxy": "대리세력" + }, + "timeline": { + "flyToTooltip": "클릭하면 지도에서 해당 위치로 이동합니다" + }, + "dayNames": { + "sun": "일", + "mon": "월", + "tue": "화", + "wed": "수", + "thu": "목", + "fri": "금", + "sat": "토" + }, + "airport": { + "international": "국제선", + "domestic": "국내선" + }, + "coastGuard": { + "agency": "해양경찰청" + }, + "piracy": { + "recentIncidents": "최근 1년 {{count}}건" + }, + "navWarning": { + "altitude": "사용고도", + "source": "출처", + "khoaLink": "KHOA 항행경보 상황판 바로가기" + }, + "osintMap": { + "viewOriginal": "기사 원문 보기" + }, + "satellite": { + "norad": "NORAD", + "lat": "Lat", + "lng": "Lng", + "alt": "Alt" + }, + "airportPopup": { + "iata": "IATA", + "icao": "ICAO", + "city": "City", + "country": "Country" + }, + "damagedShip": { + "sunk": "침몰", + "severe": "중파", + "moderate": "중손", + "minor": "경미", + "shipType": "선종", + "flag": "국적", + "cause": "원인", + "damagedAt": "피격" + }, + "cable": { + "landingStation": "{{name}} 해저케이블 기지", + "connectedCables": "연결 케이블: {{count}}개", + "waypoints": "경유지", + "rfsYear": "개통", + "totalLength": "총 길이", + "operator": "운영", + "yearSuffix": "년" + }, + "auth": { + "title": "KCG 모니터링 대시보드", + "subtitle": "해양 상황 인식 시스템", + "googleLogin": "Google로 로그인", + "domainNotice": "@gcsc.co.kr 계정만 허용됩니다", + "devLogin": "DEV LOGIN (인증 우회)", + "devNotice": "개발 환경 전용", + "loading": "인증 확인 중...", + "loginFailed": "로그인 실패", + "domainError": "gcsc.co.kr 도메인 계정만 접근할 수 있습니다", + "logout": "로그아웃" + }, + "infra": { + "nuclear": "원자력", + "coal": "석탄", + "gas": "LNG", + "oil": "석유", + "hydro": "수력", + "solar": "태양광", + "wind": "풍력", + "biomass": "바이오", + "substation": "변전소", + "plant": "발전소", + "powerPlant": "발전소", + "output": "출력", + "voltage": "전압", + "operator": "운영", + "fuel": "연료" + } +} diff --git a/frontend/src/i18n/locales/ko/events.json b/frontend/src/i18n/locales/ko/events.json new file mode 100644 index 0000000..adf3b73 --- /dev/null +++ b/frontend/src/i18n/locales/ko/events.json @@ -0,0 +1,65 @@ +{ + "type": { + "airstrike": "공습", + "explosion": "폭발", + "missile_launch": "미사일 발사", + "intercept": "요격", + "alert": "경보", + "impact": "피격", + "osint": "OSINT" + }, + "source": { + "US": "미국", + "IL": "이스라엘", + "IR": "이란", + "proxy": "대리세력" + }, + "category": { + "military": "군사", + "shipping": "해운", + "oil": "석유", + "nuclear": "핵", + "diplomacy": "외교", + "economy": "경제", + "cyber": "사이버", + "humanitarian": "인도주의", + "political": "정치" + }, + "news": { + "breaking": "속보", + "breakingTitle": "속보 / 주요 뉴스", + "koreaTitle": "한국 주요 뉴스", + "liveUpdates": "실시간 업데이트", + "source": "출처", + "readMore": "자세히 보기", + "categoryLabel": { + "trump": "트럼프", + "oil": "유가", + "diplomacy": "외교", + "economy": "경제" + } + }, + "osint": { + "title": "OSINT 피드", + "noData": "수집된 OSINT 정보가 없습니다", + "liveTitle": "OSINT LIVE FEED", + "koreaLiveTitle": "OSINT LIVE", + "loading": "Loading...", + "categoryLabel": { + "military": "군사", + "oil": "에너지", + "diplomacy": "외교", + "shipping": "해운", + "nuclear": "핵", + "maritime_accident": "해양사고", + "fishing": "어선/수산", + "maritime_traffic": "해상교통", + "general": "일반" + } + }, + "log": { + "title": "Event Log", + "noEvents": "아직 이벤트가 없습니다. 재생을 눌러 시작하세요.", + "new": "NEW" + } +} diff --git a/frontend/src/i18n/locales/ko/ships.json b/frontend/src/i18n/locales/ko/ships.json new file mode 100644 index 0000000..48e4481 --- /dev/null +++ b/frontend/src/i18n/locales/ko/ships.json @@ -0,0 +1,197 @@ +{ + "category": { + "warship": "군함", + "carrier": "항공모함", + "destroyer": "구축함", + "submarine": "잠수함", + "cargo": "화물선", + "tanker": "유조선", + "patrol": "초계함", + "civilian": "민간선", + "unknown": "미분류" + }, + "categoryLabel": { + "carrier": "CARRIER", + "destroyer": "DDG", + "warship": "WARSHIP", + "submarine": "SUB", + "patrol": "PATROL", + "tanker": "TANKER", + "cargo": "CARGO", + "civilian": "CIV", + "unknown": "N/A" + }, + "mtType": { + "cargo": "화물선", + "tanker": "유조선", + "passenger": "여객선", + "fishing": "어선", + "military": "군함", + "tug_special": "예인선", + "high_speed": "고속선", + "pleasure": "유람선", + "other": "기타", + "unspecified": "미분류", + "unknown": "미분류" + }, + "mtTypeLabel": { + "cargo": "Cargo", + "tanker": "Tanker", + "passenger": "Passenger", + "fishing": "Fishing", + "pleasure": "Yacht", + "military": "Military", + "tug_special": "Tug/Special", + "other": "Other", + "unknown": "Unknown" + }, + "flag": { + "KR": "한국", + "US": "미국", + "JP": "일본", + "CN": "중국", + "IR": "이란", + "UK": "영국", + "FR": "프랑스", + "DE": "독일", + "PA": "파나마", + "LR": "라이베리아", + "MH": "마셜제도", + "AU": "호주", + "IN": "인도", + "HK": "홍콩", + "SG": "싱가포르", + "BZ": "벨리즈", + "OM": "오만", + "AE": "UAE", + "SA": "사우디", + "BH": "바레인", + "QA": "카타르" + }, + "navy": { + "US": "US NAVY", + "KR": "ROKN", + "JP": "JMSDF", + "CN": "PLAN", + "IR": "IRIN" + }, + "navyLabel": { + "US": "USN", + "UK": "RN", + "FR": "MN", + "KR": "ROKN", + "IR": "IRIN", + "JP": "JMSDF", + "AU": "RAN", + "DE": "DM", + "IN": "IN" + }, + "popup": { + "mmsi": "MMSI", + "imo": "IMO", + "callSign": "Call Sign", + "heading": "Heading", + "course": "Course", + "speed": "Speed", + "lat": "Lat", + "lon": "Lon", + "destination": "Dest", + "eta": "ETA", + "status": "Status", + "length": "Length", + "width": "Width", + "draught": "Draught", + "lastUpdate": "Last Update", + "loading": "Loading..." + }, + "status": { + "anchored": "정박", + "underway": "항해", + "moored": "계류", + "total": "전체" + }, + "monitoring": { + "illegalFishing": "불법어선 의심", + "illegalTransship": "불법환적 의심", + "darkVessel": "AIS 미송출 의심", + "cableWatch": "해저케이블 근접", + "dokdoWatch": "독도 영해 접근", + "ferryWatch": "여객선 위치" + }, + "aircraft": { + "fighter": "전투기", + "military": "군용기", + "surveillance": "정찰기", + "tanker": "공중급유기", + "cargo": "수송기", + "civilian": "민간기", + "unknown": "미분류" + }, + "aircraftLabel": { + "fighter": "FIGHTER", + "tanker": "TANKER", + "surveillance": "ISR", + "cargo": "CARGO", + "military": "MIL", + "civilian": "CIV", + "unknown": "???" + }, + "aircraftPopup": { + "hex": "Hex", + "reg": "Reg.", + "operator": "Operator", + "type": "Type", + "squawk": "Squawk", + "alt": "Alt", + "speed": "Speed", + "hdg": "Hdg", + "verticalSpeed": "V/S", + "ground": "GROUND", + "loadingPhoto": "Loading photo..." + }, + "shipStatus": { + "koreanTitle": "한국 선박 현황", + "chineseTitle": "중국 선박 현황", + "chineseFishingAlert": "중국어선 {{count}}척 우리 해역 근접" + }, + "cctv": { + "region": { + "jeju": "제주", + "south": "남해", + "west": "서해", + "east": "동해" + }, + "type": { + "tide": "조위관측", + "fog": "해무관측" + }, + "live": "LIVE", + "khoa": "KHOA", + "viewStream": "실시간 영상 보기", + "connecting": "연결중", + "connectionFailed": "연결 실패", + "viewOnBadatime": "badatime.com에서 보기", + "rec": "REC", + "khoaFull": "KHOA 국립해양조사원", + "connectingEllipsis": "연결 중..." + }, + "facility": { + "type": { + "refinery": "정유소", + "oilfield": "유전", + "gasfield": "가스전", + "terminal": "수출터미널", + "petrochemical": "석유화학", + "desalination": "담수화시설" + }, + "damaged": "피격", + "plannedStrike": "공격 예정", + "production": "생산/처리", + "desalProduction": "담수생산", + "gasProduction": "가스생산", + "reserveOil": "매장량(유)", + "reserveGas": "매장량(가스)", + "operator": "운영사", + "barrels": "배럴" + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5536b3f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,30 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Legacy aliases → kcg tokens (theme-reactive) */ + --bg-primary: var(--kcg-bg); + --bg-secondary: var(--kcg-surface); + --bg-card: var(--kcg-card); + --text-primary: var(--kcg-text); + --text-secondary: var(--kcg-muted); + --accent: var(--kcg-accent); + --danger: var(--kcg-danger); + --warning: var(--kcg-warning); +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +#root { + width: 100%; + height: 100vh; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..81dce04 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './i18n' +import './styles/tailwind.css' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/services/airplaneslive.ts b/frontend/src/services/airplaneslive.ts new file mode 100644 index 0000000..36d24ae --- /dev/null +++ b/frontend/src/services/airplaneslive.ts @@ -0,0 +1,318 @@ +import type { Aircraft, AircraftCategory } from '../types'; + +// Airplanes.live API - specializes in military aircraft tracking +const ADSBX_BASE = '/api/airplaneslive/v2'; + +// Known military type codes +const MILITARY_TYPES: Record = { + 'F16': 'fighter', 'F15': 'fighter', 'F15E': 'fighter', 'FA18': 'fighter', + 'F22': 'fighter', 'F35': 'fighter', 'F14': 'fighter', 'EF2K': 'fighter', + 'RFAL': 'fighter', 'SU27': 'fighter', 'SU30': 'fighter', 'SU35': 'fighter', + 'KC10': 'tanker', 'KC30': 'tanker', 'KC46': 'tanker', 'K35R': 'tanker', + 'KC35': 'tanker', 'A332': 'tanker', + 'RC135': 'surveillance', 'E3': 'surveillance', 'E8': 'surveillance', + 'RQ4': 'surveillance', 'MQ9': 'surveillance', 'P8': 'surveillance', + 'EP3': 'surveillance', 'E6': 'surveillance', 'U2': 'surveillance', + 'C17': 'cargo', 'C5': 'cargo', 'C130': 'cargo', 'C2': 'cargo', +}; + +interface AirplanesLiveAc { + hex: string; + flight?: string; + r?: string; // registration (e.g. "A6-XWC") + lat?: number; + lon?: number; + alt_baro?: number | 'ground'; + gs?: number; + track?: number; + baro_rate?: number; + t?: string; // aircraft type code (e.g. "A35K") + desc?: string; // type description (e.g. "AIRBUS A-350-1000") + ownOp?: string; // owner/operator + squawk?: string; + category?: string; + nav_heading?: number; + seen?: number; + seen_pos?: number; + dbFlags?: number; + emergency?: string; +} + +function classifyFromType(type: string): AircraftCategory { + const t = type.toUpperCase(); + for (const [code, cat] of Object.entries(MILITARY_TYPES)) { + if (t.includes(code)) return cat; + } + return 'civilian'; // 군용 타입이 아니면 민간기 +} + +function parseAirplanesLive(data: { ac?: AirplanesLiveAc[] }): Aircraft[] { + if (!data.ac) return []; + + return data.ac + .filter(a => a.lat != null && a.lon != null) + .map(a => { + const typecode = a.t || ''; + const isMilDb = (a.dbFlags ?? 0) & 1; // military flag in database + let category = classifyFromType(typecode); + if (category === 'civilian' && isMilDb) category = 'military'; + + return { + icao24: a.hex, + callsign: (a.flight || '').trim(), + lat: a.lat!, + lng: a.lon!, + altitude: a.alt_baro === 'ground' ? 0 : (a.alt_baro ?? 0) * 0.3048, // ft->m + velocity: (a.gs ?? 0) * 0.5144, // knots -> m/s + heading: a.track ?? a.nav_heading ?? 0, + verticalRate: (a.baro_rate ?? 0) * 0.00508, // fpm -> m/s + onGround: a.alt_baro === 'ground', + category, + typecode: typecode || undefined, + typeDesc: a.desc || undefined, + registration: a.r || undefined, + operator: a.ownOp || undefined, + squawk: a.squawk || undefined, + lastSeen: Date.now() - (a.seen ?? 0) * 1000, + }; + }); +} + +export async function fetchMilitaryAircraft(): Promise { + try { + // Airplanes.live military endpoint - Middle East area + const url = `${ADSBX_BASE}/mil`; + const res = await fetch(url); + if (res.status === 429) return []; + if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); + const data = await res.json(); + + // Filter to Middle East + surrounding region + return parseAirplanesLive(data).filter( + a => a.lat >= 12 && a.lat <= 42 && a.lng >= 25 && a.lng <= 68, + ); + } catch (err) { + console.warn('Airplanes.live fetch failed:', err); + return []; // Will fallback to OpenSky sample data + } +} + +// ═══ Korea region military aircraft ═══ +export async function fetchMilitaryAircraftKorea(): Promise { + try { + const url = `${ADSBX_BASE}/mil`; + const res = await fetch(url); + if (res.status === 429) return []; + if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`); + const data = await res.json(); + return parseAirplanesLive(data).filter( + a => a.lat >= 15 && a.lat <= 50 && a.lng >= 110 && a.lng <= 150, + ); + } catch (err) { + console.warn('Airplanes.live Korea mil failed:', err); + return []; + } +} + +// Korea region queries for all aircraft +const KR_QUERIES = [ + { lat: 37.5, lon: 127, radius: 250 }, // 서울 / 수도권 + { lat: 35, lon: 129, radius: 250 }, // 부산 / 경남 + { lat: 33.5, lon: 126.5, radius: 200 }, // 제주 + { lat: 36, lon: 127, radius: 250 }, // 충청 / 대전 + { lat: 38.5, lon: 128, radius: 200 }, // 동해안 / 강원 + { lat: 35.5, lon: 131, radius: 250 }, // 동해 / 울릉도 + { lat: 34, lon: 124, radius: 200 }, // 서해 / 황해 + { lat: 40, lon: 130, radius: 250 }, // 일본해 / 북방 +]; + +const krLiveCache = new Map(); +let krInitialDone = false; +let krQueryIdx = 0; +let krInitPromise: Promise | null = null; + +async function doKrInitialLoad(): Promise { + console.log('Airplanes.live Korea: initial load...'); + for (let i = 0; i < KR_QUERIES.length; i++) { + try { + if (i > 0) await delay(1500); + const ac = await fetchOneRegion(KR_QUERIES[i]); + krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() }); + } catch { /* skip */ } + } + krInitialDone = true; + krInitPromise = null; +} + +export async function fetchAllAircraftLiveKorea(): Promise { + const now = Date.now(); + + if (!krInitialDone) { + if (!krInitPromise) krInitPromise = doKrInitialLoad(); + } else { + const toFetch: { idx: number; q: typeof KR_QUERIES[0] }[] = []; + for (let i = 0; i < 2; i++) { + const idx = (krQueryIdx + i) % KR_QUERIES.length; + const cached = krLiveCache.get(`kr-${idx}`); + if (!cached || now - cached.ts > CACHE_TTL) { + toFetch.push({ idx, q: KR_QUERIES[idx] }); + } + } + krQueryIdx = (krQueryIdx + 2) % KR_QUERIES.length; + + for (let i = 0; i < toFetch.length; i++) { + try { + if (i > 0) await delay(1200); + const ac = await fetchOneRegion(toFetch[i].q); + krLiveCache.set(`kr-${toFetch[i].idx}`, { ac, ts: Date.now() }); + } catch { /* skip */ } + } + } + + const seen = new Set(); + const merged: Aircraft[] = []; + for (const { ac } of krLiveCache.values()) { + for (const a of ac) { + if (!seen.has(a.icao24)) { seen.add(a.icao24); merged.push(a); } + } + } + return merged; +} + +// Fetch ALL aircraft (military + civilian) in Middle East using point/radius queries +// Airplanes.live /v2/point/{lat}/{lon}/{radius_nm} — CORS *, no auth +// Rate limit: ~1 req/5s — must query sequentially with delay + +const LIVE_QUERIES = [ + // ── 이란 ── + { lat: 35.5, lon: 51.5, radius: 250 }, // 0: 테헤란 / 북부 이란 + { lat: 30, lon: 52, radius: 250 }, // 1: 이란 남부 / 시라즈 / 부셰르 + { lat: 33, lon: 57, radius: 250 }, // 2: 이란 동부 / 이스파한 → 마슈하드 + // ── 이라크 / 시리아 ── + { lat: 33.5, lon: 44, radius: 250 }, // 3: 바그다드 / 이라크 중부 + // ── 이스라엘 / 동지중해 ── + { lat: 33, lon: 36, radius: 250 }, // 4: 레바논 / 이스라엘 / 시리아 + // ── 터키 남동부 ── + { lat: 38, lon: 40, radius: 250 }, // 5: 터키 SE / 인시를릭 AB + // ── 걸프 / UAE ── + { lat: 25, lon: 55, radius: 250 }, // 6: UAE / 오만 / 호르무즈 해협 + // ── 사우디 ── + { lat: 26, lon: 44, radius: 250 }, // 7: 사우디 중부 / 리야드 + // ── 예멘 / 홍해 ── + { lat: 16, lon: 44, radius: 250 }, // 8: 예멘 / 아덴만 + // ── 아라비아해 ── + { lat: 22, lon: 62, radius: 250 }, // 9: 아라비아해 / 파키스탄 연안 +]; + +// Accumulated aircraft cache — keeps all regions, refreshed per-region +const liveCache = new Map(); +const CACHE_TTL = 60_000; // 60s per region cache +let initialLoadDone = false; +let queryIndex = 0; +let initialLoadPromise: Promise | null = null; + +function delay(ms: number) { + return new Promise(r => setTimeout(r, ms)); +} + +async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise { + const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`; + const res = await fetch(url); + if (res.status === 429) { + // Rate limited — back off and return empty + console.warn('Airplanes.live rate limited (429), backing off'); + await delay(5000); + return []; + } + if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); + const data = await res.json(); + return parseAirplanesLive(data); +} + +// Non-blocking initial load: fetch regions in background, return partial results immediately +async function doInitialLoad(): Promise { + console.log('Airplanes.live: initial load — fetching 10 regions in background...'); + for (let i = 0; i < LIVE_QUERIES.length; i++) { + try { + if (i > 0) await delay(1500); + const ac = await fetchOneRegion(LIVE_QUERIES[i]); + liveCache.set(`${i}`, { ac, ts: Date.now() }); + console.log(` Region ${i}: ${ac.length} aircraft`); + } catch (err) { + console.warn(` Region ${i} failed:`, err); + } + } + initialLoadDone = true; + initialLoadPromise = null; +} + +export async function fetchAllAircraftLive(): Promise { + const now = Date.now(); + + if (!initialLoadDone) { + // Start background load if not started yet + if (!initialLoadPromise) { + initialLoadPromise = doInitialLoad(); + } + // Don't block — return whatever we have so far + } else { + // ── 이후: 2개 지역씩 순환 갱신 (더 가볍게) ── + const toFetch: { idx: number; q: typeof LIVE_QUERIES[0] }[] = []; + + for (let i = 0; i < 2; i++) { + const idx = (queryIndex + i) % LIVE_QUERIES.length; + const cached = liveCache.get(`${idx}`); + if (!cached || now - cached.ts > CACHE_TTL) { + toFetch.push({ idx, q: LIVE_QUERIES[idx] }); + } + } + queryIndex = (queryIndex + 2) % LIVE_QUERIES.length; + + for (let i = 0; i < toFetch.length; i++) { + try { + if (i > 0) await delay(1200); + const ac = await fetchOneRegion(toFetch[i].q); + liveCache.set(`${toFetch[i].idx}`, { ac, ts: Date.now() }); + } catch (err) { + console.warn(`Region ${toFetch[i].idx} fetch failed:`, err); + } + } + } + + // Merge all cached regions, deduplicate by icao24 + const seen = new Set(); + const merged: Aircraft[] = []; + for (const { ac } of liveCache.values()) { + for (const a of ac) { + if (!seen.has(a.icao24)) { + seen.add(a.icao24); + merged.push(a); + } + } + } + return merged; +} + +export async function fetchByCallsign(callsign: string): Promise { + try { + const url = `${ADSBX_BASE}/callsign/${callsign}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); + const data = await res.json(); + return parseAirplanesLive(data); + } catch { + return []; + } +} + +export async function fetchByIcao(hex: string): Promise { + try { + const url = `${ADSBX_BASE}/hex/${hex}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); + const data = await res.json(); + return parseAirplanesLive(data); + } catch { + return []; + } +} diff --git a/frontend/src/services/airports.ts b/frontend/src/services/airports.ts new file mode 100644 index 0000000..f52a211 --- /dev/null +++ b/frontend/src/services/airports.ts @@ -0,0 +1,38 @@ +// ═══ Korean Airports Data ═══ +// International + Domestic merged into single markers + +export interface KoreanAirport { + id: string; // IATA code + icao: string; + name: string; + nameKo: string; + lat: number; + lng: number; + type: 'international' | 'domestic' | 'military'; + intl: boolean; // has international flights + domestic: boolean; // has domestic flights +} + +export const KOREAN_AIRPORTS: KoreanAirport[] = [ + // ═══ 주요 국제공항 ═══ + { id: 'ICN', icao: 'RKSI', name: 'Incheon Intl', nameKo: '인천국제공항', lat: 37.4602, lng: 126.4407, type: 'international', intl: true, domestic: true }, + { id: 'GMP', icao: 'RKSS', name: 'Gimpo Intl', nameKo: '김포국제공항', lat: 37.5583, lng: 126.7906, type: 'international', intl: true, domestic: true }, + { id: 'PUS', icao: 'RKPK', name: 'Gimhae Intl', nameKo: '김해국제공항', lat: 35.1795, lng: 128.9382, type: 'international', intl: true, domestic: true }, + { id: 'CJU', icao: 'RKPC', name: 'Jeju Intl', nameKo: '제주국제공항', lat: 33.5113, lng: 126.4929, type: 'international', intl: true, domestic: true }, + { id: 'TAE', icao: 'RKTN', name: 'Daegu Intl', nameKo: '대구국제공항', lat: 35.8941, lng: 128.6589, type: 'international', intl: true, domestic: true }, + { id: 'CJJ', icao: 'RKTU', name: 'Cheongju Intl', nameKo: '청주국제공항', lat: 36.7166, lng: 127.4991, type: 'international', intl: true, domestic: true }, + { id: 'MWX', icao: 'RKJB', name: 'Muan Intl', nameKo: '무안국제공항', lat: 34.9914, lng: 126.3828, type: 'international', intl: true, domestic: true }, + + // ═══ 국내선 공항 ═══ + { id: 'KWJ', icao: 'RKJJ', name: 'Gwangju', nameKo: '광주공항', lat: 35.1264, lng: 126.8089, type: 'domestic', intl: false, domestic: true }, + { id: 'RSU', icao: 'RKJY', name: 'Yeosu', nameKo: '여수공항', lat: 34.8423, lng: 127.6170, type: 'domestic', intl: false, domestic: true }, + { id: 'USN', icao: 'RKPU', name: 'Ulsan', nameKo: '울산공항', lat: 35.5935, lng: 129.3519, type: 'domestic', intl: false, domestic: true }, + { id: 'KPO', icao: 'RKTH', name: 'Pohang', nameKo: '포항공항', lat: 35.9878, lng: 129.4205, type: 'domestic', intl: false, domestic: true }, + { id: 'HIN', icao: 'RKPS', name: 'Sacheon', nameKo: '사천공항', lat: 35.0886, lng: 128.0702, type: 'domestic', intl: false, domestic: true }, + { id: 'WJU', icao: 'RKNW', name: 'Wonju', nameKo: '원주공항', lat: 37.4381, lng: 127.9604, type: 'domestic', intl: false, domestic: true }, + { id: 'KUV', icao: 'RKJK', name: 'Gunsan', nameKo: '군산공항', lat: 35.9038, lng: 126.6158, type: 'domestic', intl: false, domestic: true }, + { id: 'YNY', icao: 'RKNY', name: 'Yangyang Intl', nameKo: '양양국제공항', lat: 38.0613, lng: 128.6690, type: 'international', intl: true, domestic: true }, + + // ═══ 도서 공항 ═══ + { id: 'JDG', icao: 'RKPD', name: 'Jeongseok (Ulleungdo)', nameKo: '울릉공항', lat: 37.5200, lng: 130.8980, type: 'domestic', intl: false, domestic: true }, +]; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..d4b3048 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,58 @@ +import type { GeoEvent, SensorLog, ApiConfig } from '../types'; +import { sampleEvents, generateSensorData } from '../data/sampleData'; + +const defaultConfig: ApiConfig = { + eventsEndpoint: '/api/events', + sensorEndpoint: '/api/sensors', + pollIntervalMs: 30_000, +}; + +let cachedSensorData: SensorLog[] | null = null; + +export async function fetchEvents(_config?: Partial): Promise { // eslint-disable-line @typescript-eslint/no-unused-vars + // In production, replace with actual API call: + // const res = await fetch(config.eventsEndpoint); + // return res.json(); + return Promise.resolve(sampleEvents); +} + +export async function fetchSensorData(_config?: Partial): Promise { // eslint-disable-line @typescript-eslint/no-unused-vars + // In production, replace with actual API call: + // const res = await fetch(config.sensorEndpoint); + // return res.json(); + if (!cachedSensorData) { + cachedSensorData = generateSensorData(); + } + return Promise.resolve(cachedSensorData); +} + +export function createPollingService( + onEvents: (events: GeoEvent[]) => void, + onSensors: (data: SensorLog[]) => void, + config: Partial = {}, +) { + const merged = { ...defaultConfig, ...config }; + let intervalId: number | null = null; + + const poll = async () => { + const [events, sensors] = await Promise.all([ + fetchEvents(merged), + fetchSensorData(merged), + ]); + onEvents(events); + onSensors(sensors); + }; + + return { + start: () => { + poll(); + intervalId = window.setInterval(poll, merged.pollIntervalMs); + }, + stop: () => { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + }, + }; +} diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts new file mode 100644 index 0000000..a64c545 --- /dev/null +++ b/frontend/src/services/authApi.ts @@ -0,0 +1,41 @@ +const AUTH_BASE = '/api/kcg/auth'; + +export interface AuthUser { + email: string; + name: string; + picture?: string; +} + +export async function googleLogin(credential: string): Promise { + const res = await fetch(`${AUTH_BASE}/google`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? 'Login failed'); + } + + return res.json(); +} + +export async function getMe(): Promise { + const res = await fetch(`${AUTH_BASE}/me`, { + credentials: 'include', + }); + + if (!res.ok) { + throw new Error('Not authenticated'); + } + + return res.json(); +} + +export async function logout(): Promise { + await fetch(`${AUTH_BASE}/logout`, { + method: 'POST', + credentials: 'include', + }); +} diff --git a/frontend/src/services/cctv.ts b/frontend/src/services/cctv.ts new file mode 100644 index 0000000..7bb3d1e --- /dev/null +++ b/frontend/src/services/cctv.ts @@ -0,0 +1,43 @@ +// ═══ Korean Coastal CCTV Camera Data ═══ +// Source: 국립해양조사원 KHOA TAGO + +export interface CctvCamera { + id: number; + name: string; + region: '제주' | '남해' | '서해' | '동해'; + lat: number; + lng: number; + type: 'tide' | 'fog'; + url: string; + streamUrl: string; + source: 'KHOA'; +} + +/** KHOA HLS 스트림 */ +const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; +function khoa(site: string) { return `${KHOA_HLS}/${site}/s.m3u8`; } + +export const KOREA_CCTV_CAMERAS: CctvCamera[] = [ + // ═══ 서해 (West Sea) ═══ + { id: 1, name: '인천항 조위관측소', region: '서해', lat: 37.450382, lng: 126.593028, type: 'tide', url: 'https://www.badatime.com/cctv/29', streamUrl: khoa('Incheon'), source: 'KHOA' }, + { id: 2, name: '인천항 해무관측', region: '서해', lat: 37.379626, lng: 126.615917, type: 'fog', url: 'https://www.badatime.com/cctv/30', streamUrl: khoa('SeaFog_Incheon'), source: 'KHOA' }, + { id: 3, name: '대산항 해무관측', region: '서해', lat: 36.978758, lng: 126.304908, type: 'fog', url: 'https://www.badatime.com/cctv/31', streamUrl: khoa('SeaFog_Daesan'), source: 'KHOA' }, + { id: 4, name: '평택당진항 해무관측', region: '서해', lat: 37.113185, lng: 126.39375, type: 'fog', url: 'https://www.badatime.com/cctv/32', streamUrl: khoa('SeaFog_PTDJ'), source: 'KHOA' }, + + // ═══ 남해 (South Sea) ═══ + { id: 5, name: '목포항 해무관측', region: '남해', lat: 34.751831, lng: 126.310232, type: 'fog', url: 'https://www.badatime.com/cctv/35', streamUrl: khoa('SeaFog_Mokpo'), source: 'KHOA' }, + { id: 6, name: '진도항 조위관측소', region: '남해', lat: 34.379541, lng: 126.307928, type: 'tide', url: 'https://www.badatime.com/cctv/36', streamUrl: khoa('Jindo'), source: 'KHOA' }, + { id: 7, name: '여수항 해무관측', region: '남해', lat: 34.75386, lng: 127.752881, type: 'fog', url: 'https://www.badatime.com/cctv/37', streamUrl: khoa('SeaFog_Yeosu'), source: 'KHOA' }, + { id: 8, name: '여수항 조위관측소', region: '남해', lat: 34.747005, lng: 127.766053, type: 'tide', url: 'https://www.badatime.com/cctv/38', streamUrl: khoa('Yeosu'), source: 'KHOA' }, + { id: 9, name: '부산항 조위관측소', region: '남해', lat: 35.096329, lng: 129.034726, type: 'tide', url: 'https://www.badatime.com/cctv/39', streamUrl: khoa('Busan'), source: 'KHOA' }, + { id: 10, name: '부산항 해무관측', region: '남해', lat: 35.077924, lng: 129.081469, type: 'fog', url: 'https://www.badatime.com/cctv/40', streamUrl: khoa('SeaFog_Busan'), source: 'KHOA' }, + { id: 11, name: '해운대 해무관측', region: '남해', lat: 35.158148, lng: 129.158529, type: 'fog', url: 'https://www.badatime.com/cctv/41', streamUrl: khoa('SeaFog_Haeundae'), source: 'KHOA' }, + + // ═══ 동해 (East Sea) ═══ + { id: 12, name: '울산항 해무관측', region: '동해', lat: 35.501902, lng: 129.387139, type: 'fog', url: 'https://www.badatime.com/cctv/42', streamUrl: khoa('SeaFog_Ulsan'), source: 'KHOA' }, + { id: 13, name: '포항항 해무관측', region: '동해', lat: 36.051325, lng: 129.378492, type: 'fog', url: 'https://www.badatime.com/cctv/43', streamUrl: khoa('SeaFog_Pohang'), source: 'KHOA' }, + { id: 14, name: '묵호항 조위관측소', region: '동해', lat: 37.550385, lng: 129.116396, type: 'tide', url: 'https://www.badatime.com/cctv/44', streamUrl: khoa('Mukho'), source: 'KHOA' }, + + // ═══ 제주 (Jeju) ═══ + { id: 15, name: '모슬포항 조위관측소', region: '제주', lat: 33.213884, lng: 126.25051, type: 'tide', url: 'https://www.badatime.com/cctv/45', streamUrl: khoa('Moseulpo'), source: 'KHOA' }, +]; diff --git a/frontend/src/services/celestrak.ts b/frontend/src/services/celestrak.ts new file mode 100644 index 0000000..0d9b6ce --- /dev/null +++ b/frontend/src/services/celestrak.ts @@ -0,0 +1,321 @@ +import * as satellite from 'satellite.js'; +import type { Satellite, SatellitePosition } from '../types'; + +// CelesTrak TLE groups to fetch — relevant to Middle East theater +const CELESTRAK_GROUPS: { group: string; category: Satellite['category'] }[] = [ + { group: 'military', category: 'reconnaissance' }, + { group: 'gps-ops', category: 'navigation' }, + { group: 'geo', category: 'communications' }, + { group: 'weather', category: 'weather' }, + { group: 'stations', category: 'other' }, +]; + +// Category override by satellite name keywords +function refineSatCategory(name: string, defaultCat: Satellite['category']): Satellite['category'] { + const n = name.toUpperCase(); + if (n.includes('SBIRS') || n.includes('NROL') || n.includes('USA') || n.includes('KEYHOLE') || n.includes('LACROSSE')) return 'reconnaissance'; + if (n.includes('WGS') || n.includes('AEHF') || n.includes('MUOS') || n.includes('STARLINK') || n.includes('MILSTAR')) return 'communications'; + if (n.includes('GPS') || n.includes('NAVSTAR') || n.includes('GALILEO') || n.includes('BEIDOU') || n.includes('GLONASS')) return 'navigation'; + if (n.includes('GOES') || n.includes('METOP') || n.includes('NOAA') || n.includes('METEOR') || n.includes('DMSP')) return 'weather'; + if (n.includes('ISS')) return 'other'; + return defaultCat; +} + +// Parse 3-line TLE format (name + line1 + line2) +function parseTLE(text: string, defaultCategory: Satellite['category']): Satellite[] { + const lines = text.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0); + const sats: Satellite[] = []; + + for (let i = 0; i < lines.length - 2; i++) { + // TLE line 1 starts with "1 ", line 2 starts with "2 " + if (lines[i + 1].startsWith('1 ') && lines[i + 2].startsWith('2 ')) { + const name = lines[i]; + const tle1 = lines[i + 1]; + const tle2 = lines[i + 2]; + + // Extract NORAD catalog number from line 1 (columns 3-7) + const noradId = parseInt(tle1.substring(2, 7).trim(), 10); + if (isNaN(noradId)) continue; + + sats.push({ + noradId, + name, + tle1, + tle2, + category: refineSatCategory(name, defaultCategory), + }); + + i += 2; // skip the 2 TLE lines + } + } + + return sats; +} + +// Middle East bounding box for filtering LEO satellites +// Only keep satellites whose ground track passes near the region (lat 15-45, lon 25-65) +function isNearMiddleEast(sat: Satellite): boolean { + try { + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + const now = new Date(); + // Check current position and ±45min positions + for (const offsetMin of [0, -45, 45, -90, 90]) { + const t = new Date(now.getTime() + offsetMin * 60_000); + const pv = satellite.propagate(satrec, t); + if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; + const gmst = satellite.gstime(t); + const geo = satellite.eciToGeodetic(pv.position, gmst); + const lat = satellite.degreesLat(geo.latitude); + const lng = satellite.degreesLong(geo.longitude); + // Generous bounding: lat -5 to 55, lon 15 to 75 + if (lat >= -5 && lat <= 55 && lng >= 15 && lng <= 75) return true; + } + return false; + } catch { + return false; + } +} + +// Satellite cache — avoid re-fetching within 10 minutes +let satCache: { sats: Satellite[]; ts: number } | null = null; +const SAT_CACHE_TTL = 10 * 60_000; + +export async function fetchSatelliteTLE(): Promise { + // Return cache if fresh + if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { + return satCache.sats; + } + + const allSats: Satellite[] = []; + const seenIds = new Set(); + + // Fetch TLE groups from CelesTrak sequentially (avoid hammering) + for (const { group, category } of CELESTRAK_GROUPS) { + try { + const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; + const res = await fetch(url); + if (!res.ok) { + console.warn(`CelesTrak ${group}: ${res.status}`); + continue; + } + const text = await res.text(); + const parsed = parseTLE(text, category); + for (const sat of parsed) { + if (!seenIds.has(sat.noradId)) { + seenIds.add(sat.noradId); + allSats.push(sat); + } + } + } catch (err) { + console.warn(`CelesTrak ${group} fetch failed:`, err); + } + } + + if (allSats.length === 0) { + console.warn('CelesTrak: no data fetched, using fallback'); + return FALLBACK_SATELLITES; + } + + // For GEO/MEO sats keep all, for LEO filter to Middle East region + const filtered: Satellite[] = []; + for (const sat of allSats) { + try { + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + const pv = satellite.propagate(satrec, new Date()); + if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; + const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); + const altKm = geo.height; + + // GEO (>30000km) and MEO (>5000km): always include (they cover wide areas) + if (altKm > 5000) { + filtered.push(sat); + } else { + // LEO: only keep if passes near Middle East + if (isNearMiddleEast(sat)) { + filtered.push(sat); + } + } + } catch { + // skip bad TLE + } + } + + // Cap at ~100 satellites to keep rendering performant + const capped = filtered.slice(0, 100); + + satCache = { sats: capped, ts: Date.now() }; + console.log(`CelesTrak: loaded ${capped.length} satellites (from ${allSats.length} total)`); + return capped; +} + +// ═══ Korea region satellite fetch ═══ +function isNearKorea(sat: Satellite): boolean { + try { + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + const now = new Date(); + for (const offsetMin of [0, -45, 45, -90, 90]) { + const t = new Date(now.getTime() + offsetMin * 60_000); + const pv = satellite.propagate(satrec, t); + if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; + const gmst = satellite.gstime(t); + const geo = satellite.eciToGeodetic(pv.position, gmst); + const lat = satellite.degreesLat(geo.latitude); + const lng = satellite.degreesLong(geo.longitude); + if (lat >= 10 && lat <= 50 && lng >= 110 && lng <= 150) return true; + } + return false; + } catch { + return false; + } +} + +let satCacheKorea: { sats: Satellite[]; ts: number } | null = null; + +export async function fetchSatelliteTLEKorea(): Promise { + if (satCacheKorea && Date.now() - satCacheKorea.ts < SAT_CACHE_TTL) { + return satCacheKorea.sats; + } + + const allSats: Satellite[] = []; + const seenIds = new Set(); + + for (const { group, category } of CELESTRAK_GROUPS) { + try { + const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; + const res = await fetch(url); + if (!res.ok) continue; + const text = await res.text(); + const parsed = parseTLE(text, category); + for (const sat of parsed) { + if (!seenIds.has(sat.noradId)) { + seenIds.add(sat.noradId); + allSats.push(sat); + } + } + } catch { /* skip */ } + } + + if (allSats.length === 0) return FALLBACK_SATELLITES; + + const filtered: Satellite[] = []; + for (const sat of allSats) { + try { + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + const pv = satellite.propagate(satrec, new Date()); + if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; + const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); + const altKm = geo.height; + if (altKm > 5000) { + filtered.push(sat); + } else { + if (isNearKorea(sat)) filtered.push(sat); + } + } catch { /* skip */ } + } + + const capped = filtered.slice(0, 100); + satCacheKorea = { sats: capped, ts: Date.now() }; + console.log(`CelesTrak Korea: loaded ${capped.length} satellites`); + return capped; +} + +// Fallback satellites if CelesTrak is unreachable +const FALLBACK_SATELLITES: Satellite[] = [ + { noradId: 25544, name: 'ISS (ZARYA)', category: 'other', + tle1: '1 25544U 98067A 26060.50000000 .00016717 00000-0 10270-3 0 9999', + tle2: '2 25544 51.6400 210.0000 0005000 350.0000 10.0000 15.49000000 10000' }, + { noradId: 37481, name: 'SBIRS GEO-1', category: 'reconnaissance', + tle1: '1 37481U 11019A 26060.50000000 .00000010 00000-0 00000-0 0 9999', + tle2: '2 37481 3.5000 60.0000 0003000 90.0000 270.0000 1.00270000 10000' }, + { noradId: 44478, name: 'WGS-10', category: 'communications', + tle1: '1 44478U 19060A 26060.50000000 .00000010 00000-0 00000-0 0 9999', + tle2: '2 44478 0.1000 60.0000 0002000 270.0000 90.0000 1.00270000 10000' }, + { noradId: 55268, name: 'GPS III-06', category: 'navigation', + tle1: '1 55268U 23008A 26060.50000000 .00000010 00000-0 00000-0 0 9999', + tle2: '2 55268 55.0000 60.0000 0050000 100.0000 260.0000 2.00600000 10000' }, + { noradId: 43689, name: 'MetOp-C', category: 'weather', + tle1: '1 43689U 18096A 26060.50000000 .00000400 00000-0 20000-3 0 9999', + tle2: '2 43689 98.7000 110.0000 0002000 90.0000 270.0000 14.21000000 10000' }, +]; + +// Cache satrec objects (expensive to create) +const satrecCache = new Map>(); + +function getSatrec(sat: Satellite) { + let rec = satrecCache.get(sat.noradId); + if (!rec) { + rec = satellite.twoline2satrec(sat.tle1, sat.tle2); + satrecCache.set(sat.noradId, rec); + } + return rec; +} + +// Cache ground tracks — only recompute every 60s +const trackCache = new Map(); +const TRACK_CACHE_MS = 60_000; + +export function propagateSatellite( + sat: Satellite, + time: Date, + trackMinutes: number = 90, +): SatellitePosition | null { + try { + const satrec = getSatrec(sat); + const posVel = satellite.propagate(satrec, time); + if (!posVel || typeof posVel.position === 'boolean' || !posVel.position) return null; + + const pos = posVel.position; + const gmst = satellite.gstime(time); + const geo = satellite.eciToGeodetic(pos, gmst); + + const lat = satellite.degreesLat(geo.latitude); + const lng = satellite.degreesLong(geo.longitude); + const altitude = geo.height; + + // Ground track — use cache if fresh enough + const cached = trackCache.get(sat.noradId); + let groundTrack: [number, number][]; + + if (cached && Math.abs(cached.time - time.getTime()) < TRACK_CACHE_MS) { + groundTrack = cached.track; + } else { + groundTrack = []; + const steps = 20; // reduced from 60 + const stepMs = (trackMinutes * 60 * 1000) / steps; + + for (let i = -steps / 2; i <= steps / 2; i++) { + const t = new Date(time.getTime() + i * stepMs); + const pv = satellite.propagate(satrec, t); + if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; + const g = satellite.gstime(t); + const gd = satellite.eciToGeodetic(pv.position, g); + groundTrack.push([ + satellite.degreesLat(gd.latitude), + satellite.degreesLong(gd.longitude), + ]); + } + trackCache.set(sat.noradId, { time: time.getTime(), track: groundTrack }); + } + + return { + noradId: sat.noradId, + name: sat.name, + lat, + lng, + altitude, + category: sat.category, + groundTrack, + }; + } catch { + return null; + } +} + +export function propagateAll( + satellites: Satellite[], + time: Date, +): SatellitePosition[] { + return satellites + .map(s => propagateSatellite(s, time)) + .filter((p): p is SatellitePosition => p !== null); +} diff --git a/frontend/src/services/coastGuard.ts b/frontend/src/services/coastGuard.ts new file mode 100644 index 0000000..50a318f --- /dev/null +++ b/frontend/src/services/coastGuard.ts @@ -0,0 +1,82 @@ +// ═══ 대한민국 해양경찰청 시설 위치 ═══ +// Korea Coast Guard (KCG) facilities + +export type CoastGuardType = 'hq' | 'regional' | 'station' | 'substation' | 'vts'; + +export interface CoastGuardFacility { + id: number; + name: string; + type: CoastGuardType; + lat: number; + lng: number; +} + +const TYPE_LABEL: Record = { + hq: '본청', + regional: '지방청', + station: '해양경찰서', + substation: '파출소', + vts: 'VTS센터', +}; + +export { TYPE_LABEL as CG_TYPE_LABEL }; + +export const COAST_GUARD_FACILITIES: CoastGuardFacility[] = [ + // ═══ 본청 ═══ + { id: 1, name: '해양경찰청 본청', type: 'hq', lat: 36.9870, lng: 126.9300 }, + + // ═══ 지방해양경찰청 ═══ + { id: 10, name: '중부지방해양경찰청', type: 'regional', lat: 37.4563, lng: 126.5958 }, + { id: 11, name: '서해지방해양경찰청', type: 'regional', lat: 34.8118, lng: 126.3922 }, + { id: 12, name: '남해지방해양경찰청', type: 'regional', lat: 34.7436, lng: 127.7370 }, + { id: 13, name: '동해지방해양경찰청', type: 'regional', lat: 37.7695, lng: 128.8760 }, + { id: 14, name: '제주지방해양경찰청', type: 'regional', lat: 33.5170, lng: 126.5310 }, + + // ═══ 해양경찰서 ═══ + { id: 20, name: '인천해양경찰서', type: 'station', lat: 37.4500, lng: 126.6100 }, + { id: 21, name: '평택해양경찰서', type: 'station', lat: 36.9694, lng: 126.8319 }, + { id: 22, name: '태안해양경찰서', type: 'station', lat: 36.7456, lng: 126.2978 }, + { id: 23, name: '보령해양경찰서', type: 'station', lat: 36.3500, lng: 126.5880 }, + { id: 24, name: '군산해양경찰서', type: 'station', lat: 35.9750, lng: 126.6530 }, + { id: 25, name: '목포해양경찰서', type: 'station', lat: 34.7930, lng: 126.3840 }, + { id: 26, name: '완도해양경찰서', type: 'station', lat: 34.3110, lng: 126.7550 }, + { id: 27, name: '여수해양경찰서', type: 'station', lat: 34.7440, lng: 127.7360 }, + { id: 28, name: '통영해양경찰서', type: 'station', lat: 34.8540, lng: 128.4330 }, + { id: 29, name: '창원해양경찰서', type: 'station', lat: 35.0800, lng: 128.5970 }, + { id: 30, name: '부산해양경찰서', type: 'station', lat: 35.1028, lng: 129.0360 }, + { id: 31, name: '울산해양경찰서', type: 'station', lat: 35.5067, lng: 129.3850 }, + { id: 32, name: '포항해양경찰서', type: 'station', lat: 36.0320, lng: 129.3650 }, + { id: 33, name: '동해해양경찰서', type: 'station', lat: 37.5250, lng: 129.1140 }, + { id: 34, name: '속초해양경찰서', type: 'station', lat: 38.2040, lng: 128.5910 }, + { id: 35, name: '제주해양경찰서', type: 'station', lat: 33.5200, lng: 126.5250 }, + { id: 36, name: '서귀포해양경찰서', type: 'station', lat: 33.2400, lng: 126.5620 }, + + // ═══ 주요 파출소 ═══ + { id: 50, name: '옹진해양경찰파출소', type: 'substation', lat: 37.0333, lng: 125.6833 }, + { id: 51, name: '연평해양경찰파출소', type: 'substation', lat: 37.6660, lng: 125.7000 }, + { id: 52, name: '백령해양경찰파출소', type: 'substation', lat: 37.9670, lng: 124.7170 }, + { id: 53, name: '덕적해양경찰파출소', type: 'substation', lat: 37.2320, lng: 126.1450 }, + { id: 54, name: '흑산해양경찰파출소', type: 'substation', lat: 34.6840, lng: 125.4350 }, + { id: 55, name: '거문해양경찰파출소', type: 'substation', lat: 34.0290, lng: 127.3080 }, + { id: 56, name: '추자해양경찰파출소', type: 'substation', lat: 33.9540, lng: 126.2960 }, + { id: 57, name: '울릉해양경찰파출소', type: 'substation', lat: 37.4840, lng: 130.9060 }, + { id: 58, name: '독도해양경찰파출소', type: 'substation', lat: 37.2426, lng: 131.8647 }, + { id: 59, name: '마라도해양경찰파출소', type: 'substation', lat: 33.1140, lng: 126.2670 }, + + // ═══ VTS (Vessel Traffic Service) 센터 ═══ + { id: 100, name: '인천VTS', type: 'vts', lat: 37.4480, lng: 126.6020 }, + { id: 101, name: '평택VTS', type: 'vts', lat: 36.9600, lng: 126.8220 }, + { id: 102, name: '대산VTS', type: 'vts', lat: 36.9850, lng: 126.3530 }, + { id: 103, name: '군산VTS', type: 'vts', lat: 35.9880, lng: 126.5800 }, + { id: 104, name: '목포VTS', type: 'vts', lat: 34.7850, lng: 126.3780 }, + { id: 105, name: '완도VTS', type: 'vts', lat: 34.3250, lng: 126.7540 }, + { id: 106, name: '여수VTS', type: 'vts', lat: 34.7480, lng: 127.7420 }, + { id: 107, name: '통영VTS', type: 'vts', lat: 34.8500, lng: 128.4280 }, + { id: 108, name: '마산VTS', type: 'vts', lat: 35.0720, lng: 128.5780 }, + { id: 109, name: '부산VTS', type: 'vts', lat: 35.0750, lng: 129.0780 }, + { id: 110, name: '울산VTS', type: 'vts', lat: 35.5100, lng: 129.3750 }, + { id: 111, name: '포항VTS', type: 'vts', lat: 36.0450, lng: 129.3800 }, + { id: 112, name: '동해VTS', type: 'vts', lat: 37.5300, lng: 129.1200 }, + { id: 113, name: '속초VTS', type: 'vts', lat: 38.2100, lng: 128.5930 }, + { id: 114, name: '제주VTS', type: 'vts', lat: 33.5150, lng: 126.5400 }, +]; diff --git a/frontend/src/services/infra.ts b/frontend/src/services/infra.ts new file mode 100644 index 0000000..6da037b --- /dev/null +++ b/frontend/src/services/infra.ts @@ -0,0 +1,135 @@ +// ═══ Korean Power Infrastructure from OpenStreetMap (Overpass API) ═══ + +export interface PowerFacility { + id: string; + type: 'plant' | 'substation'; + name: string; + lat: number; + lng: number; + source?: string; // solar, nuclear, gas, coal, wind, hydro, oil + output?: string; // e.g. "1000 MW" + operator?: string; + voltage?: string; // for substations +} + +// Overpass QL: power plants + wind generators + substations in South Korea +const OVERPASS_QUERY = ` +[out:json][timeout:30][bbox:33,124,39,132]; +( + nwr["power"="plant"]; + nwr["power"="generator"]["generator:source"="wind"]; + nwr["power"="substation"]["substation"="transmission"]; +); +out center 500; +`; + +let cachedData: PowerFacility[] | null = null; +let lastFetch = 0; +const CACHE_MS = 600_000; // 10 min cache + +export async function fetchKoreaInfra(): Promise { + if (cachedData && Date.now() - lastFetch < CACHE_MS) return cachedData; + + try { + const url = `/api/overpass/api/interpreter`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(OVERPASS_QUERY)}`, + }); + + if (!res.ok) throw new Error(`Overpass ${res.status}`); + const json = await res.json(); + + const facilities: PowerFacility[] = []; + + for (const el of json.elements || []) { + const tags = el.tags || {}; + const lat = el.lat ?? el.center?.lat; + const lng = el.lon ?? el.center?.lon; + if (lat == null || lng == null) continue; + + const isPower = tags.power; + if (isPower === 'plant') { + facilities.push({ + id: `plant-${el.id}`, + type: 'plant', + name: tags.name || tags['name:ko'] || tags['name:en'] || 'Power Plant', + lat, lng, + source: tags['plant:source'] || tags['generator:source'] || undefined, + output: tags['plant:output:electricity'] || undefined, + operator: tags.operator || undefined, + }); + } else if (isPower === 'generator' && tags['generator:source'] === 'wind') { + facilities.push({ + id: `wind-${el.id}`, + type: 'plant', + name: tags.name || tags['name:ko'] || tags['name:en'] || '풍력발전기', + lat, lng, + source: 'wind', + output: tags['generator:output:electricity'] || undefined, + operator: tags.operator || undefined, + }); + } else if (isPower === 'substation') { + facilities.push({ + id: `sub-${el.id}`, + type: 'substation', + name: tags.name || tags['name:ko'] || tags['name:en'] || 'Substation', + lat, lng, + voltage: tags.voltage || undefined, + operator: tags.operator || undefined, + }); + } + } + + console.log(`Overpass: ${facilities.length} power facilities in Korea (${facilities.filter(f => f.type === 'plant').length} plants, ${facilities.filter(f => f.type === 'substation').length} substations)`); + cachedData = facilities; + lastFetch = Date.now(); + return facilities; + } catch (err) { + console.warn('Overpass API failed, using fallback data:', err); + if (cachedData) return cachedData; + return getFallbackInfra(); + } +} + +// Fallback: major Korean power plants (in case API fails) +function getFallbackInfra(): PowerFacility[] { + return [ + // Nuclear + { id: 'p-kori', type: 'plant', name: '고리 원전', lat: 35.3197, lng: 129.2783, source: 'nuclear', output: '6040 MW', operator: '한국수력원자력' }, + { id: 'p-shin-kori', type: 'plant', name: '신고리 원전', lat: 35.3220, lng: 129.2900, source: 'nuclear', output: '5600 MW', operator: '한국수력원자력' }, + { id: 'p-hanul', type: 'plant', name: '한울 원전', lat: 37.0928, lng: 129.3844, source: 'nuclear', output: '5900 MW', operator: '한국수력원자력' }, + { id: 'p-hanbit', type: 'plant', name: '한빛 원전', lat: 35.4133, lng: 126.4228, source: 'nuclear', output: '5875 MW', operator: '한국수력원자력' }, + { id: 'p-wolsong', type: 'plant', name: '월성 원전', lat: 35.7128, lng: 129.4753, source: 'nuclear', output: '2779 MW', operator: '한국수력원자력' }, + { id: 'p-shin-wolsong', type: 'plant', name: '신월성 원전', lat: 35.7100, lng: 129.4800, source: 'nuclear', output: '2000 MW', operator: '한국수력원자력' }, + // Coal/Gas + { id: 'p-dangjin', type: 'plant', name: '당진 화력', lat: 36.9703, lng: 126.6067, source: 'coal', output: '6040 MW', operator: '한국동서발전' }, + { id: 'p-taean', type: 'plant', name: '태안 화력', lat: 36.7833, lng: 126.2500, source: 'coal', output: '6100 MW', operator: '한국서부발전' }, + { id: 'p-boryeong', type: 'plant', name: '보령 화력', lat: 36.3500, lng: 126.4833, source: 'coal', output: '4000 MW', operator: '한국중부발전' }, + { id: 'p-samcheok', type: 'plant', name: '삼척 화력', lat: 37.3667, lng: 129.1500, source: 'coal', output: '2100 MW', operator: '한국남부발전' }, + { id: 'p-incheon', type: 'plant', name: '인천 LNG', lat: 37.4483, lng: 126.5917, source: 'gas', output: '3478 MW', operator: '한국중부발전' }, + { id: 'p-pyeongtaek', type: 'plant', name: '평택 LNG', lat: 36.9667, lng: 126.9333, source: 'gas', output: '1770 MW', operator: '한국중부발전' }, + { id: 'p-yeongheung', type: 'plant', name: '영흥 화력', lat: 37.2167, lng: 126.4333, source: 'coal', output: '5080 MW', operator: '한국남동발전' }, + // Hydro + { id: 'p-chungju', type: 'plant', name: '충주 수력', lat: 36.9833, lng: 127.9833, source: 'hydro', output: '412 MW', operator: '한국수력원자력' }, + { id: 'p-hapcheon', type: 'plant', name: '합천 수력', lat: 35.5667, lng: 128.1500, source: 'hydro', output: '360 MW', operator: '한국수력원자력' }, + // Wind + { id: 'p-yeongdeok', type: 'plant', name: '영덕 풍력', lat: 36.4150, lng: 129.4300, source: 'wind', output: '39.6 MW', operator: '한국남동발전' }, + { id: 'p-taebaek', type: 'plant', name: '태백 풍력', lat: 37.1500, lng: 128.9800, source: 'wind', output: '40 MW', operator: '한국동서발전' }, + { id: 'p-gasiri', type: 'plant', name: '가시리 풍력 (제주)', lat: 33.3600, lng: 126.6800, source: 'wind', output: '15 MW', operator: '제주에너지공사' }, + { id: 'p-tamra', type: 'plant', name: '탐라 해상풍력 (제주)', lat: 33.2800, lng: 126.1700, source: 'wind', output: '30 MW', operator: '탐라해상풍력' }, + { id: 'p-seonam', type: 'plant', name: '서남해 해상풍력', lat: 35.0700, lng: 126.0200, source: 'wind', output: '60 MW', operator: '한국해상풍력' }, + { id: 'p-yeongyang', type: 'plant', name: '영양 풍력', lat: 36.7000, lng: 129.1200, source: 'wind', output: '61.5 MW', operator: '한국남부발전' }, + { id: 'p-jeongseon', type: 'plant', name: '정선 풍력', lat: 37.3300, lng: 128.7200, source: 'wind', output: '30 MW', operator: '강원풍력' }, + { id: 'p-daegwallyeong', type: 'plant', name: '대관령 풍력', lat: 37.7000, lng: 128.7500, source: 'wind', output: '98 MW', operator: '대관령풍력' }, + { id: 'p-sinan', type: 'plant', name: '신안 해상풍력', lat: 34.8500, lng: 125.8800, source: 'wind', output: '8.2 GW', operator: '신안해상풍력' }, + { id: 'p-ulsan-float', type: 'plant', name: '울산 부유식 해상풍력', lat: 35.4000, lng: 129.6500, source: 'wind', output: '1.5 GW', operator: '울산부유식풍력' }, + // Major substations + { id: 's-singapyeong', type: 'substation', name: '신갑평 변전소', lat: 37.1667, lng: 127.3000, voltage: '765000', operator: 'KEPCO' }, + { id: 's-sinseosan', type: 'substation', name: '신서산 변전소', lat: 36.7500, lng: 126.5000, voltage: '765000', operator: 'KEPCO' }, + { id: 's-sinchungju', type: 'substation', name: '신충주 변전소', lat: 36.9833, lng: 127.9500, voltage: '765000', operator: 'KEPCO' }, + { id: 's-sinyongin', type: 'substation', name: '신용인 변전소', lat: 37.2333, lng: 127.2000, voltage: '765000', operator: 'KEPCO' }, + { id: 's-sinbukgyeongnam', type: 'substation', name: '신북경남 변전소', lat: 35.4500, lng: 128.7500, voltage: '765000', operator: 'KEPCO' }, + ]; +} diff --git a/frontend/src/services/koreaEez.ts b/frontend/src/services/koreaEez.ts new file mode 100644 index 0000000..a2b3383 --- /dev/null +++ b/frontend/src/services/koreaEez.ts @@ -0,0 +1,122 @@ +// ═══ 대한민국 배타적 경제수역(EEZ) 경계 좌표 ═══ +// 출처: 해양수산부, UNCLOS, 한일/한중 어업협정, NLL 기준 +// 서해(한중 잠정조치수역), 남해(한일 중간수역), 동해(한일 중간선) 포함 + +// EEZ 외곽 경계 (시계방향, [lat, lng]) +export const KOREA_EEZ_BOUNDARY: [number, number][] = [ + // ── 서해 NLL 부근 (북서단) ── + [37.75, 124.40], + [37.70, 124.10], + [37.40, 123.70], + [37.00, 123.50], + [36.60, 123.30], + [36.20, 123.20], + [35.80, 123.10], + [35.40, 123.00], + [35.00, 122.90], + + // ── 서해 남부 (한중 중간선 부근) ── + [34.60, 122.80], + [34.20, 123.00], + [33.80, 123.30], + [33.40, 123.60], + [33.00, 124.00], + + // ── 제주도 남서~남 ── + [32.60, 124.40], + [32.30, 125.00], + [32.10, 125.60], + [32.00, 126.20], + + // ── 제주도 남쪽 (한일 중간수역 북방한계) ── + [32.00, 126.80], + [32.10, 127.30], + [32.20, 127.80], + [32.40, 128.10], + + // ── 대한해협 (대마도 서쪽, 한일 중간선) ── + // 대마도(쓰시마) 서안 129.2°E → 중간선은 약 128.5°E + [32.70, 128.40], + [33.00, 128.50], + [33.50, 128.60], + [34.00, 128.80], + [34.40, 129.10], + + // ── 동해 남부 (한일 중간선) ── + [34.80, 129.40], + [35.20, 129.70], + [35.60, 130.00], + [36.00, 130.20], + [36.50, 130.40], + [37.00, 130.60], + + // ── 동해 중부~북부 (울릉도 동쪽) ── + [37.50, 130.80], + [38.00, 130.90], + [38.30, 130.80], + [38.60, 130.60], + + // ── 동해안 따라 남하 (영해 외곽) ── + [38.50, 128.80], + [38.35, 128.60], + [38.30, 128.55], + + // ── 서해 NLL 연결 (육지 경유 개념, 실제는 해상만 표시) ── + // 생략 — 폴리곤을 닫기 위해 시작점으로 복귀 + [37.75, 124.40], +]; + +// 한일 중간수역 (Joint Management Zone) — 사용하지 않음, 참고용 +export const KOREA_JAPAN_JMZ: [number, number][] = []; + +// 한중 잠정조치수역 (Provisional Measures Zone) +export const KOREA_CHINA_PMZ: [number, number][] = [ + [37.00, 123.00], + [36.75, 122.80], + [36.25, 122.60], + [35.75, 122.50], + [35.25, 122.40], + [34.50, 122.50], + [34.00, 122.80], + [33.50, 123.20], + [33.00, 123.60], + [32.50, 124.20], + [32.50, 124.80], + [33.00, 124.50], + [33.50, 124.20], + [34.00, 124.00], + [34.50, 123.80], + [35.00, 123.70], + [35.50, 123.60], + [36.00, 123.50], + [36.50, 123.40], + [37.00, 123.30], + [37.00, 123.00], +]; + +// 독도 주변 12해리 영해 (원형 근사) +export const DOKDO_TERRITORIAL: { center: [number, number]; radiusKm: number } = { + center: [37.2417, 131.8647], + radiusKm: 22.2, // 12 nautical miles +}; + +// 서해 5도 NLL (Northern Limit Line) +export const NLL_WEST_SEA: [number, number][] = [ + [37.75, 124.40], + [37.74, 124.65], + [37.72, 124.90], + [37.70, 125.10], + [37.68, 125.30], + [37.67, 125.50], + [37.67, 125.70], +]; + +// 동해 NLL +export const NLL_EAST_SEA: [number, number][] = [ + [38.60, 128.35], + [38.60, 128.60], + [38.60, 129.00], + [38.60, 129.50], + [38.60, 130.00], + [38.60, 130.60], +]; diff --git a/frontend/src/services/navWarning.ts b/frontend/src/services/navWarning.ts new file mode 100644 index 0000000..720abaa --- /dev/null +++ b/frontend/src/services/navWarning.ts @@ -0,0 +1,605 @@ +// ═══ 해상사격장 구역 / 항행경보 ═══ +// Source: 해상사격장 구역(좌표) WGS-84 (2025.10.29) +// 해군/해병대/공군/육군/해경/국방과학연구소 훈련구역 + +export type NavWarningLevel = 'danger' | 'caution' | 'info'; +export type NavWarningArea = '동해' | '서해' | '남해' | '제주' | '전해역'; +export type TrainingOrg = '해군' | '해병대' | '공군' | '육군' | '해경' | '국과연'; + +export interface NavWarning { + id: string; // R-72, R-99, etc. + title: string; + org: TrainingOrg; + area: NavWarningArea; + level: NavWarningLevel; + lat: number; // center lat for marker + lng: number; // center lng for marker + polygon: [number, number][]; // [lat, lng][] vertices + altitude: string; // 사용고도 + description: string; + source: string; +} + +const LEVEL_LABEL: Record = { + danger: '위험', + caution: '주의', + info: '정보', +}; + +export { LEVEL_LABEL as NW_LEVEL_LABEL }; + +const ORG_LABEL: Record = { + '해군': '해군 훈련구역', + '해병대': '해병대 훈련구역', + '공군': '공군 훈련구역', + '육군': '육군 훈련구역', + '해경': '해양경찰청 훈련구역', + '국과연': '국방과학연구소 훈련구역', +}; + +export { ORG_LABEL as NW_ORG_LABEL }; + +/** DMS → decimal helper (used at build time, coords below are pre-converted) */ +function dms(d: number, m: number, s: number): number { + return d + m / 60 + s / 3600; +} + +/** Compute center of polygon */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function center(pts: [number, number][]): [number, number] { + const lat = pts.reduce((s, p) => s + p[0], 0) / pts.length; + const lng = pts.reduce((s, p) => s + p[1], 0) / pts.length; + return [lat, lng]; +} + +// ═══════════════════════════════════════════════════════ +// 해상사격장 구역 데이터 (WGS-84) +// ═══════════════════════════════════════════════════════ + +export const NAV_WARNINGS: NavWarning[] = [ + // ═══════════════════════════════════════ + // 해군 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-72', title: 'R-72 대한해협 육지도남반근해', org: '해군', area: '남해', level: 'danger', + lat: 34.22, lng: 128.35, altitude: '무한대', + polygon: [ + [dms(34,9,41), dms(128,0,0)], [dms(34,18,1), dms(128,11,27)], + [dms(34,18,0), dms(128,35,0)], [dms(34,34,0), dms(128,35,14)], + [dms(34,9,13), dms(128,43,11)], [dms(34,0,0), dms(128,31,0)], + [dms(34,0,0), dms(128,0,0)], + ], + description: '해군 대한해협 육지도남반근해 사격훈련구역. 사용고도 무한대.', + source: '해군', + }, + { + id: 'R-99', title: 'R-99 대한해협 거제도남동연안', org: '해군', area: '남해', level: 'danger', + lat: 34.55, lng: 128.77, altitude: '36,000ft', + polygon: [ + [dms(34,41,4), dms(128,43,27)], [dms(34,46,6), dms(128,50,31)], + [dms(34,46,44), dms(128,53,38)], [dms(34,34,47), dms(129,3,21)], + [dms(34,19,13), dms(128,41,11)], [dms(34,20,12), dms(128,35,14)], + ], + description: '해군 대한해협 거제도남동연안 훈련구역. 사용고도 36,000ft.', + source: '해군', + }, + { + id: 'R-100', title: 'R-100 남해안 남형제도부근', org: '해군', area: '남해', level: 'danger', + lat: dms(34,53,0), lng: dms(126,37,0), altitude: '500ft', + polygon: [[dms(34,53,0), dms(126,37,0)]], // 중심점 반경 4마일 + description: '남해안 남형제도 부근 훈련구역. 반경 4마일. 사용고도 500ft.', + source: '해군', + }, + { + id: 'R-115', title: 'R-115 동해 울릉도남방근해', org: '해군', area: '동해', level: 'danger', + lat: 37.31, lng: 130.38, altitude: '38,000ft', + polygon: [ + [dms(37,24,0), dms(129,45,0)], [dms(37,13,30), dms(131,0,0)], + ], + description: '동해 울릉도남방근해 훈련구역. 사용고도 38,000ft.', + source: '해군', + }, + { + id: 'R-117', title: 'R-117 서해안 우이도북서방면', org: '해군', area: '서해', level: 'danger', + lat: dms(34,42,30), lng: dms(125,44,0), altitude: '3,000ft', + polygon: [[dms(34,42,30), dms(125,44,0)]], // 중심점 반경 5마일 + description: '서해안 우이도 북서방면 훈련구역. 반경 5마일. 사용고도 3,000ft.', + source: '해군', + }, + { + id: 'R-118', title: 'R-118 대한해협 제주도동남근해', org: '해군', area: '제주', level: 'danger', + lat: 33.72, lng: 127.53, altitude: '2,500ft', + polygon: [ + [dms(34,0,0), dms(127,40,0)], [dms(34,0,0), dms(128,30,0)], + [dms(33,10,0), dms(127,0,0)], [dms(33,10,0), dms(127,40,0)], + ], + description: '대한해협 제주도동남근해 훈련구역. 사용고도 2,500ft.', + source: '해군', + }, + { + id: 'R-119', title: 'R-119 동해 울산근해', org: '해군', area: '동해', level: 'danger', + lat: 35.61, lng: 129.93, altitude: '2,500ft', + polygon: [ + [dms(35,47,0), dms(129,40,55)], [dms(35,43,9), dms(130,12,12)], + [dms(35,37,36), dms(130,12,12)], [dms(35,27,56), dms(129,51,48)], + [dms(35,28,0), dms(129,40,55)], + ], + description: '동해 울산근해 훈련구역. 사용고도 2,500ft.', + source: '해군', + }, + { + id: 'R-120', title: 'R-120 동해 포항북동앞바다', org: '해군', area: '동해', level: 'danger', + lat: 36.32, lng: 130.38, altitude: '38,000ft', + polygon: [ + [dms(36,44,0), dms(130,25,0)], [dms(36,25,0), dms(130,55,0)], + [dms(36,17,0), dms(130,55,0)], [dms(36,2,0), dms(130,29,0)], + [dms(36,2,0), dms(130,25,0)], + ], + description: '동해 포항 북동 앞바다 훈련구역. 사용고도 38,000ft.', + source: '해군', + }, + { + id: 'R-121', title: 'R-121 동해 속초근해', org: '해군', area: '동해', level: 'danger', + lat: 38.15, lng: 129.08, altitude: '2,500ft', + polygon: [ + [dms(38,25,0), dms(128,45,0)], [dms(38,25,0), dms(129,30,0)], + [dms(38,25,0), dms(129,30,0)], [dms(38,25,0), dms(129,0,0)], + [dms(38,17,0), dms(129,0,50)], [dms(37,17,0), dms(128,45,0)], + ], + description: '동해 속초근해 훈련구역. 사용고도 2,500ft.', + source: '해군', + }, + { + id: 'R-123', title: 'R-123 관할 완도독서시근해', org: '해군', area: '서해', level: 'danger', + lat: 35.78, lng: 125.15, altitude: '3,700ft', + polygon: [ + [dms(36,0,0), dms(125,0,0)], [dms(36,0,0), dms(125,30,0)], + [dms(35,0,0), dms(125,30,0)], [dms(35,35,0), dms(125,0,0)], + ], + description: '관할 완도독서시근해 훈련구역. 사용고도 3,700ft.', + source: '해군', + }, + { + id: 'R-124', title: 'R-124 서해안 거역도부근', org: '해군', area: '서해', level: 'danger', + lat: 36.88, lng: 125.67, altitude: '2,500ft', + polygon: [ + [dms(37,6,0), dms(125,42,0)], [dms(37,6,0), dms(126,10,0)], + [dms(36,55,0), dms(125,57,0)], [dms(36,55,0), dms(125,42,0)], + ], + description: '서해안 거역도부근 훈련구역. 사용고도 2,500ft.', + source: '해군', + }, + { + id: 'R-125', title: 'R-125 서해안 흑산도남서방면', org: '해군', area: '서해', level: 'danger', + lat: dms(34,33,0), lng: dms(125,3,0), altitude: '3,500ft', + polygon: [[dms(34,33,0), dms(125,3,0)]], // 중심점 반경 6마일 + description: '서해안 흑산도 남서방면 훈련구역. 반경 6마일. 사용고도 3,500ft.', + source: '해군', + }, + { + id: 'R-126', title: 'R-126 관할 제주도북서해상', org: '해군', area: '제주', level: 'danger', + lat: 34.05, lng: 126.05, altitude: '3,000ft', + polygon: [ + [dms(34,30,0), dms(125,48,0)], [dms(34,0,0), dms(126,0,0)], + [dms(33,30,0), dms(125,48,0)], [dms(33,30,0), dms(126,10,0)], + ], + description: '관할 제주도 북서해상 훈련구역. 사용고도 3,000ft.', + source: '해군', + }, + { + id: 'R-128', title: 'R-128 대한해협 사기포근해', org: '해군', area: '남해', level: 'danger', + lat: 33.25, lng: 126.95, altitude: '7,000ft', + polygon: [ + [dms(33,0,0), dms(126,37,0)], [dms(32,40,0), dms(126,45,0)], + ], + description: '대한해협 사기포근해 훈련구역. 사용고도 7,000ft.', + source: '해군', + }, + { + id: 'R-133', title: 'R-133 서해안 초지양부근', org: '해군', area: '서해', level: 'danger', + lat: dms(37,22,20), lng: dms(126,11,35), altitude: '500ft', + polygon: [[dms(37,22,20), dms(126,11,35)]], // 중심점 반경 2마일 + description: '서해안 초지양부근 훈련구역. 반경 2마일. 사용고도 500ft.', + source: '해군', + }, + { + id: 'R-135', title: 'R-135 동해 강릉근해', org: '해군', area: '동해', level: 'danger', + lat: 37.95, lng: 129.42, altitude: '500ft', + polygon: [ + [dms(38,9,30), dms(129,4,0)], [dms(38,6,0), dms(129,37,45)], + [dms(37,33,30), dms(129,24,15)], [dms(37,37,0), dms(129,30,20)], + ], + description: '동해 강릉근해 훈련구역. 사용고도 500ft.', + source: '해군', + }, + + // ═══════════════════════════════════════ + // 해병대 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-116', title: 'R-116 서해안 대청도감우부근', org: '해병대', area: '서해', level: 'danger', + lat: dms(37,47,55), lng: dms(124,39,33), altitude: '2,500ft', + polygon: [[dms(37,47,55), dms(124,39,33)]], // 반경 4마일 + description: '해병대 서해안 대청도 감우부근 훈련구역. 반경 4마일. 사용고도 2,500ft.', + source: '해병대', + }, + { + id: 'R-131', title: 'R-131 서해안 백령도 서방면', org: '해병대', area: '서해', level: 'danger', + lat: 37.94, lng: 124.50, altitude: '5,000ft', + polygon: [ + [dms(37,59,0), dms(124,4,10)], [dms(37,59,0), dms(124,38,10)], + [dms(37,59,21), dms(124,42,30)], [dms(37,54,0), dms(124,42,30)], + [dms(37,54,0), dms(124,38,10)], [dms(37,54,0), dms(124,4,10)], + ], + description: '해병대 백령도 서방면 훈련구역. 사용고도 5,000ft.', + source: '해병대', + }, + { + id: 'R-132', title: 'R-132 서해안 백령도 남동면', org: '해병대', area: '서해', level: 'danger', + lat: 37.90, lng: 124.70, altitude: '10,000ft', + polygon: [ + [dms(37,57,0), dms(124,44,0)], [dms(37,57,0), dms(124,44,0)], + [dms(37,45,0), dms(124,50,0)], [dms(37,45,0), dms(124,47,0)], + ], + description: '해병대 백령도 남동면 훈련구역. 사용고도 10,000ft.', + source: '해병대', + }, + { + id: 'R-134', title: 'R-134 서해안 대청연평(연)부근', org: '해병대', area: '서해', level: 'danger', + lat: 37.60, lng: 125.30, altitude: '5,000ft', + polygon: [ + [dms(37,38,40), dms(124,45,0)], [dms(37,42,0), dms(125,5,0)], + [dms(37,42,0), dms(125,0,30)], [dms(37,40,0), dms(125,10,0)], + [dms(37,38,0), dms(124,45,0)], [dms(37,34,0), dms(124,14,45)], + [dms(37,40,0), dms(125,12,0)], [dms(37,41,0), dms(125,41,40)], + [dms(37,37,20), dms(125,41,40)], [dms(37,37,30), dms(125,29,0)], + [dms(37,30,45), dms(125,24,0)], [dms(37,26,0), dms(125,24,0)], + [dms(37,24,6), dms(125,24,0)], [dms(37,32,15), dms(124,48,0)], + ], + description: '해병대 서해안 대청연평 훈련구역. 사용고도 5,000ft.', + source: '해병대', + }, + { + id: 'R-136', title: 'R-136 동해 삼척근해', org: '해병대', area: '동해', level: 'danger', + lat: 37.48, lng: 129.60, altitude: '500ft', + polygon: [ + [dms(37,25,0), dms(129,30,45)], [dms(37,28,30), dms(129,37,0)], + [dms(37,6,30), dms(129,47,10)], [dms(37,30,30), dms(129,40,0)], + ], + description: '해병대 동해 삼척근해 훈련구역. 사용고도 500ft.', + source: '해병대', + }, + { + id: 'R-137', title: 'R-137 서해안 우도앞면', org: '해병대', area: '서해', level: 'danger', + lat: 37.63, lng: 125.87, altitude: '5,000ft', + polygon: [ + [dms(37,38,27), dms(125,55,27)], [dms(37,36,23), dms(126,0,23)], + [dms(37,36,22), dms(126,0,19)], [dms(37,36,22), dms(125,55,24)], + ], + description: '해병대 서해안 우도앞면 훈련구역. 사용고도 5,000ft.', + source: '해병대', + }, + { + id: 'R-153', title: 'R-153 서해안 대청도서쪽', org: '해병대', area: '서해', level: 'danger', + lat: 37.72, lng: 124.45, altitude: '3,000ft', + polygon: [ + [dms(37,44,55), dms(124,5,10)], [dms(37,44,55), dms(124,56,40)], + [dms(37,10,30), dms(124,36,40)], [dms(37,30,14), dms(124,36,10)], + ], + description: '해병대 서해안 대청도 서쪽 훈련구역. 사용고도 3,000ft.', + source: '해병대', + }, + { + id: 'R-154', title: 'R-154 서해안 연평도부근', org: '해병대', area: '서해', level: 'danger', + lat: 37.63, lng: 125.72, altitude: '3,000ft', + polygon: [ + [dms(37,41,46), dms(125,43,12)], [dms(37,35,17), dms(125,43,12)], + [dms(37,35,17), dms(125,54,12)], [dms(37,37,47), dms(125,55,10)], + ], + description: '해병대 서해안 연평도 부근 훈련구역. 사용고도 3,000ft.', + source: '해병대', + }, + { + id: 'R-156', title: 'R-156 동해안 주문진근해', org: '해병대', area: '동해', level: 'danger', + lat: 37.90, lng: 129.30, altitude: '3,500ft', + polygon: [ + [dms(38,14,0), dms(129,0,0)], [dms(37,48,0), dms(129,46,0)], + [dms(37,48,0), dms(129,36,0)], [dms(38,10,0), dms(129,19,0)], + [dms(38,10,0), dms(129,0,0)], + ], + description: '해병대 동해안 주문진 근해 훈련구역. 사용고도 3,500ft.', + source: '해병대', + }, + + // ═══════════════════════════════════════ + // 국방과학연구소 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-108A', title: 'R-108A 서해안 안흥1', org: '국과연', area: '서해', level: 'danger', + lat: 36.62, lng: 126.25, altitude: '27,000ft', + polygon: [ + [dms(36,40,46), dms(126,9,16)], [dms(36,40,36), dms(126,11,58)], + [dms(36,33,48), dms(126,13,48)], [dms(36,33,58), dms(126,9,54)], + ], + description: '국방과학연구소 서해안 안흥 시험장 1구역. 사용고도 27,000ft.', + source: '국과연', + }, + { + id: 'R-108B', title: 'R-108B 서해안 안흥2', org: '국과연', area: '서해', level: 'danger', + lat: 36.62, lng: 126.18, altitude: '33,000ft', + polygon: [ + [dms(36,40,46), dms(126,9,16)], [dms(36,40,36), dms(126,11,58)], + [dms(36,29,25), dms(126,15,1)], [dms(36,28,10), dms(126,7,28)], + ], + description: '국방과학연구소 서해안 안흥 시험장 2구역. 사용고도 33,000ft.', + source: '국과연', + }, + { + id: 'R-108D', title: 'R-108D 서해안 안흥4', org: '국과연', area: '서해', level: 'danger', + lat: 36.55, lng: 126.15, altitude: '40,000ft', + polygon: [ + [dms(36,40,46), dms(126,9,16)], [dms(36,40,36), dms(126,11,52)], + [dms(36,21,50), dms(126,9,7)], [dms(36,23,55), dms(126,2,2)], + ], + description: '국방과학연구소 서해안 안흥 시험장 4구역. 사용고도 40,000ft.', + source: '국과연', + }, + { + id: 'R-108F', title: 'R-108F 서해안 안흥6', org: '국과연', area: '서해', level: 'danger', + lat: 36.55, lng: 126.08, altitude: '80,000ft', + polygon: [ + [dms(36,40,46), dms(126,9,16)], [dms(36,40,36), dms(126,11,52)], + [dms(36,17,19), dms(126,0,32)], [dms(36,18,10), dms(125,56,37)], + ], + description: '국방과학연구소 서해안 안흥 시험장 6구역. 사용고도 80,000ft.', + source: '국과연', + }, + + // ═══════════════════════════════════════ + // 해양경찰청 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-140', title: 'R-140 동해안 속초연안', org: '해경', area: '동해', level: 'caution', + lat: dms(38,9,0), lng: dms(128,53,0), altitude: '300ft', + polygon: [[dms(38,9,0), dms(128,53,0)]], // 중심점 반경 이내 + description: '해양경찰청 동해안 속초연안 훈련구역. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-141', title: 'R-141 동해 묵호동방연안', org: '해경', area: '동해', level: 'caution', + lat: 37.58, lng: 129.28, altitude: '300ft', + polygon: [ + [dms(37,47,5), dms(129,11,0)], [dms(37,42,5), dms(129,13,0)], + [dms(37,30,5), dms(129,11,0)], [dms(37,30,5), dms(129,12,0)], + ], + description: '해양경찰청 동해 묵호동방연안 훈련구역. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-142A', title: 'R-142A 동해안 묵호연안(갑)', org: '해경', area: '동해', level: 'caution', + lat: dms(37,8,0), lng: dms(129,34,0), altitude: '300ft', + polygon: [[dms(37,8,0), dms(129,34,0)]], // 반경 2마일 + description: '해양경찰청 동해안 묵호연안(갑) 훈련구역. 반경 2마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-142B', title: 'R-142B 동해 강구항근해', org: '해경', area: '동해', level: 'caution', + lat: dms(36,20,0), lng: dms(129,50,0), altitude: '300ft', + polygon: [[dms(36,20,0), dms(129,50,0)]], // 반경 5마일 + description: '해양경찰청 동해 강구항근해 훈련구역. 반경 5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-142C', title: 'R-142C 동해안 호미곶연안', org: '해경', area: '동해', level: 'caution', + lat: dms(36,5,0), lng: dms(129,45,0), altitude: '300ft', + polygon: [[dms(36,5,0), dms(129,45,0)]], // 반경 5마일 + description: '해양경찰청 동해안 호미곶연안 훈련구역. 반경 5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-143', title: 'R-143 남해안 부산연안', org: '해경', area: '남해', level: 'caution', + lat: 35.15, lng: 129.28, altitude: '300ft', + polygon: [ + [dms(35,7,10), dms(129,17,0)], [dms(35,4,25), dms(129,20,40)], + [dms(34,58,20), dms(129,14,15)], [dms(35,1,10), dms(129,10,25)], + ], + description: '해양경찰청 남해안 부산연안 훈련구역. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-144', title: 'R-144 남해안 소지도부근', org: '해경', area: '남해', level: 'caution', + lat: dms(34,39,0), lng: dms(128,36,0), altitude: '300ft', + polygon: [[dms(34,39,0), dms(128,36,0)]], // 반경 4마일 + description: '해양경찰청 남해안 소지도부근 훈련구역. 반경 4마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-145', title: 'R-145 남해안 세도도부근', org: '해경', area: '남해', level: 'caution', + lat: dms(34,29,56), lng: dms(128,4,52), altitude: '300ft', + polygon: [[dms(34,29,56), dms(128,4,52)]], // 반경 5마일 + description: '해양경찰청 남해안 세도도부근 훈련구역. 반경 5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-146', title: 'R-146 남해안 정도남방연안', org: '해경', area: '남해', level: 'caution', + lat: dms(34,4,11), lng: dms(126,51,53), altitude: '300ft', + polygon: [[dms(34,4,11), dms(126,51,53)]], // 반경 5마일 + description: '해양경찰청 남해안 정도남방연안 훈련구역. 반경 5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-147', title: 'R-147 대한해협 마라도남방연안', org: '해경', area: '제주', level: 'caution', + lat: dms(32,40,0), lng: dms(126,0,0), altitude: '300ft', + polygon: [[dms(32,40,0), dms(126,0,0)]], // 반경 4마일 + description: '해양경찰청 대한해협 마라도남방연안 훈련구역. 반경 4마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-148A', title: 'R-148A 서해안 멸치도부근', org: '해경', area: '서해', level: 'caution', + lat: dms(34,45,34), lng: dms(126,13,24), altitude: '300ft', + polygon: [[dms(34,45,34), dms(126,13,24)]], // 반경 2.5마일 + description: '해양경찰청 서해안 멸치도부근 훈련구역. 반경 2.5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-148B', title: 'R-148B 서해안 진도서방연안', org: '해경', area: '서해', level: 'caution', + lat: dms(34,25,11), lng: dms(125,54,53), altitude: '300ft', + polygon: [[dms(34,25,11), dms(125,54,53)]], // 반경 4마일 + description: '해양경찰청 서해안 진도서방연안 훈련구역. 반경 4마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-149', title: 'R-149 남해안 화도서방연안', org: '해경', area: '남해', level: 'caution', + lat: dms(33,44,45), lng: dms(126,13,0), altitude: '300ft', + polygon: [[dms(33,44,45), dms(126,13,0)]], // 반경 5마일 + description: '해양경찰청 남해안 화도서방연안 훈련구역. 반경 5마일. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-150', title: 'R-150 남해안 마라도동쪽연안', org: '해경', area: '제주', level: 'caution', + lat: 33.31, lng: 126.45, altitude: '300ft', + polygon: [ + [dms(33,8,30), dms(126,23,0)], [dms(33,8,30), dms(126,29,0)], + [dms(32,58,30), dms(126,29,0)], [dms(32,58,30), dms(126,22,0)], + ], + description: '해양경찰청 남해안 마라도동쪽연안 훈련구역. 사용고도 300ft.', + source: '해경', + }, + { + id: 'R-151A', title: 'R-151A 서해안 팔금도남방연안', org: '해경', area: '서해', level: 'caution', + lat: 35.55, lng: 126.32, altitude: '300ft', + polygon: [ + [dms(35,50,0), dms(126,18,50)], [dms(35,0,0), dms(126,20,0)], + [dms(35,45,0), dms(126,20,0)], [dms(35,45,0), dms(126,15,0)], + ], + description: '해양경찰청 서해안 팔금도남방연안 훈련구역. 사용고도 300ft.', + source: '해경', + }, + + // ═══════════════════════════════════════ + // 공군 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-74', title: 'R-74 동해 포항북동남서역', org: '공군', area: '동해', level: 'danger', + lat: 36.40, lng: 130.10, altitude: '50,000ft', + polygon: [ + [dms(36,52,0), dms(130,0,0)], [dms(36,52,0), dms(130,13,0)], + [dms(36,44,0), dms(130,25,0)], [dms(36,2,0), dms(130,25,0)], + [dms(36,2,0), dms(130,0,0)], + ], + description: '공군 동해 포항 북동남서역 훈련구역. 사용고도 50,000ft.', + source: '공군', + }, + { + id: 'R-77', title: 'R-77 동해 미작진부근', org: '공군', area: '동해', level: 'danger', + lat: 38.50, lng: 128.50, altitude: '15,000ft', + polygon: [ + [dms(38,33,0), dms(128,24,0)], [dms(38,33,0), dms(128,31,0)], + [dms(38,32,0), dms(128,32,0)], [dms(38,30,0), dms(128,31,0)], + [dms(38,31,0), dms(128,24,0)], + ], + description: '공군 동해 미작진부근 훈련구역. 사용고도 15,000ft.', + source: '공군', + }, + { + id: 'R-80', title: 'R-80 관할 격렬비도남서역', org: '공군', area: '서해', level: 'danger', + lat: 36.35, lng: 125.92, altitude: '50,000ft', + polygon: [ + [dms(36,32,0), dms(124,50,0)], [dms(36,32,0), dms(125,36,0)], + [dms(36,30,0), dms(125,36,0)], [dms(36,4,55), dms(124,31,26)], + [dms(36,23,0), dms(124,31,26)], + ], + description: '공군 관할 격렬비도 남서역 훈련구역. 사용고도 50,000ft.', + source: '공군', + }, + { + id: 'R-84', title: 'R-84 관할 임자도서남역', org: '공군', area: '서해', level: 'danger', + lat: 35.22, lng: 125.70, altitude: '50,000ft', + polygon: [ + [dms(35,15,57), dms(124,31,44)], [dms(35,15,0), dms(125,36,10)], + [dms(34,30,0), dms(125,36,12)], [dms(34,44,7), dms(125,17,50)], + ], + description: '공군 관할 임자도서남역 훈련구역. 사용고도 50,000ft.', + source: '공군', + }, + { + id: 'R-88', title: 'R-88 관할 백비(白碑)도서남역', org: '공군', area: '서해', level: 'danger', + lat: 37.10, lng: 126.10, altitude: '50,000ft', + polygon: [ + [dms(37,1,21), dms(124,50,0)], [dms(37,2,0), dms(125,36,0)], + [dms(36,32,0), dms(125,36,0)], [dms(36,32,0), dms(125,0,0)], + ], + description: '공군 관할 백비도서남역 훈련구역. 사용고도 50,000ft.', + source: '공군', + }, + { + id: 'R-97A', title: 'R-97A 서해안 대천(A)', org: '공군', area: '서해', level: 'danger', + lat: 36.28, lng: 126.15, altitude: '30,000ft', + polygon: [ + [dms(36,20,0), dms(125,57,0)], [dms(36,20,0), dms(126,10,0)], + [dms(36,18,0), dms(126,25,0)], [dms(36,13,0), dms(126,31,0)], + ], + description: '공군 서해안 대천(A) 훈련구역. 사용고도 30,000ft.', + source: '공군', + }, + { + id: 'R-97B', title: 'R-97B 서해안 대천(B)', org: '공군', area: '서해', level: 'danger', + lat: 36.35, lng: 126.50, altitude: '무한대', + polygon: [ + [dms(36,20,0), dms(125,57,0)], [dms(36,20,0), dms(126,10,0)], + [dms(36,22,17), dms(126,14,41)], [dms(36,21,22), dms(126,30,7)], + [dms(36,14,0), dms(126,38,0)], [dms(36,53,0), dms(125,25,0)], + [dms(38,14,30), dms(125,57,0)], + ], + description: '공군 서해안 대천(B) 훈련구역. 사용고도 무한대.', + source: '공군', + }, + + // ═══════════════════════════════════════ + // 육군 훈련구역 + // ═══════════════════════════════════════ + { + id: 'R-97E', title: 'R-97E 서해안 대천(E)', org: '육군', area: '서해', level: 'danger', + lat: 36.30, lng: 126.60, altitude: '30,000ft', + polygon: [ + [dms(36,18,39), dms(126,19,2)], [dms(36,14,0), dms(126,38,0)], + [dms(36,6,21), dms(126,31,0)], [dms(36,11,49), dms(126,25,0)], + ], + description: '육군 서해안 대천(E) 훈련구역. 사용고도 30,000ft.', + source: '육군', + }, + { + id: 'R-97F', title: 'R-97F 서해안 대천(F)', org: '육군', area: '서해', level: 'danger', + lat: 36.33, lng: 126.78, altitude: '15,000ft', + polygon: [ + [dms(36,20,0), dms(126,31,0)], [dms(36,16,0), dms(126,35,0)], + [dms(36,0,0), dms(126,35,57)], [dms(36,17,18), dms(126,25,0)], + ], + description: '육군 서해안 대천(F) 훈련구역. 사용고도 15,000ft.', + source: '육군', + }, + { + id: 'R-104', title: 'R-104 서해안 미어도부근', org: '육군', area: '서해', level: 'danger', + lat: dms(35,32,51), lng: dms(126,26,26), altitude: '15,000ft', + polygon: [[dms(35,32,51), dms(126,26,26)]], // 반경 5마일 + description: '육군 서해안 미어도부근 훈련구역. 반경 5마일. 사용고도 15,000ft.', + source: '육군', + }, + { + id: 'R-105', title: 'R-105 서해안 지도부근', org: '육군', area: '서해', level: 'danger', + lat: dms(35,53,26), lng: dms(126,4,16), altitude: '25,000ft', + polygon: [[dms(35,53,26), dms(126,4,16)]], // 반경 10마일 + description: '육군 서해안 지도부근 훈련구역. 반경 10마일. 사용고도 25,000ft.', + source: '육군', + }, + { + id: 'R-107', title: 'R-107 동해 강릉연안(다)', org: '육군', area: '동해', level: 'danger', + lat: 38.25, lng: 129.70, altitude: '40,000ft', + polygon: [ + [dms(38,15,0), dms(129,15,30)], [dms(38,14,0), dms(130,0,0)], + [dms(37,47,0), dms(130,0,0)], + ], + description: '육군 동해 강릉연안(다) 훈련구역. 사용고도 40,000ft.', + source: '육군', + }, +]; diff --git a/frontend/src/services/opensky.ts b/frontend/src/services/opensky.ts new file mode 100644 index 0000000..c625a5a --- /dev/null +++ b/frontend/src/services/opensky.ts @@ -0,0 +1,600 @@ +import type { Aircraft, AircraftCategory } from '../types'; + +// OpenSky Network API - free tier, no auth needed for basic queries +const OPENSKY_BASE = '/api/opensky/api'; + +// Middle East bounding box (lat_min, lat_max, lng_min, lng_max) +const ME_BOUNDS = { + lamin: 24, + lamax: 42, + lomin: 30, + lomax: 62, +}; + +// Known military callsign prefixes +const MILITARY_PREFIXES: Record = { + 'RCH': 'cargo', // C-17 / C-5 AMC + 'REACH': 'cargo', + 'KING': 'tanker', // HC-130 rescue tanker + 'ETHYL': 'tanker', // KC-135 + 'STEEL': 'tanker', // KC-135 + 'PACK': 'tanker', // KC-135 + 'NCHO': 'tanker', // KC-10 + 'JULEP': 'tanker', + 'IRON': 'fighter', + 'VIPER': 'fighter', + 'RAGE': 'fighter', + 'TOXIN': 'surveillance', // RC-135 + 'OLIVE': 'surveillance', // RC-135 + 'COBRA': 'surveillance', + 'FORTE': 'surveillance', // RQ-4 Global Hawk + 'HAWK': 'surveillance', + 'GLOBAL': 'surveillance', + 'SNTRY': 'surveillance', // E-3 AWACS + 'WIZARD': 'surveillance', + 'DOOM': 'military', + 'EVAC': 'military', + 'SAM': 'military', // VIP/govt + 'EXEC': 'military', + 'NAVY': 'military', + 'TOPCT': 'military', + 'DEATH': 'fighter', // B-2 Spirit + 'REAPER': 'surveillance', // MQ-9 + 'DRAGON': 'surveillance', // U-2 +}; + +function classifyAircraft(callsign: string): AircraftCategory { + const cs = callsign.toUpperCase().trim(); + for (const [prefix, cat] of Object.entries(MILITARY_PREFIXES)) { + if (cs.startsWith(prefix)) return cat; + } + return 'civilian'; +} + +function parseOpenSkyResponse(data: { time: number; states: unknown[][] | null }): Aircraft[] { + if (!data.states) return []; + + return data.states + .filter(s => s[6] !== null && s[5] !== null) // must have position + .map(s => { + const callsign = ((s[1] as string) || '').trim(); + const category = classifyAircraft(callsign); + return { + icao24: s[0] as string, + callsign, + lat: s[6] as number, + lng: s[5] as number, + altitude: (s[7] as number) || 0, + velocity: (s[9] as number) || 0, + heading: (s[10] as number) || 0, + verticalRate: (s[11] as number) || 0, + onGround: s[8] as boolean, + category, + lastSeen: (s[4] as number) * 1000, + }; + }); +} + +// OpenSky free tier: ~1 request per 10s. Shared throttle to avoid 429. +let lastOpenSkyCall = 0; +const OPENSKY_MIN_INTERVAL = 12_000; // 12s between calls + +async function throttledOpenSkyFetch(url: string): Promise { + const now = Date.now(); + const wait = OPENSKY_MIN_INTERVAL - (now - lastOpenSkyCall); + if (wait > 0) await new Promise(r => setTimeout(r, wait)); + lastOpenSkyCall = Date.now(); + + const res = await fetch(url); + if (res.status === 429) { + console.warn('OpenSky rate limited (429), skipping'); + return []; + } + if (!res.ok) throw new Error(`OpenSky ${res.status}`); + const data = await res.json(); + return parseOpenSkyResponse(data); +} + +export async function fetchAircraftOpenSky(): Promise { + try { + const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`; + return await throttledOpenSkyFetch(url); + } catch (err) { + console.warn('OpenSky fetch failed, using sample data:', err); + return getSampleAircraft(); + } +} + +// ═══ Korea region ═══ +const KR_BOUNDS = { lamin: 20, lamax: 45, lomin: 115, lomax: 145 }; + +export async function fetchAircraftOpenSkyKorea(): Promise { + try { + const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`; + return await throttledOpenSkyFetch(url); + } catch (err) { + console.warn('OpenSky Korea fetch failed:', err); + return []; + } +} + +// T0 = main Iranian retaliation wave +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; +const MIN = 60_000; + +// ── 2026 March 1 verified aircraft deployments ── +// Based on OSINT: Operation Epic Fury order of battle +function getSampleAircraft(): Aircraft[] { + const now = Date.now(); + return [ + // ═══════════════════════════════════════════ + // SURVEILLANCE / ISR (persistent coverage) + // ═══════════════════════════════════════════ + + // RQ-4B Global Hawk "FORTE12" - 24h ISR orbit over Iraq/Iran border + // Deployed from Al Dhafra, UAE (standard CENTCOM ISR asset) + { + icao24: 'ae1461', callsign: 'FORTE12', lat: 33.2, lng: 43.5, altitude: 16764, + velocity: 170, heading: 135, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'RQ4B', lastSeen: now, + activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + // MQ-4C Triton "FORTE13" - Maritime ISR over Persian Gulf + // One MQ-4C lost near Iran Feb 22; this is replacement sortie + { + icao24: 'ae1462', callsign: 'FORTE13', lat: 27.0, lng: 55.0, altitude: 15240, + velocity: 165, heading: 90, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'MQ4C', lastSeen: now, + activeStart: T0 - 10 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // RC-135V Rivet Joint "TOXIN31" - SIGINT, relocated to Crete (Souda Bay) + // Per OSINT: RC-135 shifted from Turkey-Syria border to Crete Mar 2026 + { + icao24: 'ae5420', callsign: 'TOXIN31', lat: 35.5, lng: 34.0, altitude: 9144, + velocity: 230, heading: 90, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'RC135V', lastSeen: now, + activeStart: T0 - 10 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // E-3G AWACS "SNTRY60" - Airborne early warning over northern Iraq + // Al Dhafra-based, orbiting for Iranian missile launch detection + { + icao24: 'ae0005', callsign: 'SNTRY60', lat: 34.5, lng: 44.0, altitude: 9144, + velocity: 200, heading: 45, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'E3G', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // U-2S Dragon Lady "DRAGON01" - Ultra-high altitude recon from Al Dhafra + // Facilities hit by Iranian missile strike ~T0+1h; U-2 airborne before impact + { + icao24: 'ae0006', callsign: 'DRAGON01', lat: 30.5, lng: 52.0, altitude: 21336, + velocity: 200, heading: 0, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'U2S', lastSeen: now, + activeStart: T0 - 8 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // MQ-9A Reaper "REAPER41" - Armed ISR over western Iraq + // Launched from Al Dhafra pre-strike; base facilities damaged by Iranian missiles + { + icao24: 'ae0007', callsign: 'REAPER41', lat: 32.0, lng: 41.5, altitude: 7620, + velocity: 80, heading: 180, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'MQ9A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // MQ-9A Reaper "REAPER42" - Armed ISR over Strait of Hormuz + { + icao24: 'ae0008', callsign: 'REAPER42', lat: 26.5, lng: 56.0, altitude: 6096, + velocity: 75, heading: 270, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'MQ9A', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + + // ═══════════════════════════════════════════ + // TANKERS (aerial refueling for strike packages) + // ═══════════════════════════════════════════ + + // KC-135R "ETHYL71" - Refueling orbit over western Iraq for strike package + { + icao24: 'ae0001', callsign: 'ETHYL71', lat: 31.5, lng: 42.0, altitude: 10668, + velocity: 250, heading: 270, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'KC135R', lastSeen: now, + activeStart: T0 - 8 * HOUR, activeEnd: T0 + 6 * HOUR, + }, + // KC-46A Pegasus "STEEL55" - Refueling support for B-2 bombers + // B-2s flew from Diego Garcia/Whiteman; needed multiple refueling + { + icao24: 'ae0009', callsign: 'STEEL55', lat: 28.5, lng: 50.0, altitude: 10972, + velocity: 245, heading: 315, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'KC46A', lastSeen: now, + activeStart: T0 - 10 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // KC-135R "PACK22" - Refueling orbit over eastern Jordan + // Supporting Israeli F-35I and F-15I strike missions + { + icao24: 'ae0003', callsign: 'PACK22', lat: 32.8, lng: 38.5, altitude: 10972, + velocity: 245, heading: 180, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'KC135R', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // KC-10A Extender "NCHO45" - Large refueling orbit over northern Gulf + { + icao24: 'ae0002', callsign: 'NCHO45', lat: 29.8, lng: 47.5, altitude: 10058, + velocity: 240, heading: 300, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'KC10A', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 5 * HOUR, + }, + + // ═══════════════════════════════════════════ + // BOMBERS (US stealth strikes on Iran) + // ═══════════════════════════════════════════ + + // B-2A Spirit "DEATH11" - Stealth bomber, Whiteman via Diego Garcia + // B-2s confirmed conducting deep strikes on Iranian nuclear/military facilities + { + icao24: 'ae2001', callsign: 'DEATH11', lat: 29.0, lng: 53.0, altitude: 12192, + velocity: 260, heading: 0, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'B2A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 2 * HOUR, + }, + // B-2A Spirit "DEATH12" - Second stealth bomber in pair + { + icao24: 'ae2002', callsign: 'DEATH12', lat: 28.5, lng: 52.5, altitude: 12192, + velocity: 260, heading: 10, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'B2A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 2 * HOUR, + }, + + // ═══════════════════════════════════════════ + // US FIGHTERS (Al Udeid, Al Dhafra based) + // ═══════════════════════════════════════════ + + // F-22A Raptor "RAGE01" - Al Udeid-based, air superiority + { + icao24: 'ae3001', callsign: 'RAGE01', lat: 30.0, lng: 48.0, altitude: 10668, + velocity: 320, heading: 45, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F22A', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // F-22A Raptor "RAGE02" - Wingman + { + icao24: 'ae3002', callsign: 'RAGE02', lat: 29.8, lng: 47.5, altitude: 10668, + velocity: 318, heading: 50, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F22A', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // F-35A Lightning II "VIPER11" - From RAF Lakenheath (deployed to Al Udeid) + { + icao24: 'ae3003', callsign: 'VIPER11', lat: 31.0, lng: 46.0, altitude: 9144, + velocity: 300, heading: 60, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F35A', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // F-35A Lightning II "VIPER12" - Wingman + { + icao24: 'ae3004', callsign: 'VIPER12', lat: 30.8, lng: 45.5, altitude: 9144, + velocity: 298, heading: 65, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F35A', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // F-15E Strike Eagle "IRON41" - From RAF Lakenheath/Al Dhafra + // Deep strike mission into Iran + { + icao24: 'ae3005', callsign: 'IRON41', lat: 29.5, lng: 50.0, altitude: 8534, + velocity: 310, heading: 30, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15E', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 2 * HOUR, + }, + // F-15E Strike Eagle "IRON42" - Wingman + { + icao24: 'ae3006', callsign: 'IRON42', lat: 29.3, lng: 49.5, altitude: 8534, + velocity: 308, heading: 35, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15E', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 2 * HOUR, + }, + // F/A-18E/F Super Hornet "NAVY51" - USS Abraham Lincoln CVW + // Lincoln CSG in Arabian Sea; launching strikes on Iran + { + icao24: 'ae3007', callsign: 'NAVY51', lat: 24.0, lng: 60.0, altitude: 7620, + velocity: 280, heading: 350, verticalRate: 5, onGround: false, + category: 'fighter', typecode: 'FA18F', lastSeen: now, + activeStart: T0 - 1 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // F/A-18E Super Hornet "NAVY52" - Lincoln CAP flight + { + icao24: 'ae3008', callsign: 'NAVY52', lat: 23.5, lng: 59.5, altitude: 6096, + velocity: 275, heading: 0, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'FA18E', lastSeen: now, + activeStart: T0 - 1 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + + // ═══════════════════════════════════════════ + // ISRAELI AIR FORCE (strikes on Iran) + // ═══════════════════════════════════════════ + + // F-35I Adir "IRON33" - From Nevatim AB, deep strike into Iran + // F-35I confirmed shooting down Iranian Yak-130 on Mar 4 + { + icao24: 'ae0012', callsign: 'IRON33', lat: 31.2, lng: 34.9, altitude: 10000, + velocity: 310, heading: 90, verticalRate: 3, onGround: false, + category: 'fighter', typecode: 'F35I', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // F-35I Adir "IRON34" - Wingman from Nevatim + { + icao24: 'ae4002', callsign: 'IRON34', lat: 31.0, lng: 35.2, altitude: 10000, + velocity: 308, heading: 95, verticalRate: 2, onGround: false, + category: 'fighter', typecode: 'F35I', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // F-15I Ra'am "RAGE22" - From Ramon AB, carrying Black Sparrow standoff missiles + { + icao24: 'ae0011', callsign: 'RAGE22', lat: 30.2, lng: 35.3, altitude: 7620, + velocity: 300, heading: 45, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15I', lastSeen: now, + activeStart: T0 - 90 * MIN, activeEnd: T0 + 2 * HOUR, + }, + // F-15I Ra'am "RAGE23" - Wingman + { + icao24: 'ae4004', callsign: 'RAGE23', lat: 30.0, lng: 35.5, altitude: 7620, + velocity: 298, heading: 50, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15I', lastSeen: now, + activeStart: T0 - 90 * MIN, activeEnd: T0 + 2 * HOUR, + }, + // F-16I Sufa "VIPER01" - CAP over Negev/Golan (intercept duty) + { + icao24: 'ae0010', callsign: 'VIPER01', lat: 30.5, lng: 35.0, altitude: 6096, + velocity: 280, heading: 60, verticalRate: 5, onGround: false, + category: 'fighter', typecode: 'F16I', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + + // ═══════════════════════════════════════════ + // CARGO / LOGISTICS + // ═══════════════════════════════════════════ + + // C-17A Globemaster III "RCH882" - Al Udeid → Al Asad logistics + { + icao24: 'ae0004', callsign: 'RCH882', lat: 29.0, lng: 48.0, altitude: 9753, + velocity: 220, heading: 340, verticalRate: -2, onGround: false, + category: 'cargo', typecode: 'C17A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 - 1 * HOUR, + }, + // C-17A "RCH445" - Post-strike logistics, Ramstein → Gulf + { + icao24: 'ae0013', callsign: 'RCH445', lat: 32.0, lng: 46.0, altitude: 10000, + velocity: 215, heading: 200, verticalRate: 0, onGround: false, + category: 'cargo', typecode: 'C17A', lastSeen: now, + activeStart: T0 + 2 * HOUR, activeEnd: T0 + 9 * HOUR, + }, + // C-5M Super Galaxy "RCH901" - Heavy lift from Ramstein (ammo resupply) + { + icao24: 'ae5001', callsign: 'RCH901', lat: 35.0, lng: 38.0, altitude: 10668, + velocity: 230, heading: 120, verticalRate: 0, onGround: false, + category: 'cargo', typecode: 'C5M', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 5 * HOUR, + }, + + // ═══════════════════════════════════════════ + // CIVILIAN (diverted/cancelled due to airspace closures) + // ═══════════════════════════════════════════ + + // Qatar Airways QR8101 - Doha → Amman (before airspace closure) + { + icao24: '738012', callsign: 'QTR8101', lat: 25.3, lng: 51.5, altitude: 3048, + velocity: 130, heading: 300, verticalRate: -5, onGround: false, + category: 'civilian', lastSeen: now, + activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR, + }, + // Emirates EK412 - Dubai → Istanbul (before airspace closure) + { + icao24: '710104', callsign: 'EK412', lat: 25.1, lng: 55.2, altitude: 10668, + velocity: 260, heading: 330, verticalRate: 3, onGround: false, + category: 'civilian', lastSeen: now, + activeStart: T0 - 8 * HOUR, activeEnd: T0 - 2 * HOUR, + }, + // Qatar Airways QR306 - Doha → London (transiting region post-strike) + { + icao24: '738020', callsign: 'QTR306', lat: 26.0, lng: 50.5, altitude: 11000, + velocity: 240, heading: 315, verticalRate: 0, onGround: false, + category: 'civilian', lastSeen: now, + activeStart: T0 + 3 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // Etihad EY55 - Abu Dhabi → Cairo (post-strike, restricted routing) + { + icao24: '710110', callsign: 'ETD55', lat: 24.5, lng: 54.0, altitude: 10500, + velocity: 250, heading: 280, verticalRate: 2, onGround: false, + category: 'civilian', lastSeen: now, + activeStart: T0 + 3 * HOUR, activeEnd: T0 + 9 * HOUR, + }, + + // ═══════════════════════════════════════════ + // ADDITIONAL MILITARY — Coalition & Regional + // ═══════════════════════════════════════════ + + // RAF Typhoon FGR4 "TYPHON1" — RAF Akrotiri, Cyprus CAP + { + icao24: 'ae6001', callsign: 'TYPHON1', lat: 35.0, lng: 33.5, altitude: 9144, + velocity: 310, heading: 120, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'EUFI', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // RAF Typhoon FGR4 "TYPHON2" + { + icao24: 'ae6002', callsign: 'TYPHON2', lat: 34.8, lng: 33.8, altitude: 9144, + velocity: 308, heading: 125, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'EUFI', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // French Rafale "RAFALE1" — FS Charles de Gaulle launch + { + icao24: 'ae6003', callsign: 'RAFALE1', lat: 34.5, lng: 30.0, altitude: 10000, + velocity: 320, heading: 90, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'RFAL', lastSeen: now, + activeStart: T0 + 4 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // USAF E-8C JSTARS "WIZARD21" — Ground surveillance over Iraq + { + icao24: 'ae6004', callsign: 'WIZARD21', lat: 33.5, lng: 42.5, altitude: 10668, + velocity: 210, heading: 45, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'E8C', lastSeen: now, + activeStart: T0 - 8 * HOUR, activeEnd: T0 + 6 * HOUR, + }, + // P-8A Poseidon "NAVY61" — Maritime patrol, Arabian Sea + { + icao24: 'ae6005', callsign: 'NAVY61', lat: 24.0, lng: 58.0, altitude: 7620, + velocity: 210, heading: 270, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'P8A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // P-8A Poseidon "NAVY62" — Maritime patrol, Strait of Hormuz + { + icao24: 'ae6006', callsign: 'NAVY62', lat: 26.0, lng: 55.5, altitude: 6096, + velocity: 200, heading: 180, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'P8A', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 9 * HOUR, + }, + // E-2D Hawkeye "NAVY71" — USS Abraham Lincoln AEW + { + icao24: 'ae6007', callsign: 'NAVY71', lat: 23.0, lng: 61.0, altitude: 7620, + velocity: 160, heading: 30, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'E2D', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 6 * HOUR, + }, + // KC-135R "JULEP31" — Refueling over Saudi + { + icao24: 'ae6008', callsign: 'JULEP31', lat: 27.0, lng: 45.0, altitude: 10668, + velocity: 245, heading: 0, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'KC135R', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + // A330 MRTT "UAE50" — UAE Air Force tanker + { + icao24: 'ae6009', callsign: 'UAE50', lat: 25.0, lng: 53.0, altitude: 10000, + velocity: 240, heading: 315, verticalRate: 0, onGround: false, + category: 'tanker', typecode: 'A332', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 6 * HOUR, + }, + // C-130J "RCH771" — Tactical airlift Kuwait + { + icao24: 'ae6010', callsign: 'RCH771', lat: 29.2, lng: 47.5, altitude: 6096, + velocity: 150, heading: 180, verticalRate: 0, onGround: false, + category: 'cargo', typecode: 'C130J', lastSeen: now, + activeStart: T0 - 2 * HOUR, activeEnd: T0 + 5 * HOUR, + }, + // USAF B-1B Lancer "BONE21" — From Diego Garcia, standoff strikes + { + icao24: 'ae6011', callsign: 'BONE21', lat: 25.0, lng: 59.0, altitude: 10668, + velocity: 280, heading: 350, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'B1B', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 3 * HOUR, + }, + // Saudi F-15SA "RSAF01" — Air defense patrol + { + icao24: 'ae6012', callsign: 'RSAF01', lat: 26.5, lng: 43.0, altitude: 9144, + velocity: 300, heading: 90, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15SA', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // Saudi F-15SA "RSAF02" + { + icao24: 'ae6013', callsign: 'RSAF02', lat: 26.3, lng: 43.5, altitude: 9144, + velocity: 298, heading: 95, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F15SA', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + // UAE F-16E "UAEAF1" — Al Dhafra CAP + { + icao24: 'ae6014', callsign: 'UAEAF1', lat: 24.5, lng: 55.0, altitude: 8534, + velocity: 290, heading: 60, verticalRate: 0, onGround: false, + category: 'fighter', typecode: 'F16E', lastSeen: now, + activeStart: T0 - 4 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // Israeli G550 "ORON01" — Oron SIGINT/AEW platform + { + icao24: 'ae6015', callsign: 'ORON01', lat: 31.0, lng: 34.5, altitude: 12192, + velocity: 200, heading: 45, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'G550', lastSeen: now, + activeStart: T0 - 3 * HOUR, activeEnd: T0 + 8 * HOUR, + }, + // MQ-9 "REAPER43" — Over Yemen + { + icao24: 'ae6016', callsign: 'REAPER43', lat: 15.0, lng: 44.0, altitude: 6096, + velocity: 80, heading: 180, verticalRate: 0, onGround: false, + category: 'surveillance', typecode: 'MQ9A', lastSeen: now, + activeStart: T0 - 6 * HOUR, activeEnd: T0 + 10 * HOUR, + }, + + // ═══════════════════════════════════════════ + // CIVILIAN — 영공 폐쇄 전/후 항공편 (중동 주요 노선) + // ═══════════════════════════════════════════ + + // ── 공습 전 출발 (T0-12h ~ T0-4h) ── + // 이란/이라크 영공 폐쇄 전 정상 운항 + { icao24: 'c10001', callsign: 'QTR777', lat: 25.3, lng: 51.6, altitude: 10668, velocity: 250, heading: 310, verticalRate: 3, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10002', callsign: 'QTR003', lat: 26.0, lng: 50.0, altitude: 11000, velocity: 245, heading: 290, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10003', callsign: 'UAE203', lat: 25.2, lng: 55.3, altitude: 10668, velocity: 260, heading: 320, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10004', callsign: 'UAE501', lat: 25.1, lng: 55.2, altitude: 11000, velocity: 255, heading: 275, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10005', callsign: 'ETD401', lat: 24.4, lng: 54.7, altitude: 10668, velocity: 248, heading: 310, verticalRate: 1, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10006', callsign: 'GFA271', lat: 26.2, lng: 50.6, altitude: 10000, velocity: 240, heading: 300, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10007', callsign: 'SVA103', lat: 24.7, lng: 46.7, altitude: 10668, velocity: 250, heading: 350, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10008', callsign: 'SVA321', lat: 25.0, lng: 46.0, altitude: 11000, velocity: 255, heading: 45, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10009', callsign: 'KAC501', lat: 29.2, lng: 47.9, altitude: 10668, velocity: 240, heading: 300, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10010', callsign: 'OMA143', lat: 23.6, lng: 58.3, altitude: 10668, velocity: 248, heading: 315, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10011', callsign: 'THY772', lat: 37.0, lng: 40.0, altitude: 11000, velocity: 240, heading: 270, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10012', callsign: 'THY784', lat: 38.0, lng: 38.0, altitude: 10668, velocity: 245, heading: 180, verticalRate: -1, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10013', callsign: 'IRA712', lat: 35.7, lng: 51.4, altitude: 3048, velocity: 120, heading: 180, verticalRate: -5, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 11 * HOUR }, + { icao24: 'c10014', callsign: 'IRA332', lat: 32.0, lng: 52.0, altitude: 10000, velocity: 230, heading: 90, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 11 * HOUR }, + { icao24: 'c10015', callsign: 'BAW115', lat: 30.0, lng: 45.0, altitude: 11000, velocity: 250, heading: 130, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10016', callsign: 'DLH600', lat: 33.0, lng: 42.0, altitude: 11000, velocity: 248, heading: 120, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + { icao24: 'c10017', callsign: 'AFR562', lat: 34.0, lng: 38.0, altitude: 11000, velocity: 245, heading: 110, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 11 * HOUR, activeEnd: T0 - 5 * HOUR }, + { icao24: 'c10018', callsign: 'SIA472', lat: 28.0, lng: 55.0, altitude: 11000, velocity: 260, heading: 100, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10019', callsign: 'CPA761', lat: 27.0, lng: 56.0, altitude: 10668, velocity: 255, heading: 80, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10020', callsign: 'KAL505', lat: 29.0, lng: 50.0, altitude: 11000, velocity: 250, heading: 70, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 - 6 * HOUR }, + + // ── 영공 폐쇄 직전 긴급 이탈 (T0-6h ~ T0-2h) ── + { icao24: 'c10021', callsign: 'QTR008', lat: 26.5, lng: 49.0, altitude: 10668, velocity: 260, heading: 250, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 1 * HOUR }, + { icao24: 'c10022', callsign: 'UAE022', lat: 25.5, lng: 54.0, altitude: 11000, velocity: 255, heading: 200, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 1 * HOUR }, + { icao24: 'c10023', callsign: 'ETD203', lat: 24.8, lng: 54.5, altitude: 10000, velocity: 245, heading: 240, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 5 * HOUR, activeEnd: T0 - 1 * HOUR }, + { icao24: 'c10024', callsign: 'FDB523', lat: 25.3, lng: 55.4, altitude: 9000, velocity: 230, heading: 190, verticalRate: -2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 5 * HOUR, activeEnd: T0 - 1 * HOUR }, + { icao24: 'c10025', callsign: 'SVA661', lat: 26.0, lng: 44.0, altitude: 10668, velocity: 250, heading: 270, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 2 * HOUR }, + { icao24: 'c10026', callsign: 'GFA511', lat: 26.5, lng: 50.3, altitude: 8000, velocity: 220, heading: 210, verticalRate: -3, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 5 * HOUR, activeEnd: T0 - 1 * HOUR }, + { icao24: 'c10027', callsign: 'OMA155', lat: 24.0, lng: 57.0, altitude: 10668, velocity: 245, heading: 160, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 2 * HOUR }, + { icao24: 'c10028', callsign: 'IAW117', lat: 33.3, lng: 44.4, altitude: 5000, velocity: 150, heading: 300, verticalRate: -4, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 4 * HOUR }, + { icao24: 'c10029', callsign: 'THY790', lat: 37.5, lng: 36.0, altitude: 10668, velocity: 250, heading: 160, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 - 2 * HOUR }, + { icao24: 'c10030', callsign: 'KAC303', lat: 29.0, lng: 48.2, altitude: 8000, velocity: 230, heading: 250, verticalRate: -2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 5 * HOUR, activeEnd: T0 - 1 * HOUR }, + + // ── 영공 폐쇄 구간 (T0-2h ~ T0+3h) — 거의 민간기 없음 ── + // 우회 항공편만 소수 + { icao24: 'c10031', callsign: 'QTR922', lat: 28.0, lng: 42.0, altitude: 11000, velocity: 260, heading: 300, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 1 * HOUR, activeEnd: T0 + 3 * HOUR }, + { icao24: 'c10032', callsign: 'UAE780', lat: 20.0, lng: 58.0, altitude: 11000, velocity: 255, heading: 250, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 - 1 * HOUR, activeEnd: T0 + 4 * HOUR }, + + // ── 영공 부분 개방 후 (T0+3h ~) ── 우회 노선으로 운항 재개 + { icao24: 'c10033', callsign: 'QTR102', lat: 25.5, lng: 51.4, altitude: 10668, velocity: 248, heading: 280, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 3 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10034', callsign: 'QTR844', lat: 25.2, lng: 51.5, altitude: 11000, velocity: 252, heading: 60, verticalRate: 3, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10035', callsign: 'UAE412', lat: 25.3, lng: 55.2, altitude: 10668, velocity: 260, heading: 310, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 3 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10036', callsign: 'UAE506', lat: 25.0, lng: 55.0, altitude: 11000, velocity: 255, heading: 80, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10037', callsign: 'ETD231', lat: 24.5, lng: 54.6, altitude: 10000, velocity: 250, heading: 290, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 3 * HOUR, activeEnd: T0 + 9 * HOUR }, + { icao24: 'c10038', callsign: 'ETD889', lat: 24.3, lng: 54.8, altitude: 11000, velocity: 248, heading: 100, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10039', callsign: 'SVA115', lat: 24.8, lng: 46.5, altitude: 10668, velocity: 245, heading: 340, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 3 * HOUR, activeEnd: T0 + 9 * HOUR }, + { icao24: 'c10040', callsign: 'SVA717', lat: 24.5, lng: 46.8, altitude: 10668, velocity: 250, heading: 50, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10041', callsign: 'FDB881', lat: 25.1, lng: 55.5, altitude: 9500, velocity: 235, heading: 200, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10042', callsign: 'AXB221', lat: 25.0, lng: 55.1, altitude: 10668, velocity: 240, heading: 130, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10043', callsign: 'GFA891', lat: 26.3, lng: 50.5, altitude: 10000, velocity: 235, heading: 280, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10044', callsign: 'KAC117', lat: 29.5, lng: 47.8, altitude: 10668, velocity: 248, heading: 300, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10045', callsign: 'OMA303', lat: 23.8, lng: 57.5, altitude: 10668, velocity: 245, heading: 250, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 3 * HOUR, activeEnd: T0 + 9 * HOUR }, + { icao24: 'c10046', callsign: 'THY801', lat: 37.2, lng: 37.0, altitude: 11000, velocity: 240, heading: 140, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 10 * HOUR }, + { icao24: 'c10047', callsign: 'SIA478', lat: 22.0, lng: 58.0, altitude: 11000, velocity: 260, heading: 100, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10048', callsign: 'CPA763', lat: 21.0, lng: 59.0, altitude: 11000, velocity: 258, heading: 85, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10049', callsign: 'KAL507', lat: 25.0, lng: 52.0, altitude: 11000, velocity: 250, heading: 65, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10050', callsign: 'BAW117', lat: 30.0, lng: 38.0, altitude: 11000, velocity: 255, heading: 130, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10051', callsign: 'DLH602', lat: 33.0, lng: 35.0, altitude: 11000, velocity: 245, heading: 120, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10052', callsign: 'AFR564', lat: 34.0, lng: 33.0, altitude: 11000, velocity: 248, heading: 110, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10053', callsign: 'UAE870', lat: 25.2, lng: 55.3, altitude: 10668, velocity: 258, heading: 340, verticalRate: 3, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 6 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10054', callsign: 'QTR360', lat: 25.4, lng: 51.3, altitude: 10668, velocity: 252, heading: 320, verticalRate: 2, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 6 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10055', callsign: 'SVA401', lat: 24.9, lng: 46.6, altitude: 10668, velocity: 250, heading: 80, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 6 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10056', callsign: 'FDB301', lat: 25.0, lng: 55.2, altitude: 9000, velocity: 230, heading: 250, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 7 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10057', callsign: 'AIC115', lat: 25.3, lng: 55.0, altitude: 10668, velocity: 245, heading: 120, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10058', callsign: 'PAK302', lat: 25.5, lng: 55.3, altitude: 10000, velocity: 238, heading: 90, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 6 * HOUR, activeEnd: T0 + 12 * HOUR }, + { icao24: 'c10059', callsign: 'RJA401', lat: 31.7, lng: 36.0, altitude: 10668, velocity: 240, heading: 180, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 5 * HOUR, activeEnd: T0 + 11 * HOUR }, + { icao24: 'c10060', callsign: 'MEA341', lat: 33.8, lng: 35.5, altitude: 10000, velocity: 235, heading: 250, verticalRate: 0, onGround: false, category: 'civilian', lastSeen: now, activeStart: T0 + 6 * HOUR, activeEnd: T0 + 12 * HOUR }, + ]; +} diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts new file mode 100644 index 0000000..b5d1456 --- /dev/null +++ b/frontend/src/services/osint.ts @@ -0,0 +1,756 @@ +// ═══ Real-time OSINT Feed Service ═══ +// Fetches live news from GDELT Project API + Google News RSS +// Focus: Iran, Hormuz Strait, Middle East military, oil/energy + +export interface OsintItem { + id: string; + timestamp: number; // unix ms + title: string; + source: string; // "Reuters", "AP", etc. + url: string; + category: 'military' | 'oil' | 'diplomacy' | 'shipping' | 'nuclear' | 'general' | 'maritime_accident' | 'fishing' | 'maritime_traffic'; + language: 'en' | 'ko' | 'other'; + imageUrl?: string; + lat?: number; // extracted from title keywords + lng?: number; +} + +// ── Korean maritime location extraction ── +const KOREA_LOCATIONS: [RegExp, number, number][] = [ + // 주요 항구/해역 + [/인천|경인/i, 37.45, 126.60], + [/평택|당진/i, 36.97, 126.83], + [/대산/i, 36.98, 126.35], + [/군산/i, 35.97, 126.65], + [/목포/i, 34.79, 126.38], + [/완도/i, 34.31, 126.76], + [/여수|광양/i, 34.74, 127.74], + [/통영/i, 34.85, 128.43], + [/거제/i, 34.88, 128.62], + [/마산|창원/i, 35.08, 128.60], + [/부산/i, 35.10, 129.04], + [/울산/i, 35.51, 129.39], + [/포항/i, 36.03, 129.37], + [/울릉/i, 37.48, 130.91], + [/독도/i, 37.24, 131.87], + [/묵호|동해시/i, 37.55, 129.11], + [/강릉/i, 37.75, 128.90], + [/속초/i, 38.20, 128.59], + [/제주/i, 33.51, 126.53], + [/서귀포/i, 33.24, 126.56], + [/마라도/i, 33.11, 126.27], + [/추자/i, 33.95, 126.30], + [/흑산/i, 34.68, 125.44], + [/백령/i, 37.97, 124.72], + [/연평/i, 37.67, 125.70], + [/대청/i, 37.83, 124.72], + [/진도/i, 34.38, 126.31], + [/태안/i, 36.75, 126.30], + [/보령/i, 36.35, 126.59], + [/서해|서쪽.*바다/i, 36.50, 125.50], + [/남해|남쪽.*바다/i, 34.50, 128.00], + [/동해|동쪽.*바다/i, 37.00, 130.00], + [/대한해협/i, 34.00, 128.50], + [/제주해협/i, 33.50, 126.80], + [/호르무즈|Hormuz/i, 26.56, 56.25], + [/페르시아만|Persian Gulf/i, 27.00, 51.50], +]; + +/** 제목에서 위치 추출 */ +export function extractLocation(title: string): { lat: number; lng: number } | null { + for (const [pattern, lat, lng] of KOREA_LOCATIONS) { + if (pattern.test(title)) return { lat, lng }; + } + return null; +} + +// ── Category classification by keywords ── +const CATEGORY_RULES: [RegExp, OsintItem['category']][] = [ + // Maritime-specific (must come before general shipping) + [/해양사고|해난|좌초|침몰|전복|충돌사고|구조작업|해상사고|조난|실종.*선|표류|구명|인명사고|maritime accident|collision.*vessel|capsiz|grounding|sinking|rescue.*sea|distress/i, 'maritime_accident'], + [/어선|어업|어획|수산|조업|불법조업|중국어선|오징어|꽃게|해삼|어민|fishing|trawler|illegal fishing|IUU|poaching|fishery|fish.*boat/i, 'fishing'], + [/해상교통|VTS|항로|선박통항|교통관제|해상안전|입출항|항만.*교통|해사|AIS.*추적|해양수산부|해양경찰청|해경|vessel traffic|sea lane|port congestion|maritime traffic|shipping lane|navigation.*safety|Ministry of Oceans|Korea Coast Guard/i, 'maritime_traffic'], + [/\b(strike|missile|attack|bomb|military|war|defense|weapon|drone|fighter|navy|army|air\s?force|IRGC|pentagon|carrier|destroyer|intercept|airstrike|combat|troop)\b/i, 'military'], + [/\b(oil|crude|WTI|brent|OPEC|barrel|petroleum|gas|LNG|energy|refiner|pipeline|tanker|fuel)\b/i, 'oil'], + [/\b(diplomacy|sanction|UN|NATO|treaty|negotiat|summit|ambassador|ceasefire|peace|talk|deal|accord)\b/i, 'diplomacy'], + [/\b(ship|vessel|maritime|strait|hormuz|port|cargo|shipping|blockade|naval|fleet|escort|piracy|AIS)\b/i, 'shipping'], + [/\b(nuclear|uranium|centrifuge|enrichment|IAEA|nonproliferation|warhead|plutonium)\b/i, 'nuclear'], +]; + +function classifyArticle(title: string): OsintItem['category'] { + for (const [pattern, cat] of CATEGORY_RULES) { + if (pattern.test(title)) return cat; + } + return 'general'; +} + +// ── GDELT DOC 2.0 API ── +// Free, no auth, returns JSON with articles matching keywords +// 이란 상황: 호르무즈 해협 중심 +const GDELT_KEYWORDS_IRAN = '"Strait of Hormuz" OR Hormuz OR "Persian Gulf" OR Iran OR IRGC OR "oil tanker" OR "Gulf shipping" OR "oil price" OR "Middle East oil"'; +// 한국 현황: 해양사고/해상교통/어선/수산/항만/해상안전 +const GDELT_KEYWORDS_KOREA = '"Korea coast guard" OR "Korea maritime accident" OR "Korea ship collision" OR "Korea vessel rescue" OR "Korea illegal fishing" OR "Korea NLL" OR "Korea Dokdo" OR "Korea sea patrol" OR "Korea ship sinking"'; + +const GNEWS_KR_IRAN = '호르무즈 해협 OR 이란 유조선 OR 페르시아만 OR 중동 원유 OR 호르무즈 봉쇄 OR 이란 해군'; +const GNEWS_KR_KOREA = '해양사고 OR 해상구조 OR 해양경찰 OR 해양경찰청 OR 선박충돌 OR 선박좌초 OR 해상안전 OR 불법조업 OR 해상교통관제 OR 선박침몰 OR 해경 단속 OR NLL OR 독도 영해'; + +const GNEWS_EN_IRAN = '"Strait of Hormuz" OR "Persian Gulf" OR "Iran oil" OR "Gulf tanker" OR "Iran navy" OR "Hormuz blockade"'; +const GNEWS_EN_KOREA = '"Korea maritime accident" OR "Korea fishing" OR "Korea port" OR "Korea coast guard" OR "Korea vessel traffic" OR "Korea sea safety"'; + +async function fetchGDELT(keywords: string): Promise { + const query = encodeURIComponent(keywords); + const url = `/api/gdelt/api/v2/doc/doc?query=${query}&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h`; + + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`GDELT ${res.status}`); + const data = await res.json(); + + if (!data.articles || !Array.isArray(data.articles)) return []; + + return data.articles.map((a: Record, i: number) => { + const title = a.title || 'Untitled'; + const loc = extractLocation(title); + return { + id: `gdelt-${i}-${a.seendate || Date.now()}`, + timestamp: a.seendate ? new Date(a.seendate.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/, '$1-$2-$3T$4:$5:$6Z')).getTime() : Date.now(), + title, + source: a.domain || a.sourcecountry || 'Unknown', + url: a.url || '', + category: classifyArticle(title), + language: (a.language === 'Korean' ? 'ko' : a.language === 'English' ? 'en' : 'other') as OsintItem['language'], + imageUrl: a.socialimage || undefined, + ...(loc ? { lat: loc.lat, lng: loc.lng } : {}), + }; + }); + } catch (err) { + console.warn('GDELT fetch failed:', err); + return []; + } +} + +// ── Google News RSS (Korean) ── +async function fetchGoogleNewsKR(keywords: string): Promise { + const query = encodeURIComponent(keywords); + const url = `/api/rss/rss/search?q=${query}&hl=ko&gl=KR&ceid=KR:ko`; + + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Google RSS ${res.status}`); + const text = await res.text(); + + const parser = new DOMParser(); + const xml = parser.parseFromString(text, 'text/xml'); + const items = xml.querySelectorAll('item'); + + const results: OsintItem[] = []; + items.forEach((item, i) => { + const title = item.querySelector('title')?.textContent || ''; + const link = item.querySelector('link')?.textContent || ''; + const pubDate = item.querySelector('pubDate')?.textContent || ''; + const source = item.querySelector('source')?.textContent || ''; + + const loc = extractLocation(title); + results.push({ + id: `gnews-kr-${i}-${Date.now()}`, + timestamp: pubDate ? new Date(pubDate).getTime() : Date.now(), + title, + source: source || 'Google News', + url: link, + category: classifyArticle(title), + language: 'ko', + ...(loc ? { lat: loc.lat, lng: loc.lng } : {}), + }); + }); + + return results; + } catch (err) { + console.warn('Google News KR fetch failed:', err); + return []; + } +} + +// ── Google News RSS (English) ── +async function fetchGoogleNewsEN(keywords: string): Promise { + const query = encodeURIComponent(keywords); + const url = `/api/rss/rss/search?q=${query}&hl=en&gl=US&ceid=US:en`; + + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Google RSS EN ${res.status}`); + const text = await res.text(); + + const parser = new DOMParser(); + const xml = parser.parseFromString(text, 'text/xml'); + const items = xml.querySelectorAll('item'); + + const results: OsintItem[] = []; + items.forEach((item, i) => { + const title = item.querySelector('title')?.textContent || ''; + const link = item.querySelector('link')?.textContent || ''; + const pubDate = item.querySelector('pubDate')?.textContent || ''; + const source = item.querySelector('source')?.textContent || ''; + + const loc = extractLocation(title); + results.push({ + id: `gnews-en-${i}-${Date.now()}`, + timestamp: pubDate ? new Date(pubDate).getTime() : Date.now(), + title, + source: source || 'Google News', + url: link, + category: classifyArticle(title), + language: 'en', + ...(loc ? { lat: loc.lat, lng: loc.lng } : {}), + }); + }); + + return results; + } catch (err) { + console.warn('Google News EN fetch failed:', err); + return []; + } +} + +// ── X.com (Twitter) — U.S. Central Command (@CENTCOM) RSS ── +// 여러 Nitter 인스턴스 + RSSHub fallback +// 중동 지역 위치 패턴 (CENTCOM 포스트용) +const ME_LOCATIONS: [RegExp, number, number][] = [ + [/Iran|Tehran|이란|테헤란/i, 35.69, 51.39], + [/Hormuz|호르무즈/i, 26.56, 56.25], + [/Iraq|Baghdad|이라크|바그다드/i, 33.31, 44.37], + [/Syria|시리아|Damascus/i, 33.51, 36.28], + [/Yemen|Houthi|예멘|후티|Sanaa/i, 15.37, 44.19], + [/Bahrain|바레인/i, 26.22, 50.60], + [/Qatar|카타르|Al Udeid/i, 25.12, 51.32], + [/UAE|Abu Dhabi|아부다비|Dubai/i, 24.25, 54.55], + [/Kuwait|쿠웨이트/i, 29.35, 47.52], + [/Erbil|에르빌/i, 36.19, 44.01], + [/Lebanon|Hezbollah|레바논|헤즈볼라/i, 33.85, 35.86], + [/Red Sea|홍해/i, 20.00, 38.00], + [/Gulf of Oman|오만만/i, 24.50, 58.50], + [/Bandar Abbas|반다르아바스/i, 27.18, 56.28], + [/Natanz|나탄즈/i, 33.72, 51.73], + [/Isfahan|이스파한/i, 32.65, 51.67], +]; + +function extractMELocation(text: string): { lat: number; lng: number } | null { + for (const [pattern, lat, lng] of ME_LOCATIONS) { + if (pattern.test(text)) return { lat, lng }; + } + return null; +} + +// ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ── +// Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리 +const CENTCOM_POSTS: { text: string; date: string; url: string }[] = [ + // ── 3월 16일 (D+16) 최신 ── + { + text: 'CENTCOM: Isfahan military complex struck overnight by B-2 stealth bombers. 15 targets destroyed including underground command bunkers', + date: '2026-03-16T06:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'UPDATE: Iran FM Araghchi states "We never asked for a ceasefire." CENTCOM forces maintain full operational tempo across theater', + date: '2026-03-16T03:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 15일 (D+15) ── + { + text: 'CENTCOM: Over 5,000 targets struck since Operation Epic Fury began. Iran offensive missile capability assessed below 3%', + date: '2026-03-15T18:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'NATO air defenses intercept THIRD Iranian ballistic missile over Turkish airspace near Hatay Province. No casualties reported', + date: '2026-03-15T12:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM confirms 8 US service members KIA, ~140 WIA since start of operations. KC-135 crash in Iraq accounts for 6 of the fatalities', + date: '2026-03-15T08:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 14일 (D+14) ── + { + text: 'IEA announces largest-ever emergency oil stockpile release: 400 million barrels. Brent crude holds above $103/barrel despite release', + date: '2026-03-14T20:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM: Trump calls on Korea, Japan, China, France, UK to send warships to Strait of Hormuz to protect commercial shipping', + date: '2026-03-14T14:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'Pentagon vows to ramp up military campaign against Iran. New supreme leader Mojtaba Khamenei warns retaliatory attacks will continue', + date: '2026-03-14T06:00:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 13일 (D+13) ── + { + text: 'CENTCOM: Hormuz Strait commercial shipping corridor now 95% mine-free. 28 mines cleared in past 72hrs by US, UK, French minesweeping forces', + date: '2026-03-13T08:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM forces intercept Iranian Shahed-136 drone swarm targeting Al Udeid Air Base, Qatar. All 8 drones destroyed by Patriot & C-RAM systems', + date: '2026-03-13T05:30:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'UPDATE: IRGC naval forces have effectively ceased offensive operations in the Strait of Hormuz. Remaining fast boats sheltering in Bandar Abbas harbor', + date: '2026-03-13T03:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'CENTCOM confirms successful strike on last known IRGC mobile missile TEL in western Iran near Tabriz. Iran\'s offensive missile capability assessed at less than 5%', + date: '2026-03-13T00:30:00Z', + url: 'https://x.com/CENTCOM', + }, + // ── 3월 12일 (D+12) ── + { + text: 'U.S., UK, and French naval forces establish Joint Maritime Security Corridor through Strait of Hormuz. First commercial convoy of 12 tankers transits safely', + date: '2026-03-12T18:00:00Z', + url: 'https://x.com/CENTCOM', + }, + { + text: 'USS Michael Murphy (DDG 112) engaged and sank five IRGC fast attack craft in the Strait of Hormuz after they attempted to harass a commercial tanker convoy', + date: '2026-03-12T04:30:00Z', + url: 'https://x.com/CENTCOM', + }, +]; + +async function fetchXCentcom(): Promise { + const results: OsintItem[] = []; + + // 1) Nitter/RSSHub 시도 (동작할 경우) + const rssUrls = [ + `/api/nitter1/CENTCOM/rss`, + `/api/nitter2/CENTCOM/rss`, + `/api/nitter3/CENTCOM/rss`, + `/api/rsshub/twitter/user/CENTCOM`, + ]; + + for (const url of rssUrls) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error' }); + if (!res.ok) continue; + const text = await res.text(); + const parser = new DOMParser(); + const xml = parser.parseFromString(text, 'text/xml'); + let items = xml.querySelectorAll('item'); + if (items.length === 0) items = xml.querySelectorAll('entry'); + if (items.length === 0) continue; + + items.forEach((item, i) => { + const rawTitle = item.querySelector('title')?.textContent || ''; + const link = item.querySelector('link')?.textContent + || item.querySelector('link')?.getAttribute('href') || ''; + const pubDate = item.querySelector('pubDate')?.textContent + || item.querySelector('published')?.textContent || ''; + const desc = item.querySelector('description')?.textContent + || item.querySelector('content')?.textContent || ''; + + const cleanTitle = rawTitle.replace(/<[^>]+>/g, '').trim(); + const cleanDesc = desc.replace(/<[^>]+>/g, '').trim(); + const title = cleanTitle || cleanDesc.slice(0, 280); + if (!title || title.length < 5) return; + + const loc = extractLocation(title + ' ' + cleanDesc) + || extractMELocation(title + ' ' + cleanDesc) + || { lat: 26.0, lng: 54.0 }; + const xUrl = link.replace(/nitter\.[^/]+/, 'x.com').replace(/xcancel\.com/, 'x.com') + || 'https://x.com/CENTCOM'; + + results.push({ + id: `x-centcom-${i}-${Date.now()}`, + timestamp: pubDate ? new Date(pubDate).getTime() : Date.now(), + title: `[CENTCOM] ${title}`, + source: 'X @CENTCOM', + url: xUrl, + category: classifyArticle(title + ' ' + cleanDesc) === 'general' ? 'military' : classifyArticle(title + ' ' + cleanDesc), + language: 'en', + lat: loc.lat, + lng: loc.lng, + }); + }); + + if (results.length > 0) { + console.log(`[OSINT] X.com @CENTCOM: ${results.length} posts from RSS`); + return results; + } + } catch { + continue; + } + } + + // 2) RSS 실패 시 → 수동 관리 CENTCOM 게시물 사용 + console.log('[OSINT] X.com @CENTCOM: RSS unavailable, using curated posts'); + return CENTCOM_POSTS.map((post, i) => { + const loc = extractMELocation(post.text) || { lat: 26.0, lng: 54.0 }; + return { + id: `x-centcom-curated-${i}`, + timestamp: new Date(post.date).getTime(), + title: `[CENTCOM] ${post.text}`, + source: 'X @CENTCOM', + url: post.url, + category: classifyArticle(post.text) === 'general' ? 'military' : classifyArticle(post.text), + language: 'en' as const, + lat: loc.lat, + lng: loc.lng, + }; + }); +} + +// ── Pinned OSINT articles (manually curated) ── +const PINNED_IRAN: OsintItem[] = [ + // ── 3월 16일 최신 ── + { + id: 'pinned-kr-isfahan-0316', + timestamp: new Date('2026-03-16T10:00:00+09:00').getTime(), + title: '[속보] 미-이스라엘, 이스파한 군사시설 야간 폭격… 이란 사망자 1,444명 돌파', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'military', + language: 'ko', + lat: 32.65, + lng: 51.67, + }, + { + id: 'pinned-kr-ceasefire-0316', + timestamp: new Date('2026-03-16T08:00:00+09:00').getTime(), + title: '이란 외무 "휴전 요청한 적 없다"… 트럼프 "이란이 딜 원해" 주장 정면 반박', + source: 'VOA Korea', + url: 'https://www.voakorea.com', + category: 'diplomacy', + language: 'ko', + lat: 35.69, + lng: 51.39, + }, + // ── 3월 15일 ── + { + id: 'pinned-kr-hormuz-派兵-0315', + timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(), + title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의', + source: '뉴데일리', + url: 'https://www.newdaily.co.kr', + category: 'military', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pinned-kr-dispatch-debate-0315', + timestamp: new Date('2026-03-15T15:00:00+09:00').getTime(), + title: '[사설] 미국의 호르무즈 파병 요청, 이란전 참전 비칠 수 있어… 신중 대응 필요', + source: '경향신문', + url: 'https://www.khan.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 37.57, + lng: 126.98, + }, + { + id: 'pinned-kr-turkey-nato-0315', + timestamp: new Date('2026-03-15T12:00:00+09:00').getTime(), + title: 'NATO 방공망, 튀르키예 상공서 이란 탄도미사일 3번째 요격… Article 5 논의 가속', + source: 'BBC Korea', + url: 'https://www.bbc.com/korean', + category: 'military', + language: 'ko', + lat: 37.00, + lng: 35.43, + }, + { + id: 'pinned-kr-kospi-0315', + timestamp: new Date('2026-03-15T09:00:00+09:00').getTime(), + title: '이란 전쟁 장기화에 코스피 4,800선 위협… 시총 500조원 이상 증발', + source: '뉴데일리', + url: 'https://biz.newdaily.co.kr', + category: 'oil', + language: 'ko', + lat: 37.57, + lng: 126.98, + }, + // ── 3월 14일 ── + { + id: 'pinned-kr-iea-oil-0314', + timestamp: new Date('2026-03-14T20:00:00+09:00').getTime(), + title: 'IEA 사상 최대 4억 배럴 비축유 방출 결정… 브렌트유 $103 여전히 고공행진', + source: '매일경제', + url: 'https://www.mk.co.kr', + category: 'oil', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pinned-kr-hormuz-shutdown-0314', + timestamp: new Date('2026-03-14T14:00:00+09:00').getTime(), + title: '호르무즈 해협 "사실상 마비"… 한국 원유 70% 중동산, 수급 비상등', + source: 'iFM', + url: 'https://news.ifm.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pinned-kr-tanker-0314', + timestamp: new Date('2026-03-14T10:00:00+09:00').getTime(), + title: '한국 해운사 시노코르, 유조선 용선료 10배 폭등 $50만/일… 전쟁 특수', + source: 'Bloomberg Korea', + url: 'https://www.bloomberg.com', + category: 'shipping', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + // ── 3월 13일 ── + { + id: 'pinned-kr-hormuz-0313a', + timestamp: new Date('2026-03-13T09:00:00+09:00').getTime(), + title: '[속보] 호르무즈 해협 안전항행 회랑 설정… 한국행 유조선 3척 통과 성공', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pinned-kr-hormuz-0313b', + timestamp: new Date('2026-03-13T08:00:00+09:00').getTime(), + title: '[단독] 청해부대 "문무대왕함" 호르무즈 해협 진입… 한국 선박 호위 개시', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'military', + language: 'ko', + lat: 26.30, + lng: 56.50, + }, + { + id: 'pinned-kr-ship-0312', + timestamp: new Date('2026-03-12T18:00:00+09:00').getTime(), + title: '[긴급] 한국 LNG선 "SK이노베이션호" 이란 드론 피격… 선체 경미 손상, 승조원 무사', + source: 'SBS', + url: 'https://news.sbs.co.kr', + category: 'shipping', + language: 'ko', + lat: 26.20, + lng: 56.60, + }, +]; + +// ── Pinned OSINT articles (Korea maritime/security) ── +const PINNED_KOREA: OsintItem[] = [ + // ── 3월 15일 최신 ── + { + id: 'pin-kr-nk-missile-0315', + timestamp: new Date('2026-03-15T07:00:00+09:00').getTime(), + title: '[속보] 북한, 동해상으로 탄도미사일 약 10발 발사… 350km 비행', + source: '연합뉴스', + url: 'https://www.yna.co.kr', + category: 'military', + language: 'ko', + lat: 39.00, + lng: 127.00, + }, + { + id: 'pin-kr-nk-kimyojong-0315', + timestamp: new Date('2026-03-15T10:00:00+09:00').getTime(), + title: '김여정 "전술핵으로 상대 군사인프라 생존 불가"… 자유의 방패 훈련 반발', + source: 'KBS', + url: 'https://news.kbs.co.kr', + category: 'military', + language: 'ko', + lat: 39.00, + lng: 125.75, + }, + { + id: 'pin-kr-hormuz-deploy-0315', + timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(), + title: '트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대 파병 논의 본격화', + source: '뉴데일리', + url: 'https://www.newdaily.co.kr', + category: 'military', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pin-kr-kctu-0315', + timestamp: new Date('2026-03-15T14:00:00+09:00').getTime(), + title: '민주노총 "호르무즈 파병은 침략전쟁 참전"… 파병 반대 성명', + source: '경향신문', + url: 'https://www.khan.co.kr', + category: 'diplomacy', + language: 'ko', + lat: 37.57, + lng: 126.98, + }, + // ── 3월 14일 ── + { + id: 'pin-kr-hormuz-zero-0314', + timestamp: new Date('2026-03-14T20:00:00+09:00').getTime(), + title: '[긴급] 호르무즈 해협 통항 제로… AIS 기준 양방향 선박 이동 완전 중단', + source: 'News1', + url: 'https://www.news1.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, + { + id: 'pin-kr-freedom-shield-0314', + timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(), + title: '한미 자유의 방패 2026 훈련 진행 중… 미군 일부 중동 전환 배치에도 "방위태세 문제 없어"', + source: 'MBC', + url: 'https://imnews.imbc.com', + category: 'military', + language: 'ko', + lat: 37.50, + lng: 127.00, + }, + { + id: 'pin-kr-hmm-0314', + timestamp: new Date('2026-03-14T15:00:00+09:00').getTime(), + title: 'HMM 선박 6~7척 호르무즈 인근 대기 중… 해운업계 운임 50~80% 급등', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 26.00, + lng: 56.00, + }, + // ── 3월 13일 ── + { + id: 'pin-kr-fuel-cap-0313', + timestamp: new Date('2026-03-13T12:00:00+09:00').getTime(), + title: '[속보] 정부, 1997년 이후 첫 유류 가격 상한제 시행… 휘발유 1,724원/L 상한', + source: '서울경제', + url: 'https://en.sedaily.com', + category: 'oil', + language: 'ko', + lat: 37.57, + lng: 126.98, + }, + { + id: 'pin-kr-coast-guard-0313', + timestamp: new Date('2026-03-13T08:00:00+09:00').getTime(), + title: '해경, 서해5도 꽃게 시즌 대비 중국 불법어선 단속 강화… 6척 나포, 241척 검문', + source: '아시아경제', + url: 'https://www.asiae.co.kr', + category: 'maritime_traffic', + language: 'ko', + lat: 37.67, + lng: 125.70, + }, + { + id: 'pin-kr-nk-destroyer-0312', + timestamp: new Date('2026-03-12T16:00:00+09:00').getTime(), + title: '북한 최현급 구축함, 순항미사일 시험 발사 확인… VLS 88셀로 증강', + source: 'AEI/국방일보', + url: 'https://www.aei.org', + category: 'military', + language: 'ko', + lat: 39.80, + lng: 127.50, + }, + { + id: 'pin-kr-oil-reserve-0312', + timestamp: new Date('2026-03-12T14:00:00+09:00').getTime(), + title: '한국, IEA 공조로 전략비축유 역대 최대 2,246만 배럴 방출… 잔여 7,764만 배럴', + source: '한국경제', + url: 'https://www.hankyung.com', + category: 'oil', + language: 'ko', + lat: 36.97, + lng: 126.83, + }, + { + id: 'pin-kr-mof-emergency-0312', + timestamp: new Date('2026-03-12T10:00:00+09:00').getTime(), + title: '해양수산부 24시간 비상체제 가동… 호르무즈 인근 한국선박 40척 안전관리', + source: '해사신문', + url: 'https://www.haesanews.com', + category: 'shipping', + language: 'ko', + lat: 36.00, + lng: 127.00, + }, + { + id: 'pin-kr-chinese-fishing-0311', + timestamp: new Date('2026-03-11T09:00:00+09:00').getTime(), + title: '서해 NLL 인근 중국 불법어선 하루 200척 이상… "어획량 1/3로 급감" 어민 호소', + source: '아시아A', + url: 'https://www.asiaa.co.kr', + category: 'fishing', + language: 'ko', + lat: 37.67, + lng: 125.50, + }, + { + id: 'pin-kr-spring-safety-0311', + timestamp: new Date('2026-03-11T08:00:00+09:00').getTime(), + title: '해수부, 봄철 해양사고 예방대책 시행… 안개 충돌사고 대비 인천항 무인순찰로봇 도입', + source: 'iFM', + url: 'https://news.ifm.kr', + category: 'maritime_traffic', + language: 'ko', + lat: 37.45, + lng: 126.60, + }, + { + id: 'pin-kr-ships-hormuz-0311', + timestamp: new Date('2026-03-11T07:00:00+09:00').getTime(), + title: '호르무즈 해협 내 한국 국적선 26척·한국인 144명 체류 확인… 미사일 100m 근접 피격 증언', + source: '서울신문', + url: 'https://www.seoul.co.kr', + category: 'shipping', + language: 'ko', + lat: 26.56, + lng: 56.25, + }, +]; + +// ── Main fetch: merge all sources, deduplicate, sort by time ── +export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise { + const gdeltKw = focus === 'korea' ? GDELT_KEYWORDS_KOREA : GDELT_KEYWORDS_IRAN; + const gnKrKw = focus === 'korea' ? GNEWS_KR_KOREA : GNEWS_KR_IRAN; + const gnEnKw = focus === 'korea' ? GNEWS_EN_KOREA : GNEWS_EN_IRAN; + + const sources = [ + fetchGDELT(gdeltKw), + fetchGoogleNewsKR(gnKrKw), + fetchGoogleNewsEN(gnEnKw), + ...(focus === 'iran' ? [fetchXCentcom()] : []), + ]; + const results = await Promise.allSettled(sources); + + const pinned = focus === 'iran' ? PINNED_IRAN : PINNED_KOREA; + const all: OsintItem[] = [ + ...pinned, + ...results.flatMap(r => r.status === 'fulfilled' ? r.value : []), + ]; + + // Filter out irrelevant articles for Korea feed + const KOREA_NOISE = /쿠팡|수산시장|맛집|레시피|요리|축제|관광|여행|부동산|아파트|주식|코스피|연예|드라마|영화|스포츠|야구|축구|골프|coupang|recipe|tourism|real estate/i; + const filtered = focus === 'korea' + ? all.filter(item => !KOREA_NOISE.test(item.title)) + : all; + + // Deduplicate by similar title (first 40 chars) + const seen = new Set(); + const unique = filtered.filter(item => { + const key = item.title.slice(0, 40).toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + // Sort newest first + unique.sort((a, b) => b.timestamp - a.timestamp); + + return unique.slice(0, 50); // cap at 50 items +} diff --git a/frontend/src/services/piracy.ts b/frontend/src/services/piracy.ts new file mode 100644 index 0000000..727aafa --- /dev/null +++ b/frontend/src/services/piracy.ts @@ -0,0 +1,61 @@ +// ═══ 해적 위험 해역 데이터 ═══ + +export interface PiracyZone { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + level: 'critical' | 'high' | 'moderate'; + description: string; + detail: string; + recentIncidents?: number; // 최근 1년 발생 건수 +} + +export const PIRACY_ZONES: PiracyZone[] = [ + { + id: 'pz-singapore', + name: 'Singapore Strait', + nameKo: '싱가포르 해협', + lat: 1.18, + lng: 104.00, + level: 'critical', + description: '아시아 해적 사건 80% 이상 집중', + detail: '인도네시아 빈탄섬·바탐섬 북쪽 해역, 필립 채널(Phillip Channel) 부근. 야간(20:00~06:00) 항해 중 선박 침입하여 엔진 부품·선원 소지품 절도. 최근 총기 소지 비율 증가 추세.', + recentIncidents: 58, + }, + { + id: 'pz-malacca', + name: 'Straits of Malacca', + nameKo: '말라카 해협', + lat: 2.50, + lng: 101.20, + level: 'high', + description: '세계 물동량 핵심 요충지, 좁은 수로', + detail: '싱가포르 해협과 연결되는 좁은 수로. 세계 물동량의 약 25% 통과. 좁은 통로 특성상 선박 감속 필수 → 해적 표적 용이. 인도네시아·말레이시아·태국 해역 접경 지대.', + recentIncidents: 22, + }, + { + id: 'pz-sulu', + name: 'Sulu-Celebes Seas', + nameKo: '술루-셀레베스해', + lat: 5.80, + lng: 121.00, + level: 'moderate', + description: '무장 단체 선원 납치 위협', + detail: '필리핀 남부~말레이시아 사바주 동부 해역. 아부 사야프(Abu Sayyaf) 등 무장 단체의 선원 납치 사건 빈발 지역. 최근 3국 합동순찰(INDOMALPHI) 강화로 감소 추세이나 여전히 경계 필요.', + recentIncidents: 8, + }, +]; + +export const PIRACY_LEVEL_COLOR: Record = { + critical: '#ef4444', + high: '#f97316', + moderate: '#eab308', +}; + +export const PIRACY_LEVEL_LABEL: Record = { + critical: '최고위험', + high: '고위험', + moderate: '주의', +}; diff --git a/frontend/src/services/propagation.ts b/frontend/src/services/propagation.ts new file mode 100644 index 0000000..ae83690 --- /dev/null +++ b/frontend/src/services/propagation.ts @@ -0,0 +1,451 @@ +import type { Aircraft, Ship } from '../types'; + +// T0 = main strike moment +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; +const DEG2RAD = Math.PI / 180; + +// ── Waypoint system ────────────────────────────────── +// Each waypoint: [hoursFromT0, lat, lng] +type WP = [number, number, number]; + +// Interpolate position along waypoints at a given time +function interpWaypoints(wps: WP[], hoursFromT0: number): { lat: number; lng: number; heading: number } { + // Clamp to waypoint range + if (hoursFromT0 <= wps[0][0]) { + const next = wps.length > 1 ? wps[1] : wps[0]; + return { lat: wps[0][1], lng: wps[0][2], heading: calcHeading(wps[0][1], wps[0][2], next[1], next[2]) }; + } + if (hoursFromT0 >= wps[wps.length - 1][0]) { + const prev = wps.length > 1 ? wps[wps.length - 2] : wps[wps.length - 1]; + const last = wps[wps.length - 1]; + return { lat: last[1], lng: last[2], heading: calcHeading(prev[1], prev[2], last[1], last[2]) }; + } + + // Find segment + for (let i = 0; i < wps.length - 1; i++) { + const [t0, lat0, lng0] = wps[i]; + const [t1, lat1, lng1] = wps[i + 1]; + if (hoursFromT0 >= t0 && hoursFromT0 <= t1) { + const frac = (hoursFromT0 - t0) / (t1 - t0); + const lat = lat0 + (lat1 - lat0) * frac; + const lng = lng0 + (lng1 - lng0) * frac; + const heading = calcHeading(lat0, lng0, lat1, lng1); + return { lat, lng, heading }; + } + } + + // Fallback + return { lat: wps[0][1], lng: wps[0][2], heading: 0 }; +} + +function calcHeading(lat1: number, lng1: number, lat2: number, lng2: number): number { + const dLng = (lng2 - lng1) * DEG2RAD; + const y = Math.sin(dLng) * Math.cos(lat2 * DEG2RAD); + const x = Math.cos(lat1 * DEG2RAD) * Math.sin(lat2 * DEG2RAD) - + Math.sin(lat1 * DEG2RAD) * Math.cos(lat2 * DEG2RAD) * Math.cos(dLng); + return ((Math.atan2(y, x) / DEG2RAD) + 360) % 360; +} + +// Generate trail by sampling recent positions +function generateTrail(wps: WP[], hoursFromT0: number, count: number, intervalMin: number): [number, number][] { + const trail: [number, number][] = []; + for (let i = count; i >= 0; i--) { + const t = hoursFromT0 - (i * intervalMin) / 60; + const pos = interpWaypoints(wps, t); + trail.push([pos.lat, pos.lng]); + } + return trail; +} + +// ── Aircraft Flight Plans ──────────────────────────── +// Waypoints: [hoursFromT0, lat, lng] + +const FLIGHT_PLANS: Record = { + // ── SURVEILLANCE / ISR ── + + // FORTE12 (RQ-4B Global Hawk) - 24h ISR racetrack over Iraq/Iran border + 'ae1461': [ + [-12, 33.0, 42.0], [-10, 34.0, 44.0], [-8, 33.0, 46.0], [-6, 34.0, 44.0], + [-4, 33.0, 42.0], [-2, 34.0, 44.0], [0, 33.5, 45.0], [2, 33.0, 42.0], + [4, 34.0, 44.0], [6, 33.0, 46.0], [8, 34.0, 44.0], [10, 33.0, 42.0], + [12, 34.0, 44.0], + ], + // FORTE13 (MQ-4C Triton) - Maritime ISR racetrack over Persian Gulf + 'ae1462': [ + [-10, 27.0, 54.0], [-8, 26.5, 56.0], [-6, 27.5, 55.0], [-4, 26.5, 56.5], + [-2, 27.0, 54.0], [0, 26.5, 56.0], [2, 27.5, 55.0], [4, 26.5, 56.5], + [6, 27.0, 54.0], [8, 26.5, 56.0], [10, 27.5, 55.0], + ], + // TOXIN31 (RC-135V) - SIGINT orbit from Crete over Eastern Med + 'ae5420': [ + [-10, 35.5, 26.0], [-8, 35.0, 30.0], [-6, 35.5, 34.0], [-4, 35.0, 30.0], + [-2, 35.5, 26.0], [0, 35.0, 30.0], [2, 35.5, 34.0], [4, 35.0, 30.0], + [6, 35.5, 26.0], [8, 35.0, 30.0], + ], + // SNTRY60 (E-3G AWACS) - AEW orbit over northern Iraq + 'ae0005': [ + [-6, 35.0, 43.0], [-4, 34.5, 44.5], [-2, 35.0, 43.0], [-1, 34.5, 44.5], + [0, 35.0, 43.5], [1, 34.5, 44.5], [2, 35.0, 43.0], [4, 34.5, 44.5], + [6, 35.0, 43.0], [8, 34.5, 44.5], [10, 35.0, 43.0], + ], + // DRAGON01 (U-2S) - Ultra-high altitude recon over Iran + 'ae0006': [ + [-8, 24.2, 54.7], [-6, 28.0, 52.0], [-4, 31.0, 53.0], [-2, 33.0, 52.0], + [0, 30.5, 52.0], [2, 28.0, 53.0], [4, 25.0, 54.0], + ], + // REAPER41 (MQ-9A) - Armed ISR racetrack over western Iraq + 'ae0007': [ + [-6, 33.0, 42.0], [-4, 32.0, 41.0], [-2, 33.0, 42.5], [0, 32.0, 41.5], + [2, 33.0, 42.0], [4, 32.0, 41.0], [6, 33.0, 42.5], [8, 32.0, 41.5], + ], + // REAPER42 (MQ-9A) - Armed ISR over Strait of Hormuz + 'ae0008': [ + [-4, 26.5, 56.5], [-2, 27.0, 55.5], [0, 26.5, 56.0], [2, 27.0, 55.5], + [4, 26.5, 56.5], [6, 27.0, 55.5], [8, 26.5, 56.0], [10, 27.0, 55.5], + ], + + // ── TANKERS ── + + // ETHYL71 (KC-135R) - Refueling orbit over western Iraq + 'ae0001': [ + [-8, 32.5, 40.0], [-6, 31.5, 41.5], [-4, 32.5, 40.0], [-2, 31.5, 41.5], + [-1, 32.0, 40.5], [0, 31.5, 41.5], [1, 32.5, 40.0], [3, 31.5, 41.5], + [5, 32.5, 40.0], [6, 32.0, 40.5], + ], + // STEEL55 (KC-46A) - B-2 tanker support orbit over northern Gulf + 'ae0009': [ + [-10, 28.0, 49.0], [-8, 29.0, 50.5], [-6, 28.0, 49.0], [-4, 29.0, 50.5], + [-2, 28.0, 49.0], [0, 29.0, 50.5], [2, 28.0, 49.0], [4, 28.5, 50.0], + ], + // PACK22 (KC-135R) - Refueling over eastern Jordan (Israeli strikes) + 'ae0003': [ + [-3, 32.0, 37.5], [-1.5, 33.0, 39.0], [0, 32.0, 37.5], [1.5, 33.0, 39.0], + [3, 32.0, 37.5], [4, 32.5, 38.0], + ], + // NCHO45 (KC-10A) - Refueling orbit over Kuwait/northern Gulf + 'ae0002': [ + [-4, 29.5, 47.0], [-2, 30.2, 48.5], [0, 29.5, 47.0], [1, 30.2, 48.5], + [3, 29.5, 47.0], [5, 30.2, 48.5], + ], + + // ── BOMBERS ── + + // DEATH11 (B-2A Spirit) - Ingress from south, strike Tehran/Isfahan, egress + 'ae2001': [ + [-6, 22.0, 58.0], [-4, 25.0, 55.0], [-2, 28.0, 53.0], [-1, 30.0, 52.0], + [0, 32.5, 51.5], [1, 30.0, 53.0], [2, 26.0, 56.0], + ], + // DEATH12 (B-2A Spirit) - Second bomber, offset route + 'ae2002': [ + [-6, 21.5, 59.0], [-4, 24.5, 56.0], [-2, 27.5, 53.5], [-1, 29.5, 52.5], + [0, 32.0, 52.0], [1, 29.5, 53.5], [2, 25.5, 57.0], + ], + + // ── US FIGHTERS ── + + // RAGE01 (F-22A) - Al Udeid scramble, air superiority sweep + 'ae3001': [ + [-2, 25.2, 51.4], [-1.5, 27.0, 50.0], [-1, 29.0, 48.5], [0, 31.0, 47.0], + [1, 32.5, 48.0], [2, 31.0, 47.0], [3, 28.0, 49.0], [4, 25.5, 51.0], + ], + // RAGE02 (F-22A) - Wingman + 'ae3002': [ + [-2, 25.0, 51.2], [-1.5, 26.8, 49.8], [-1, 28.8, 48.3], [0, 30.8, 46.8], + [1, 32.3, 47.8], [2, 30.8, 46.8], [3, 27.8, 48.8], [4, 25.3, 50.8], + ], + // VIPER11 (F-35A) - Deep strike into Iran from Al Udeid + 'ae3003': [ + [-3, 25.2, 51.4], [-2, 28.0, 50.0], [-1, 31.0, 49.0], [0, 33.0, 50.0], + [1, 31.0, 49.0], [2, 28.0, 50.0], [3, 25.2, 51.4], + ], + // VIPER12 (F-35A) - Wingman + 'ae3004': [ + [-3, 25.0, 51.2], [-2, 27.8, 49.8], [-1, 30.8, 48.8], [0, 32.8, 49.8], + [1, 30.8, 48.8], [2, 27.8, 49.8], [3, 25.0, 51.2], + ], + // IRON41 (F-15E) - Strike Eagles, deep strike + 'ae3005': [ + [-4, 24.2, 54.7], [-3, 27.0, 53.0], [-2, 29.5, 51.0], [-1, 31.5, 50.0], + [0, 33.5, 51.5], [1, 31.0, 50.5], [2, 27.0, 53.0], + ], + // IRON42 (F-15E) - Wingman + 'ae3006': [ + [-4, 24.0, 54.5], [-3, 26.8, 52.8], [-2, 29.3, 50.8], [-1, 31.3, 49.8], + [0, 33.3, 51.3], [1, 30.8, 50.3], [2, 26.8, 52.8], + ], + // NAVY51 (F/A-18F) - Launch from USS Abraham Lincoln, strike & return + 'ae3007': [ + [-1, 23.5, 62.0], [-0.5, 24.5, 59.0], [0, 26.0, 57.0], [1, 27.5, 55.5], + [2, 25.0, 58.0], [3, 23.5, 62.0], + ], + // NAVY52 (F/A-18E) - Lincoln CAP + 'ae3008': [ + [-1, 23.0, 62.5], [-0.5, 24.0, 60.0], [0, 24.5, 59.0], [1, 24.0, 60.5], + [2, 23.5, 61.5], [3, 23.0, 62.5], + ], + + // ── ISRAELI FIGHTERS ── + + // IRON33 (F-35I Adir) - Nevatim → Iran strike → return + 'ae0012': [ + [-2, 31.2, 34.9], [-1.5, 31.8, 36.0], [-1, 32.5, 38.0], [-0.5, 33.0, 42.0], + [0, 33.5, 48.0], [0.5, 33.0, 45.0], [1, 32.5, 40.0], [1.5, 32.0, 37.0], + [2, 31.5, 35.5], [3, 31.2, 34.9], + ], + // IRON34 (F-35I Adir) - Wingman + 'ae4002': [ + [-2, 31.0, 35.2], [-1.5, 31.6, 36.2], [-1, 32.3, 38.2], [-0.5, 32.8, 42.2], + [0, 33.3, 47.8], [0.5, 32.8, 44.8], [1, 32.3, 39.8], [1.5, 31.8, 37.2], + [2, 31.3, 35.7], [3, 31.0, 35.2], + ], + // RAGE22 (F-15I Ra'am) - Ramon → eastern Iraq standoff launch → return + 'ae0011': [ + [-1.5, 30.0, 34.8], [-1, 30.5, 36.0], [-0.5, 31.0, 38.5], [0, 31.5, 40.0], + [0.5, 31.0, 38.5], [1, 30.5, 36.0], [1.5, 30.2, 35.0], [2, 30.0, 34.8], + ], + // RAGE23 (F-15I Ra'am) - Wingman + 'ae4004': [ + [-1.5, 29.8, 35.0], [-1, 30.3, 36.2], [-0.5, 30.8, 38.7], [0, 31.3, 40.2], + [0.5, 30.8, 38.7], [1, 30.3, 36.2], [1.5, 30.0, 35.2], [2, 29.8, 35.0], + ], + // VIPER01 (F-16I Sufa) - CAP orbit over Negev/Golan + 'ae0010': [ + [-2, 30.8, 34.7], [-1.5, 31.5, 35.5], [-1, 32.5, 35.8], [-0.5, 33.0, 35.5], + [0, 32.5, 35.8], [0.5, 31.5, 35.5], [1, 30.8, 34.7], [1.5, 31.5, 35.5], + [2, 32.5, 35.8], [3, 31.5, 35.0], + ], + + // ── CARGO ── + + // RCH882 (C-17A) - Al Udeid → Al Asad + 'ae0004': [ + [-6, 25.1, 51.3], [-4.5, 27.0, 49.0], [-3, 29.0, 47.0], [-1.5, 31.5, 44.5], + [-1, 33.8, 42.4], + ], + // RCH445 (C-17A) - Ramstein → Gulf post-strike resupply + 'ae0013': [ + [2, 36.0, 40.0], [4, 33.0, 43.0], [6, 30.0, 46.0], [8, 27.0, 49.0], + [9, 25.1, 51.3], + ], + // RCH901 (C-5M) - Ramstein → Al Udeid heavy lift + 'ae5001': [ + [-3, 37.0, 30.0], [-1.5, 35.0, 35.0], [0, 33.0, 40.0], [2, 30.0, 45.0], + [4, 27.0, 49.0], [5, 25.1, 51.3], + ], + + // ── CIVILIAN ── + + // QTR8101 - Doha → Amman + '738012': [ + [-11, 25.3, 51.6], [-9, 27.5, 48.0], [-7, 30.0, 43.0], [-5, 31.9, 36.0], + ], + // EK412 - Dubai → Istanbul + '710104': [ + [-8, 25.3, 55.3], [-6, 28.0, 50.0], [-4, 32.0, 44.0], [-2, 37.0, 38.0], + ], + // QTR306 - Doha → London (post-strike, southern routing to avoid Iranian airspace) + '738020': [ + [3, 25.3, 51.6], [5, 26.0, 45.0], [7, 30.0, 38.0], [9, 34.0, 32.0], + [10, 38.0, 28.0], + ], + // ETD55 - Abu Dhabi → Cairo (restricted routing post-strike) + '710110': [ + [3, 24.4, 54.7], [5, 26.0, 48.0], [7, 28.5, 40.0], [9, 30.1, 31.4], + ], +}; + +// ── Aircraft Propagation ───────────────────────────── + +export function propagateAircraft( + baseAircraft: Aircraft[], + currentTime: number, +): Aircraft[] { + const hoursFromT0 = (currentTime - T0) / HOUR; + + // Filter to only aircraft active at this time + const active = baseAircraft.filter(ac => { + if (ac.activeStart != null && currentTime < ac.activeStart) return false; + if (ac.activeEnd != null && currentTime > ac.activeEnd) return false; + return true; + }); + + return active.map(ac => { + const flightPlan = FLIGHT_PLANS[ac.icao24]; + + if (flightPlan) { + // Waypoint-based interpolation + const pos = interpWaypoints(flightPlan, hoursFromT0); + const trail = generateTrail(flightPlan, hoursFromT0, 5, 10); // 5 points, 10min intervals + + return { + ...ac, + lat: pos.lat, + lng: pos.lng, + heading: pos.heading, + trail, + }; + } + + // No flight plan - keep static position (for API-sourced aircraft) + return ac; + }); +} + +// ── Ship Waypoints ─────────────────────────────────── + +const SHIP_PLANS: Record = { + // USS Abraham Lincoln CSG - Arabian Sea patrol + '369970072': [ + [-12, 23.0, 62.0], [-8, 23.5, 61.0], [-4, 23.0, 62.0], [0, 23.5, 61.5], + [4, 23.0, 62.0], [8, 23.5, 61.0], [12, 23.0, 62.0], + ], + // USS Frank E. Petersen Jr DDG-121 - escort ahead + '369970121': [ + [-12, 23.3, 61.8], [-8, 23.8, 60.8], [-4, 23.3, 61.8], [0, 23.8, 61.3], + [4, 23.3, 61.8], [8, 23.8, 60.8], [12, 23.3, 61.8], + ], + // USS Spruance DDG-111 - screen + '369970111': [ + [-12, 22.7, 62.3], [-8, 23.2, 61.3], [-4, 22.7, 62.3], [0, 23.2, 61.8], + [4, 22.7, 62.3], [8, 23.2, 61.3], [12, 22.7, 62.3], + ], + // USS Michael Murphy DDG-112 - screen + '369970112': [ + [-12, 23.5, 62.5], [-8, 24.0, 61.5], [-4, 23.5, 62.5], [0, 24.0, 62.0], + [4, 23.5, 62.5], [8, 24.0, 61.5], [12, 23.5, 62.5], + ], + // USS Gerald R. Ford CVN-78 - Red Sea + '369970078': [ + [-12, 18.5, 39.0], [-8, 19.0, 38.5], [-4, 18.5, 39.0], [0, 19.0, 38.5], + [4, 18.5, 39.0], [8, 19.0, 38.5], [12, 18.5, 39.0], + ], + // USS McFaul DDG-74 - Arabian Sea + '369970074': [ + [-12, 22.0, 60.0], [-8, 22.5, 59.5], [-4, 22.0, 60.0], [0, 22.5, 59.5], + [4, 22.0, 60.0], [8, 22.5, 59.5], [12, 22.0, 60.0], + ], + // USS John Finn DDG-113 - Arabian Sea + '369970113': [ + [-12, 24.0, 59.0], [-8, 24.5, 58.5], [-4, 24.0, 59.0], [0, 24.5, 58.5], + [4, 24.0, 59.0], [8, 24.5, 58.5], [12, 24.0, 59.0], + ], + // USS Milius DDG-69 - Northern Arabian Sea + '369970069': [ + [-12, 25.0, 58.0], [-8, 25.5, 57.5], [-4, 25.0, 58.0], [0, 25.5, 57.5], + [4, 25.0, 58.0], [8, 25.5, 57.5], [12, 25.0, 58.0], + ], + // USS Delbert D. Black DDG-119 - Aegis BMD station + '369970119': [ + [-12, 26.0, 57.0], [-8, 26.5, 56.5], [-4, 26.0, 57.0], [0, 26.5, 56.5], + [4, 26.0, 57.0], [8, 26.5, 56.5], [12, 26.0, 57.0], + ], + // USS Pinckney DDG-91 - Arabian Sea patrol + '369970091': [ + [-12, 21.0, 61.0], [-8, 21.5, 60.5], [-4, 21.0, 61.0], [0, 21.5, 60.5], + [4, 21.0, 61.0], [8, 21.5, 60.5], [12, 21.0, 61.0], + ], + // USS Mitscher DDG-57 - Aegis BMD station + '369970057': [ + [-12, 25.5, 59.0], [-8, 26.0, 58.5], [-4, 25.5, 59.0], [0, 26.0, 58.5], + [4, 25.5, 59.0], [8, 26.0, 58.5], [12, 25.5, 59.0], + ], + // USS Canberra LCS-30 - Persian Gulf patrol (central Gulf, away from Qatar coast) + '369970030': [ + [-12, 27.2, 51.5], [-8, 27.5, 51.0], [-4, 27.2, 51.5], [0, 27.5, 51.0], + [4, 27.2, 51.5], [8, 27.5, 51.0], [12, 27.2, 51.5], + ], + // USS Tulsa LCS-16 - Persian Gulf patrol (east of Qatar, open water) + '369970016': [ + [-12, 26.2, 53.0], [-8, 26.5, 52.5], [-4, 26.2, 53.0], [0, 26.5, 52.5], + [4, 26.2, 53.0], [8, 26.5, 52.5], [12, 26.2, 53.0], + ], + // HMS Diamond D34 - re-deployed to Gulf + '232001034': [ + [-6, 25.0, 57.0], [-3, 25.5, 56.5], [0, 26.0, 56.5], [3, 26.2, 56.0], + [6, 25.8, 56.5], [9, 26.2, 56.0], [12, 25.8, 56.5], + ], + // HMS Middleton M34 - minesweeper, leaving early (Gulf of Oman → Arabian Sea) + '232001041': [ + [-12, 26.0, 56.8], [-8, 25.5, 57.0], [-4, 25.0, 57.5], [0, 24.5, 58.0], + ], + // FS Languedoc D653 - off Cyprus + '227001653': [ + [-8, 34.8, 33.0], [-4, 34.5, 33.5], [0, 34.2, 33.0], [4, 34.5, 33.5], + [8, 34.2, 33.0], [12, 34.5, 33.5], + ], + // FS Charles de Gaulle R91 - 크레타 → 동부 지중해 진입 (T0+4h~) + '227001091': [ + [4, 35.0, 28.0], [6, 34.8, 29.5], [8, 34.5, 31.0], [10, 34.2, 32.5], + [12, 34.0, 33.5], + ], + // IRGCN Fast Attack 1 - Strait of Hormuz + '422001001': [ + [-10, 26.2, 54.5], [-6, 26.5, 54.0], [-3, 26.8, 53.5], [0, 26.5, 54.0], + [2, 26.8, 54.5], [4, 27.0, 55.0], + ], + // IRGCN Fast Attack 2 + '422001002': [ + [-10, 27.0, 54.0], [-6, 26.8, 53.5], [-3, 26.5, 53.0], [0, 26.8, 53.5], + [2, 27.0, 53.8], + ], + // IRGCN Fast Attack 3 + '422001003': [ + [-10, 26.0, 55.0], [-6, 26.3, 54.5], [-3, 26.6, 54.0], [0, 26.3, 54.5], + [2, 26.0, 55.0], + ], + // IRIS Dena F75 - 호르무즈 해협 인근 이란 해군 초계 + '422010001': [ + [-12, 25.8, 57.2], [-8, 25.5, 57.5], [-4, 25.2, 57.8], [0, 25.5, 58.2], + [4, 25.8, 57.8], [8, 26.0, 57.5], [12, 25.5, 57.2], + ], + // Eagle Vellore - Korean VLCC escaping Hormuz before blockade + // Fixed: start from Al Basra oil terminal (sea), route through Persian Gulf → Hormuz → Arabian Sea + '440107001': [ + [-6, 29.8, 48.8], [-5, 28.5, 49.5], [-4, 27.2, 51.5], [-3, 26.8, 53.0], + [-2, 26.5, 55.0], [-1, 26.0, 56.3], [0, 25.5, 57.5], + [2, 25.0, 58.5], [4, 24.0, 60.0], [8, 22.0, 62.0], [12, 20.0, 64.0], + ], + // ROKS Choi Young DDH-981 - Cheonghae Unit, Hormuz area + '440001981': [ + [-12, 25.5, 57.0], [-8, 26.0, 56.5], [-4, 25.5, 57.0], [0, 26.0, 56.5], + [4, 25.5, 57.0], [8, 26.0, 56.5], [12, 25.5, 57.0], + ], +}; + +// ── Ship Propagation ───────────────────────────────── + +export function propagateShips( + baseShips: Ship[], + currentTime: number, + isLive = false, +): Ship[] { + const hoursFromT0 = (currentTime - T0) / HOUR; + + const active = baseShips.filter(ship => { + // In live mode, skip time-window filtering — show all ships from API + if (isLive) return true; + if (ship.activeStart != null && currentTime < ship.activeStart) return false; + if (ship.activeEnd != null && currentTime > ship.activeEnd) return false; + return true; + }); + + return active.map(ship => { + const plan = SHIP_PLANS[ship.mmsi]; + + if (plan) { + const pos = interpWaypoints(plan, hoursFromT0); + const trail = generateTrail(plan, hoursFromT0, 4, 30); // 4 points, 30min intervals + + return { + ...ship, + lat: pos.lat, + lng: pos.lng, + heading: pos.heading, + trail, + }; + } + + return ship; + }); +} diff --git a/frontend/src/services/ships.ts b/frontend/src/services/ships.ts new file mode 100644 index 0000000..a901b8e --- /dev/null +++ b/frontend/src/services/ships.ts @@ -0,0 +1,888 @@ +import type { Ship, ShipCategory } from '../types'; + +// ═══ S&P Global Maritime AIS API v1.3 ═══ +// Base URL: https://aisapi.maritime.spglobal.com +// All methods use HTTP POST with Basic Authentication +// Content-Type: application/json + +// Use Vite dev proxy to avoid CORS (proxied to https://aisapi.maritime.spglobal.com) +const SPG_BASE = '/api/ais'; +const SPG_USERNAME = import.meta.env.VITE_SPG_USERNAME as string | undefined; +const SPG_PASSWORD = import.meta.env.VITE_SPG_PASSWORD as string | undefined; + +// Middle East bounding box for GetTargetsInAreaEnhanced +const ME_BOUNDS = { + minLat: 10, + maxLat: 42, + minLng: 30, + maxLng: 65, +}; + +// Korea region bounding box: Vladivostok → South China Sea +const KR_BOUNDS = { + minLat: 10, + maxLat: 45, + minLng: 115, + maxLng: 145, +}; + +// S&P API sinceSeconds — currently disabled, will be removed with S&P code +// const SINCE_SECONDS = 3600; + +// MMSI country prefix → flag code (MID = Maritime Identification Digits) +const MMSI_FLAG_MAP: Record = { + '440': 'KR', '441': 'KR', // South Korea + '338': 'US', '303': 'US', '366': 'US', '367': 'US', '368': 'US', '369': 'US', + '232': 'UK', '233': 'UK', '234': 'UK', '235': 'UK', + '226': 'FR', '227': 'FR', '228': 'FR', + '211': 'DE', + '422': 'IR', + '431': 'JP', '432': 'JP', + '412': 'CN', '413': 'CN', '414': 'CN', + '525': 'ID', + '533': 'MY', + '548': 'PH', + '477': 'HK', + '538': 'MH', + '636': 'LR', + '352': 'PA', '353': 'PA', '354': 'PA', '355': 'PA', '356': 'PA', '357': 'PA', +}; + +// Known naval vessel MMSI prefixes +const NAVAL_MMSI_PREFIXES: Record = { + '338': { flag: 'US', category: 'warship' }, + '369': { flag: 'US', category: 'warship' }, + '303': { flag: 'US', category: 'warship' }, +}; + +// Known vessel name patterns +const MILITARY_NAME_PATTERNS: [RegExp, ShipCategory][] = [ + [/\bCVN\b|NIMITZ|\bFORD\b|EISENHOWER|LINCOLN|REAGAN|VINSON|STENNIS|TRUMAN|WASHINGTON|BUSH/i, 'carrier'], + [/\bDDG\b|\bDDH\b|DESTROYER|ARLEIGH|BURKE|ZUMWALT|SEJONG|CHUNGMUGONG|GWANGGAETO/i, 'destroyer'], + [/\bSSN\b|\bSS\b.*SUBMARINE/i, 'submarine'], + [/\bCG\b|CRUISER|TICONDEROGA/i, 'warship'], + [/\bLHD\b|\bLHA\b|AMPHIBIOUS|WASP|AMERICA|BATAAN|DOKDO|MARADO/i, 'warship'], + [/\bPATROL\b|\bPC\b|\bMCM\b|\bLCS\b|\bPCC\b|\bFFG\b|\bFF\b|FRIGATE|INCHEON|DAEGU|ULSAN|COAST\s*GUARD|해경|KCG|해양경찰|SAMBONG|TAEPYEONGYANG/i, 'patrol'], + [/\bROKS\b|\bUSS\b|\bHMS\b|\bFS\b|\bIRGCN\b/i, 'warship'], +]; + +// S&P Global AIS VesselType string → our ShipCategory +const SPG_VESSEL_TYPE_MAP: Record = { + 'Cargo': 'cargo', + 'Bulk Carrier': 'cargo', + 'Container Ship': 'cargo', + 'General Cargo': 'cargo', + 'Tanker': 'tanker', + 'Passenger': 'civilian', + 'Tug': 'civilian', + 'Fishing': 'civilian', + 'Pilot Boat': 'civilian', + 'Tender': 'civilian', + 'Vessel': 'civilian', + 'High Speed Craft': 'civilian', + 'Search And Rescue': 'patrol', + 'Law Enforcement': 'patrol', + 'Anti Pollution': 'civilian', + 'Wing In Ground-effect': 'civilian', + 'Medical Transport': 'civilian', + 'N/A': 'unknown', +}; + +export function classifyShip(name: string, mmsi: string, vesselType?: string): ShipCategory { + // 1. Check name patterns first (most reliable for military) + for (const [pattern, cat] of MILITARY_NAME_PATTERNS) { + if (pattern.test(name)) { + if (cat === 'carrier') console.log(`[CARRIER by name] "${name}" mmsi=${mmsi} vesselType=${vesselType}`); + return cat; + } + } + // 2. Check S&P AIS VesselType + if (vesselType) { + const mapped = SPG_VESSEL_TYPE_MAP[vesselType]; + if (mapped) return mapped; + // Partial match fallback + const lower = vesselType.toLowerCase(); + if (lower.includes('tanker')) return 'tanker'; + if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk') || lower.includes('carrier')) return 'cargo'; + if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'civilian'; + if (lower.includes('naval') || lower.includes('military')) return 'warship'; + if (lower.includes('patrol') || lower.includes('law enforcement')) return 'patrol'; + } + // 3. Check MMSI prefix + const prefix = mmsi.slice(0, 3); + if (NAVAL_MMSI_PREFIXES[prefix]) return NAVAL_MMSI_PREFIXES[prefix].category; + return 'civilian'; +} + +function getFlagFromMMSI(mmsi: string): string | undefined { + const mid = mmsi.slice(0, 3); + return MMSI_FLAG_MAP[mid]; +} + +// ═══ S&P Global AIS API Response Types (Appendix D) ═══ + +interface SPGAISTarget { + // Core AIS fields + MMSI: string; + IMO: string; + Name: string; + Callsign: string; + VesselType: string; // "Cargo", "Tanker", "Passenger", etc. + Lat: number; + Lon: number; + Heading: number; // True Heading or CoG if True Heading absent + CoG: number; // Course Over Ground (max 359.9) + SoG: number; // Speed Over Ground (knots) + Status: string; // "Under way using engine", "Anchored", "Moored", etc. + Destination: string; + DestinationTidied: string; + ETA: string; // ISO format + Draught: number; + Length: number; + Width: number; + + // Timestamps + TimestampUTC: number; // UTC Seconds only + MessageTimestamp: string; // ISO format + ReceivedDate: string; // ISO format + AgeMinutes: number; + + // Enhanced data + DWT: number; // Deadweight tonnage (-999 = unknown) + STAT5CODE: string; // 7-char ship type code + Source: string; // "ORB", "EXA", or "AIS" + ExtraInfo: string; // "Military Operations", "Fishing", etc. + ImoVerified: string; + TonnesCargo: number; + + // Position data + PositionAccuracy: number; + PositionFixType: number; + RoT: number; // Rate of Turn + BandFlag: number; + RAIMFlag: number; + + // Port/Zone + ZoneId: number; + LPCCode: string; // Last Port of Call Code + DestinationPortID: number; + DestinationUNLOCODE: string; + + // Additional + DTE: string; + AISVersion: number; + RadioStatus: number; + RepeatIndicator: number; + Anomalous: boolean; + OnBerth: boolean; + InSTS: number; + StationId: number; + LengthBow: number; + LengthStern: number; + WidthPort: number; + WidthStarboard: number; + LastStaticUpdateReceived: string; + MessageType: string; + Regional: number; + Regional2: number; + Spare: number; + Spare2: number; +} + +interface SPGAPIResponse { + APSStatus: { + CompletedOK: boolean; + ErrorLevel: string; + ErrorMessage: string; + Guid: string; + JobRunDate: string; + RemedialAction: string; + SystemDate: string; + SystemVersion: string; + }; + // API returns different field names depending on endpoint + targetEnhancedArr?: SPGAISTarget[]; // GetTargetsInAreaEnhanced, GetTargetsByMMSIsEnhanced + targets?: SPGAISTarget[]; // fallback +} + +// Build Basic Auth header +function buildAuthHeader(): string { + const credentials = `${SPG_USERNAME}:${SPG_PASSWORD}`; + return `Basic ${btoa(credentials)}`; +} + +// Generic S&P Global AIS API POST call +async function callSPGAPI(endpoint: string, body: Record): Promise { + const url = `${SPG_BASE}/AISSvc.svc/AIS/${endpoint}`; + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': buildAuthHeader(), + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error(`S&P AIS API ${res.status}: ${res.statusText}`); + } + + const data: SPGAPIResponse = await res.json(); + + if (!data.APSStatus?.CompletedOK) { + throw new Error(`S&P AIS API error: ${data.APSStatus?.ErrorMessage || 'Unknown error'}`); + } + + // API returns targetEnhancedArr for Enhanced endpoints + return data.targetEnhancedArr || data.targets || []; +} + +// Parse S&P AIS target into our Ship type +function parseAISTarget(t: SPGAISTarget): Ship { + const name = (t.Name || '').trim(); + const mmsi = String(t.MMSI || ''); + const flag = getFlagFromMMSI(mmsi); + const category = classifyShip(name, mmsi, t.VesselType); + + return { + mmsi, + name: name || `MMSI-${mmsi}`, + lat: t.Lat, + lng: t.Lon, + heading: t.Heading ?? t.CoG ?? 0, + speed: t.SoG ?? 0, + course: t.CoG ?? t.Heading ?? 0, + category, + flag, + typecode: t.STAT5CODE || t.VesselType || undefined, + typeDesc: t.VesselType || undefined, + imo: t.IMO || undefined, + callSign: t.Callsign || undefined, + status: t.Status || undefined, + destination: (t.DestinationTidied || t.Destination || '').trim() || undefined, + eta: t.ETA || undefined, + draught: t.Draught > 0 ? t.Draught : undefined, + length: t.Length > 0 ? t.Length : undefined, + width: t.Width > 0 ? t.Width : undefined, + lastSeen: t.MessageTimestamp + ? new Date(t.MessageTimestamp).getTime() + : t.TimestampUTC + ? t.TimestampUTC * 1000 + : Date.now(), + }; +} + +// ═══ Primary API: GetTargetsInAreaEnhanced ═══ +// Returns vessels within a bounding box updated in the last N seconds +export async function fetchShipsFromSPG(): Promise { + // TODO: signal-batch 전환 후 제거 — S&P API 비활성화 + return []; +} + +// ═══ Supplementary: fetch specific vessels by MMSI ═══ +// Useful for tracking known military vessels that may not appear in area query +export async function fetchShipsByMMSI(mmsiList: string[]): Promise { + if (!SPG_USERNAME || !SPG_PASSWORD || mmsiList.length === 0) return []; + + try { + const targets = await callSPGAPI('GetTargetsByMMSIsEnhanced', { + MMSI: mmsiList.join(','), + }); + + return targets + .filter(t => t.Lat != null && t.Lon != null) + .map(parseAISTarget); + } catch (err) { + console.warn('S&P AIS API (GetTargetsByMMSIsEnhanced) failed:', err); + return []; + } +} + +// ═══ Supplementary: fetch tankers only ═══ +export async function fetchTankers(): Promise { + if (!SPG_USERNAME || !SPG_PASSWORD) return []; + + try { + const targets = await callSPGAPI('GetTankerSubsetOneEnhanced', {}); + return targets + .filter(t => t.Lat != null && t.Lon != null) + .filter(t => { + // Filter to Middle East region + return t.Lat >= ME_BOUNDS.minLat && t.Lat <= ME_BOUNDS.maxLat + && t.Lon >= ME_BOUNDS.minLng && t.Lon <= ME_BOUNDS.maxLng; + }) + .map(parseAISTarget); + } catch (err) { + console.warn('S&P AIS API (GetTankerSubsetOneEnhanced) failed:', err); + return []; + } +} + +// ═══ Main fetch function ═══ +// Tries S&P Global AIS API first, merges with sample military ships +export async function fetchShips(): Promise { + // Always include sample military/scenario ships (warships have AIS off) + const sampleShips = getSampleShips(); + + // Try area-based query for all commercial vessels in Middle East + const areaShips = await fetchShipsFromSPG(); + + if (areaShips.length > 0) { + console.log(`S&P AIS API: ${areaShips.length} real vessels in Middle East area`); + + // Keep sample military ships that aren't in AIS data + const sampleMilitary = sampleShips.filter(s => + s.category !== 'civilian' && s.category !== 'cargo' && s.category !== 'tanker' && s.category !== 'unknown' + ); + // Keep sample Korean commercial ships (scenario-specific stranded vessels) + const sampleKorean = sampleShips.filter(s => s.flag === 'KR' && s.category !== 'destroyer'); + + const sampleMMSIs = new Set([...sampleMilitary, ...sampleKorean].map(s => s.mmsi)); + const sampleToKeep = [...sampleMilitary, ...sampleKorean]; + + // Merge: real AIS ships + sample military/Korean ships (avoid duplicates) + const merged = [ + ...areaShips.filter(s => !sampleMMSIs.has(s.mmsi)), + ...sampleToKeep, + ]; + return merged; + } + + // Fallback to sample data only + console.warn('S&P AIS API returned no data, using sample data'); + return sampleShips; +} + + +// T0 = main strike moment +const T0 = new Date('2026-03-01T12:01:00Z').getTime(); +const HOUR = 3600_000; + +function getSampleShips(): Ship[] { + const now = Date.now(); + return [ + // ═══ USS ABRAHAM LINCOLN CSG — Arabian Sea (deployed Nov 2025, rerouted to ME) ═══ + { + mmsi: '369970072', name: 'USS ABRAHAM LINCOLN (CVN-72)', lat: 23.5, lng: 59.8, + heading: 315, speed: 20, course: 315, category: 'carrier', flag: 'US', typecode: 'CVN', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970121', name: 'USS FRANK E. PETERSEN JR. (DDG-121)', lat: 23.8, lng: 59.5, + heading: 300, speed: 22, course: 300, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970111', name: 'USS SPRUANCE (DDG-111)', lat: 23.2, lng: 60.1, + heading: 340, speed: 20, course: 340, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970112', name: 'USS MICHAEL MURPHY (DDG-112)', lat: 23.0, lng: 60.4, + heading: 280, speed: 18, course: 280, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ USS GERALD R. FORD CSG — Red Sea (deployed Feb 2026) ═══ + { + mmsi: '369970078', name: 'USS GERALD R. FORD (CVN-78)', lat: 20.5, lng: 38.5, + heading: 170, speed: 18, course: 170, category: 'carrier', flag: 'US', typecode: 'CVN', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ Independent US destroyers — Arabian Sea ═══ + { + mmsi: '369970074', name: 'USS McFAUL (DDG-74)', lat: 24.0, lng: 58.0, + heading: 45, speed: 22, course: 45, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970113', name: 'USS JOHN FINN (DDG-113)', lat: 22.0, lng: 61.5, + heading: 350, speed: 24, course: 350, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970069', name: 'USS MILIUS (DDG-69)', lat: 25.5, lng: 57.5, + heading: 270, speed: 20, course: 270, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970119', name: 'USS DELBERT D. BLACK (DDG-119)', lat: 24.5, lng: 56.8, + heading: 90, speed: 18, course: 90, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970091', name: 'USS PINCKNEY (DDG-91)', lat: 21.5, lng: 60.8, + heading: 30, speed: 22, course: 30, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970057', name: 'USS MITSCHER (DDG-57)', lat: 26.0, lng: 55.5, + heading: 180, speed: 20, course: 180, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ US LCS — Persian Gulf ═══ + { + mmsi: '369970030', name: 'USS CANBERRA (LCS-30)', lat: 27.2, lng: 51.5, + heading: 60, speed: 15, course: 60, category: 'patrol', flag: 'US', typecode: 'LCS', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '369970016', name: 'USS TULSA (LCS-16)', lat: 26.2, lng: 53.0, + heading: 120, speed: 14, course: 120, category: 'patrol', flag: 'US', typecode: 'LCS', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ UK Royal Navy ═══ + { + mmsi: '232001034', name: 'HMS DIAMOND (D34)', lat: 25.0, lng: 57.0, + heading: 0, speed: 16, course: 0, category: 'destroyer', flag: 'UK', typecode: 'DDG', + lastSeen: now, activeStart: T0 - 8 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '232001041', name: 'HMS MIDDLETON (M34)', lat: 26.0, lng: 56.8, + heading: 270, speed: 8, course: 270, category: 'patrol', flag: 'UK', typecode: 'MCM', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 6 * HOUR, + }, + + // ═══ French Navy ═══ + { + mmsi: '227001653', name: 'FS LANGUEDOC (D653)', lat: 34.8, lng: 33.5, + heading: 120, speed: 16, course: 120, category: 'destroyer', flag: 'FR', typecode: 'FREMM', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '227001091', name: 'FS CHARLES DE GAULLE (R91)', lat: 35.0, lng: 28.0, + heading: 90, speed: 22, course: 90, category: 'carrier', flag: 'FR', typecode: 'CVN', + lastSeen: now, activeStart: T0 + 4 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ IRGCN — Iranian patrol boats ═══ + { + mmsi: '422001001', name: 'IRGCN FAST ATTACK-1', lat: 26.55, lng: 56.25, + heading: 180, speed: 35, course: 180, category: 'patrol', flag: 'IR', typecode: 'PC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + { + mmsi: '422001002', name: 'IRGCN FAST ATTACK-2', lat: 26.65, lng: 56.10, + heading: 210, speed: 32, course: 210, category: 'patrol', flag: 'IR', typecode: 'PC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 4 * HOUR, + }, + { + mmsi: '422001003', name: 'IRGCN FAST ATTACK-3', lat: 26.70, lng: 56.40, + heading: 240, speed: 30, course: 240, category: 'patrol', flag: 'IR', typecode: 'PC', + lastSeen: now, activeStart: T0 - 10 * HOUR, activeEnd: T0 + 2 * HOUR, + }, + { + mmsi: '422010001', name: 'IRIS DENA (F75)', lat: 25.8, lng: 57.2, + heading: 210, speed: 14, course: 210, category: 'warship', flag: 'IR', typecode: 'FFG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ Republic of Korea Navy ═══ + { + mmsi: '440001981', name: 'ROKS CHOI YOUNG (DDH-981)', lat: 25.3, lng: 57.5, + heading: 45, speed: 16, course: 45, category: 'destroyer', flag: 'KR', typecode: 'DDH', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ KOREAN VESSELS — ships stranded at Fujairah anchorage (Gulf of Oman) ═══ + // Fujairah Offshore Anchorage: ~25.1-25.35°N, 56.30-56.55°E (open water) + { + mmsi: '440101001', name: 'HYUNDAI SUPREME', lat: 25.18, lng: 56.38, + heading: 130, speed: 0, course: 130, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440101002', name: 'HYUNDAI PRESTIGE', lat: 25.22, lng: 56.42, + heading: 110, speed: 0, course: 110, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440102001', name: 'GS VOYAGER', lat: 25.15, lng: 56.35, + heading: 90, speed: 0, course: 90, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440103001', name: 'SINOKOR ENERGY', lat: 25.25, lng: 56.45, + heading: 150, speed: 0, course: 150, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440103002', name: 'SINOKOR PIONEER', lat: 25.20, lng: 56.50, + heading: 200, speed: 0, course: 200, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440104001', name: 'SK HARMONY', lat: 25.28, lng: 56.40, + heading: 170, speed: 0, course: 170, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440105001', name: 'S-OIL CROWN', lat: 25.12, lng: 56.33, + heading: 0, speed: 0, course: 0, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440501234', name: 'HYUNDAI BRAVE', lat: 25.30, lng: 56.48, + heading: 130, speed: 0, course: 130, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440501235', name: 'HMM ROTTERDAM', lat: 25.16, lng: 56.52, + heading: 100, speed: 0, course: 100, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440501236', name: 'PAN OCEAN STAR', lat: 25.35, lng: 56.44, + heading: 80, speed: 0, course: 80, category: 'cargo', flag: 'KR', typecode: 'BULK', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440501237', name: 'POLARIS VICTORY', lat: 25.23, lng: 56.36, + heading: 160, speed: 0, course: 160, category: 'cargo', flag: 'KR', typecode: 'BULK', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440501238', name: 'KSS BUSAN', lat: 25.32, lng: 56.55, + heading: 120, speed: 0, course: 120, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440106001', name: 'SK SPLENDOR', lat: 25.19, lng: 56.46, + heading: 140, speed: 0, course: 140, category: 'tanker', flag: 'KR', typecode: 'LNG', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '440107001', name: 'EAGLE VELLORE', lat: 24.0, lng: 59.0, + heading: 120, speed: 14, course: 120, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 6 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + + // ═══ Other Commercial vessels ═══ + { + mmsi: '538006789', name: 'PACIFIC HARMONY', lat: 25.27, lng: 56.58, + heading: 300, speed: 0, course: 300, category: 'tanker', flag: 'MH', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '228001234', name: 'CMA CGM TROCADERO', lat: 26.3, lng: 53.5, + heading: 90, speed: 0, course: 90, category: 'cargo', flag: 'FR', typecode: 'CONT', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + { + mmsi: '636012345', name: 'FRONT ALTAIR', lat: 25.5, lng: 57.2, + heading: 0, speed: 0, course: 0, category: 'tanker', flag: 'LR', typecode: 'VLCC', + lastSeen: now, activeStart: T0 - 12 * HOUR, activeEnd: T0 + 12 * HOUR, + }, + ]; +} + +// ═══════════════════════════════════════ +// KOREA REGION — signal-batch data pipeline +// ═══════════════════════════════════════ + +const SIGNAL_BATCH_BASE = '/signal-batch'; + +// signal-batch POST /api/v1/vessels/recent-positions-detail +interface RecentPositionDetailRequest { + minutes: number; + coordinates: number[][]; // [[lon,lat], ...] polygon (first == last) +} + +interface RecentPositionDetailDto { + mmsi: string; + imo: number | null; + lon: number; + lat: number; + sog: number; + cog: number; + shipNm: string; + shipTy: string; + shipKindCode: string; + nationalCode: string; + lastUpdate: string; + shipImagePath: string | null; + shipImageCount: number | null; + heading: number | null; + callSign: string | null; + status: string | null; + destination: string | null; + eta: string | null; + draught: number | null; + length: number | null; + width: number | null; +} + +// AIS Ship and Cargo Type (0-99) → SPG-compatible vessel type string +// ITU-R M.1371-5, Table 53 — second digit encodes hazard category (x1-x4) +// Returns strings matching SPG_VESSEL_TYPE_MAP keys for classifyShip() compatibility +function aisTypeToVesselType(shipTy: string): string | undefined { + const code = parseInt(shipTy, 10); + if (isNaN(code) || code < 0 || code > 99) return undefined; + + // 20-29: Wing in Ground effect + if (code >= 20 && code <= 29) return 'Wing In Ground-effect'; + // 30: Fishing + if (code === 30) return 'Fishing'; + // 31-32: Towing + if (code === 31 || code === 32) return 'Tug'; + // 33: Dredging/underwater ops + if (code === 33) return 'Vessel'; + // 34: Diving operations + if (code === 34) return 'Vessel'; + // 35: Military operations — let name/MMSI classification take priority + if (code === 35) return 'N/A'; + // 36: Sailing + if (code === 36) return 'Vessel'; + // 37: Pleasure craft + if (code === 37) return 'Vessel'; + // 38-39: Reserved + // 40-49: High Speed Craft (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D) + if (code >= 40 && code <= 49) return 'High Speed Craft'; + // 50: Pilot vessel + if (code === 50) return 'Pilot Boat'; + // 51: Search and Rescue + if (code === 51) return 'Search And Rescue'; + // 52: Tug + if (code === 52) return 'Tug'; + // 53: Port tender + if (code === 53) return 'Tender'; + // 54: Anti-pollution equipment + if (code === 54) return 'Anti Pollution'; + // 55: Law enforcement + if (code === 55) return 'Law Enforcement'; + // 56-57: Spare (local assignment) + // 58: Medical transport + if (code === 58) return 'Medical Transport'; + // 59: Noncombatant ship (RR Resolution No. 18) + if (code === 59) return 'N/A'; + // 60-69: Passenger (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D) + if (code >= 60 && code <= 69) return 'Passenger'; + // 70-79: Cargo (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D) + if (code >= 70 && code <= 79) return 'Cargo'; + // 80-89: Tanker (x0=all, x1=Haz A, x2=Haz B, x3=Haz C, x4=Haz D) + if (code >= 80 && code <= 89) return 'Tanker'; + // 90-99: Other (x0=all, x1=Haz A, ... same pattern) + if (code >= 90 && code <= 99) return 'Vessel'; + + return undefined; +} + +function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship { + const name = (d.shipNm || '').trim(); + const mmsi = String(d.mmsi || ''); + // nationalCode is MMSI prefix (3 digits) — reuse existing MMSI_FLAG_MAP + const flag = MMSI_FLAG_MAP[d.nationalCode] || getFlagFromMMSI(mmsi); + + // shipTy can be either a SPG VesselType string ("Cargo", "Tanker") or AIS numeric code ("70", "80") + // Try as-is first (string), fall back to AIS numeric conversion + const rawShipTy = (d.shipTy || '').trim(); + const isNumeric = /^\d+$/.test(rawShipTy); + const vesselType = isNumeric ? aisTypeToVesselType(rawShipTy) : (rawShipTy || undefined); + + // Existing classification: name pattern → vesselType string → MMSI prefix + const category = classifyShip(name, mmsi, vesselType); + + // lastUpdate is KST "yyyy-MM-dd HH:mm:ss" — append timezone offset + let lastSeen = Date.now(); + if (d.lastUpdate) { + const parsed = new Date(d.lastUpdate.replace(' ', 'T') + '+09:00').getTime(); + if (!isNaN(parsed)) lastSeen = parsed; + } + + return { + mmsi, + name: name || `MMSI-${mmsi}`, + lat: d.lat, + lng: d.lon, + heading: d.heading ?? d.cog ?? 0, + speed: d.sog ?? 0, + course: d.cog ?? d.heading ?? 0, + category, + flag, + // vesselType string for getMarineTrafficCategory() matching + typecode: vesselType || undefined, + typeDesc: vesselType || undefined, + imo: d.imo ? String(d.imo) : undefined, + callSign: d.callSign || undefined, + status: d.status || undefined, + destination: (d.destination || '').trim() || undefined, + eta: d.eta || undefined, + draught: d.draught != null && d.draught > 0 ? d.draught : undefined, + length: d.length != null && d.length > 0 ? d.length : undefined, + width: d.width != null && d.width > 0 ? d.width : undefined, + lastSeen, + shipImagePath: d.shipImagePath || undefined, + shipImageCount: d.shipImageCount ?? undefined, + }; +} + +async function fetchShipsFromSignalBatch(): Promise { + try { + const body: RecentPositionDetailRequest = { + minutes: 5, + coordinates: [ + [KR_BOUNDS.minLng, KR_BOUNDS.minLat], + [KR_BOUNDS.maxLng, KR_BOUNDS.minLat], + [KR_BOUNDS.maxLng, KR_BOUNDS.maxLat], + [KR_BOUNDS.minLng, KR_BOUNDS.maxLat], + [KR_BOUNDS.minLng, KR_BOUNDS.minLat], + ], + }; + + const res = await fetch(`${SIGNAL_BATCH_BASE}/api/v1/vessels/recent-positions-detail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`signal-batch API ${res.status}`); + const data: RecentPositionDetailDto[] = await res.json(); + return data + .filter(d => d.lat != null && d.lon != null && d.lat !== 0 && d.lon !== 0) + .map(parseSignalBatchVessel); + } catch (err) { + console.warn('signal-batch API (Korea region) failed:', err); + return []; + } +} + +export async function fetchShipsKorea(): Promise { + const sample = getSampleShipsKorea(); + const real = await fetchShipsFromSignalBatch(); + if (real.length > 0) { + console.log(`signal-batch: ${real.length} vessels in Korea region`); + const sampleMMSIs = new Set(sample.map(s => s.mmsi)); + return [...real.filter(s => !sampleMMSIs.has(s.mmsi)), ...sample]; + } + return sample; +} + +function getSampleShipsKorea(): Ship[] { + const now = Date.now(); + return [ + // ═══ ROKN — Korean Navy (본토 방어 강화) ═══ + { mmsi: '440991001', name: 'ROKS SEJONG THE GREAT (DDG-991)', lat: 35.05, lng: 129.08, + heading: 180, speed: 0, course: 180, category: 'destroyer', flag: 'KR', typecode: 'DDG', + lastSeen: now }, + { mmsi: '440991002', name: 'ROKS YULGOK YI I (DDG-992)', lat: 37.50, lng: 126.55, + heading: 270, speed: 12, course: 270, category: 'destroyer', flag: 'KR', typecode: 'DDG', + lastSeen: now }, + { mmsi: '440991003', name: 'ROKS DOKDO (LPH-6111)', lat: 33.95, lng: 128.60, + heading: 90, speed: 14, course: 90, category: 'warship', flag: 'KR', typecode: 'LPH', + lastSeen: now }, + { mmsi: '440991004', name: 'ROKS MARADO (LPH-6112)', lat: 34.80, lng: 128.50, + heading: 150, speed: 16, course: 150, category: 'warship', flag: 'KR', typecode: 'LPH', + lastSeen: now }, + { mmsi: '440991005', name: 'ROKS DAEGU (FFG-818)', lat: 35.95, lng: 129.60, + heading: 45, speed: 10, course: 45, category: 'patrol', flag: 'KR', typecode: 'FFG', + lastSeen: now }, + { mmsi: '440991006', name: 'ROKS INCHEON (FFG-811)', lat: 37.45, lng: 126.40, + heading: 310, speed: 8, course: 310, category: 'patrol', flag: 'KR', typecode: 'FFG', + lastSeen: now }, + { mmsi: '440991007', name: 'ROKS GANG GAMCHAN (DDH-979)', lat: 33.52, lng: 126.53, + heading: 200, speed: 14, course: 200, category: 'destroyer', flag: 'KR', typecode: 'DDH', + lastSeen: now }, + + // ═══ 부산항 — 컨테이너/화물선 ═══ + { mmsi: '440201001', name: 'HMM ALGECIRAS', lat: 35.08, lng: 129.06, + heading: 0, speed: 0, course: 0, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + { mmsi: '440201002', name: 'HMM OSLO', lat: 35.06, lng: 129.08, + heading: 45, speed: 0, course: 45, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + { mmsi: '440201003', name: 'HMM GDANSK', lat: 35.04, lng: 129.10, + heading: 90, speed: 0, course: 90, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + { mmsi: '441201001', name: 'SINOKOR INCHEON', lat: 35.10, lng: 129.04, + heading: 180, speed: 0, course: 180, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + + // ═══ 울산항 — 유조선 (원유 수입 대기) ═══ + { mmsi: '440301001', name: 'SK ENERGY NO.1', lat: 35.50, lng: 129.42, + heading: 0, speed: 0, course: 0, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now }, + { mmsi: '440301002', name: 'GS CALTEX ULSAN', lat: 35.48, lng: 129.45, + heading: 30, speed: 0, course: 30, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now }, + { mmsi: '440301003', name: 'S-OIL PIONEER', lat: 35.52, lng: 129.40, + heading: 270, speed: 0, course: 270, category: 'tanker', flag: 'KR', typecode: 'VLCC', + lastSeen: now }, + + // ═══ 여수/광양 — LNG/석유화학 ═══ + { mmsi: '440401001', name: 'KOGAS LNG YEOSU', lat: 34.73, lng: 127.74, + heading: 180, speed: 0, course: 180, category: 'tanker', flag: 'KR', typecode: 'LNG', + lastSeen: now }, + { mmsi: '440401002', name: 'SK GAS CARRIER', lat: 34.75, lng: 127.70, + heading: 0, speed: 0, course: 0, category: 'tanker', flag: 'KR', typecode: 'LPG', + lastSeen: now }, + + // ═══ 인천항 ═══ + { mmsi: '440501001', name: 'PAN OCEAN INCHEON', lat: 37.44, lng: 126.58, + heading: 90, speed: 0, course: 90, category: 'cargo', flag: 'KR', typecode: 'BULK', + lastSeen: now }, + { mmsi: '440501002', name: 'POLARIS STAR', lat: 37.46, lng: 126.60, + heading: 0, speed: 0, course: 0, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + + // ═══ 동해 — 일본/러시아 항로 화물선 ═══ + { mmsi: '440601001', name: 'HYUNDAI MIPO', lat: 37.10, lng: 130.50, + heading: 30, speed: 12, course: 30, category: 'cargo', flag: 'KR', typecode: 'BULK', + lastSeen: now }, + { mmsi: '431201001', name: 'NIPPON MARU', lat: 38.20, lng: 131.50, + heading: 200, speed: 14, course: 200, category: 'cargo', flag: 'JP', typecode: 'BULK', + lastSeen: now }, + + // ═══ 대한해협 통과 선박 ═══ + { mmsi: '412801001', name: 'COSCO BUSAN', lat: 34.30, lng: 129.20, + heading: 60, speed: 16, course: 60, category: 'cargo', flag: 'CN', typecode: 'CONT', + lastSeen: now }, + { mmsi: '538201001', name: 'PACIFIC VENUS', lat: 34.50, lng: 128.80, + heading: 240, speed: 18, course: 240, category: 'tanker', flag: 'MH', typecode: 'VLCC', + lastSeen: now }, + { mmsi: '636201001', name: 'NORDIC STAR', lat: 34.10, lng: 128.50, + heading: 70, speed: 14, course: 70, category: 'tanker', flag: 'LR', typecode: 'VLCC', + lastSeen: now }, + + // ═══ 남중국해 → 부산 항로 ═══ + { mmsi: '440701001', name: 'HMM SINGAPORE', lat: 25.00, lng: 122.00, + heading: 30, speed: 18, course: 30, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + { mmsi: '440701002', name: 'HYUNDAI BANGKOK', lat: 20.50, lng: 118.00, + heading: 45, speed: 16, course: 45, category: 'cargo', flag: 'KR', typecode: 'CONT', + lastSeen: now }, + { mmsi: '477201001', name: 'OOCL HONG KONG', lat: 22.30, lng: 120.50, + heading: 20, speed: 20, course: 20, category: 'cargo', flag: 'HK', typecode: 'CONT', + lastSeen: now }, + + // ═══ 블라디보스톡 근해 ═══ + { mmsi: '440801001', name: 'KORYO TRADER', lat: 42.80, lng: 132.50, + heading: 180, speed: 10, course: 180, category: 'cargo', flag: 'KR', typecode: 'BULK', + lastSeen: now }, + + // ═══ 제주 해역 ═══ + { mmsi: '440901001', name: 'JEJU WORLD', lat: 33.30, lng: 126.30, + heading: 90, speed: 16, course: 90, category: 'civilian', flag: 'KR', typecode: 'PASS', + lastSeen: now }, + + // ═══ USN 7th Fleet (주일미군 / 한반도 근해) ═══ + { mmsi: '369971001', name: 'USS RONALD REAGAN (CVN-76)', lat: 35.30, lng: 139.60, + heading: 180, speed: 0, course: 180, category: 'carrier', flag: 'US', typecode: 'CVN', + lastSeen: now }, + { mmsi: '369971002', name: 'USS BARRY (DDG-52)', lat: 34.00, lng: 130.50, + heading: 270, speed: 22, course: 270, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now }, + { mmsi: '369971003', name: 'USS BENFOLD (DDG-65)', lat: 35.50, lng: 130.00, + heading: 180, speed: 18, course: 180, category: 'destroyer', flag: 'US', typecode: 'DDG', + lastSeen: now }, + + // ═══ JMSDF (해상자위대) ═══ + { mmsi: '431991001', name: 'JS IZUMO (DDH-183)', lat: 34.60, lng: 137.20, + heading: 90, speed: 14, course: 90, category: 'warship', flag: 'JP', typecode: 'DDH', + lastSeen: now }, + ]; +} diff --git a/frontend/src/services/submarineCable.ts b/frontend/src/services/submarineCable.ts new file mode 100644 index 0000000..e970c1c --- /dev/null +++ b/frontend/src/services/submarineCable.ts @@ -0,0 +1,419 @@ +// ═══ Korean Submarine Cable Data ═══ +// Source: TeleGeography / submarinecablemap.com +// Major submarine cables landing in South Korea + +export interface SubmarineCable { + id: string; + name: string; + color: string; + landingPoints: string[]; // city names + rfsYear?: number; // ready for service year + length?: string; // e.g. "36,500 km" + owners?: string; + route: [number, number][]; // [lng, lat] pairs for the cable path +} + +// ── Route reference ── +// Korea Strait: 129.5°E between Korea & Tsushima (water) +// Tsushima-Kyushu channel: ~129.8-130.2°E, 33.5-34°N (water) +// West of Kyushu (Goto-Nagasaki gap): ~129.3°E, 31-33°N (water) +// South of Kyushu (Cape Sata): below 30.8°N when 130-131°E +// South of Shikoku: below 32.5°N when 132-135°E +// South of Kii/Izu: below 33°N when 135-139°E +// Korea south coast: below 33.8°N between 126-129°E = open sea + +export const KOREA_SUBMARINE_CABLES: SubmarineCable[] = [ + // ═══ Southbound cables (Busan → Korea Strait → East China Sea → south) ═══ + { + id: 'apcn-2', + name: 'APCN-2', + color: '#e91e63', + landingPoints: ['부산', '일본', '중국', '대만', '필리핀', '싱가포르', '말레이시아'], + rfsYear: 2001, + length: '19,000 km', + owners: 'KT, NTT, China Telecom 등', + route: [ + [129.08, 35.18], // Busan coast + [129.5, 34.5], // Korea Strait (water between Korea & Tsushima) + [129.8, 33.8], // Tsushima-Kyushu channel (water) + [129.3, 32.5], // West of Kyushu (Goto-Nagasaki gap, water) + [129.0, 31.0], // West of Kyushu south (water) + [128.5, 29.5], // South of Kyushu (open sea) + [127.0, 27.0], // East China Sea + [125.0, 25.0], // East China Sea + [123.5, 23.0], // East of Taiwan (sea) + [121.5, 21.0], // South of Taiwan (sea) + [119.5, 18.5], // Luzon Strait + [117.5, 15.0], // South China Sea + [115.0, 10.5], // South China Sea + [111.5, 6.0], // South China Sea + [107.0, 3.0], // Approach Singapore + [104.0, 1.3], // Singapore coast + ], + }, + { + id: 'apg', + name: 'APG (Asia Pacific Gateway)', + color: '#2196f3', + landingPoints: ['부산', '일본', '중국', '대만', '홍콩', '베트남', '태국', '말레이시아', '싱가포르'], + rfsYear: 2016, + length: '10,400 km', + owners: 'KT, NTT, China Telecom, VNPT 등', + route: [ + [129.08, 35.18], // Busan coast + [129.4, 34.4], // Korea Strait + [129.7, 33.7], // Tsushima-Kyushu channel + [129.2, 32.3], // West of Kyushu (water) + [128.8, 30.8], // South of Kyushu (water) + [127.5, 28.5], // East China Sea + [125.5, 26.0], // East China Sea + [123.5, 24.0], // East of Taiwan (sea) + [121.8, 21.5], // South Taiwan (sea) + [119.0, 19.5], // Luzon Strait + [116.5, 18.5], // South China Sea + [114.5, 22.0], // Hong Kong approach (sea) + [113.0, 18.0], // South China Sea + [110.0, 14.0], // Off Vietnam (sea) + [108.5, 11.0], // Off South Vietnam (sea) + [106.5, 7.0], // South China Sea + [104.5, 2.5], // Approach Singapore + [104.0, 1.3], // Singapore coast + ], + }, + { + id: 'eac-c2c', + name: 'EAC-C2C', + color: '#ff9800', + landingPoints: ['부산', '일본', '중국', '필리핀', '싱가포르'], + rfsYear: 2002, + length: '36,500 km', + owners: 'KT 등', + route: [ + [129.08, 35.18], // Busan coast + [129.6, 34.6], // Korea Strait + [130.0, 33.9], // Tsushima-Kyushu channel + [129.5, 32.8], // West Kyushu coast (water) + [129.0, 31.2], // West of Kyushu south (water) + [128.0, 29.0], // East China Sea + [126.0, 26.5], // East China Sea + [124.0, 24.0], // East of Taiwan (sea) + [122.5, 21.0], // South Taiwan (sea) + [121.0, 18.0], // Luzon Strait + [120.0, 14.5], // West of Luzon (sea) + [117.5, 10.5], // South China Sea + [113.0, 6.0], // South China Sea + [108.0, 3.5], // South China Sea + [104.0, 1.3], // Singapore coast + ], + }, + { + id: 'flag-north-asia', + name: 'FLAG/REACH North Asia Loop', + color: '#9c27b0', + landingPoints: ['부산', '일본', '홍콩', '중국'], + rfsYear: 2002, + length: '11,500 km', + owners: 'Reliance Globalcom', + route: [ + [129.08, 35.18], // Busan coast + [129.5, 34.5], // Korea Strait + [129.9, 33.8], // Tsushima-Kyushu channel + [129.4, 32.5], // West Kyushu (water) + [129.0, 31.0], // South of Kyushu (water) + [127.0, 28.0], // East China Sea + [124.5, 25.5], // East China Sea + [122.5, 23.0], // East of Taiwan (sea) + [121.0, 21.0], // South Taiwan (sea) + [118.0, 19.5], // South China Sea + [115.5, 21.0], // Approach Hong Kong + [114.2, 22.0], // Hong Kong coast + ], + }, + { + id: 'sjc', + name: 'SJC (SE Asia-Japan Cable)', + color: '#8bc34a', + landingPoints: ['부산', '일본', '중국', '홍콩', '필리핀', '싱가포르'], + rfsYear: 2013, + length: '8,900 km', + owners: 'KT, Google, China Mobile 등', + route: [ + [129.08, 35.18], // Busan coast + [129.4, 34.4], // Korea Strait + [129.7, 33.6], // Tsushima-Kyushu channel + [129.2, 32.0], // West Kyushu (water) + [128.5, 30.5], // South of Kyushu (water) + [126.5, 27.5], // East China Sea + [124.0, 25.0], // East China Sea + [122.5, 22.5], // East of Taiwan (sea) + [121.0, 20.5], // South Taiwan (sea) + [118.0, 19.0], // South China Sea + [115.0, 21.5], // Approach Hong Kong + [114.2, 22.0], // Hong Kong coast + [113.5, 18.5], // South China Sea + [112.0, 13.0], // South China Sea + [110.0, 7.5], // South China Sea + [106.5, 3.5], // Approach Singapore + [104.0, 1.3], // Singapore coast + ], + }, + { + id: 'sjc2', + name: 'SJC2', + color: '#03a9f4', + landingPoints: ['부산', '일본', '중국', '대만', '싱가포르', '태국'], + rfsYear: 2022, + length: '10,500 km', + owners: 'KT, China Mobile, Facebook, KDDI 등', + route: [ + [129.08, 35.18], // Busan coast + [129.5, 34.5], // Korea Strait + [129.8, 33.7], // Tsushima-Kyushu channel + [129.3, 32.3], // West Kyushu (water) + [128.8, 30.8], // South of Kyushu (water) + [127.0, 28.0], // East China Sea + [125.0, 25.5], // East China Sea + [123.0, 23.5], // East of Taiwan (sea) + [121.5, 21.5], // South Taiwan (sea) + [119.0, 19.5], // South China Sea + [116.0, 21.5], // Approach Hong Kong + [114.5, 22.0], // Hong Kong area + [113.0, 17.5], // South China Sea + [110.0, 12.5], // South China Sea + [107.5, 8.0], // South China Sea + [105.0, 3.5], // Approach Singapore + [104.0, 1.3], // Singapore coast + ], + }, + + // ═══ Japan direct cable ═══ + { + id: 'kjcn', + name: 'KJCN (Korea-Japan Cable)', + color: '#4caf50', + landingPoints: ['부산', '기타큐슈(일본)'], + rfsYear: 2003, + length: '230 km', + owners: 'KT, KDDI', + route: [ + [129.08, 35.18], // Busan coast + [129.5, 34.8], // Korea Strait mid + [129.9, 34.4], // Strait (water) + [130.3, 34.1], // Approaching Kyushu north coast + [130.9, 33.9], // Kitakyushu coast + ], + }, + + // ═══ Pacific cables (Busan → Korea Strait → south of Japan → Pacific) ═══ + { + id: 'tpe', + name: 'TPE (Trans-Pacific Express)', + color: '#00bcd4', + landingPoints: ['거제', '일본', '중국', '대만', '미국'], + rfsYear: 2008, + length: '17,700 km', + owners: 'KT, China Telecom, Verizon 등', + route: [ + [128.62, 34.88], // Geoje coast + [129.3, 34.3], // Korea Strait + [129.7, 33.7], // Tsushima-Kyushu channel + [129.3, 32.2], // West Kyushu (water) + [129.0, 31.0], // SW of Kyushu (water) + [129.5, 30.0], // South of Cape Sata (open sea) + [131.5, 30.0], // SE of Kyushu (open sea) + [134.0, 30.5], // South of Shikoku (sea, <32.5°N) + [137.0, 31.5], // South of Kii (sea, <33°N) + [140.0, 32.0], // South of Izu (sea, <33°N) + [143.0, 33.0], // Off east Honshu (sea) + [148.0, 35.5], // North Pacific + [158.0, 38.0], // North Pacific + [170.0, 40.0], // Mid Pacific + [-170.0, 42.0], // Mid Pacific + [-155.0, 41.0], // Mid Pacific + [-140.0, 39.0], // East Pacific + [-130.0, 38.0], // Approach US + [-122.4, 37.8], // US West Coast + ], + }, + { + id: 'ncp', + name: 'NCP (New Cross Pacific)', + color: '#ffeb3b', + landingPoints: ['부산', '일본', '미국'], + rfsYear: 2018, + length: '13,600 km', + owners: 'KT, Amazon, Microsoft 등', + route: [ + [129.08, 35.18], // Busan coast + [129.5, 34.5], // Korea Strait + [129.8, 33.8], // Tsushima-Kyushu channel + [129.4, 32.5], // West Kyushu (water) + [129.0, 31.0], // SW of Kyushu (water) + [129.8, 30.0], // South of Cape Sata (sea) + [132.0, 30.0], // SE of Kyushu (sea) + [135.0, 30.5], // South of Shikoku (sea) + [138.0, 31.0], // South of Kii (sea) + [141.0, 32.0], // South of Izu (sea) + [144.0, 33.5], // Off east Honshu (sea) + [150.0, 37.0], // North Pacific + [160.0, 40.0], // North Pacific + [172.0, 43.0], // Mid Pacific + [-170.0, 45.5], // Mid Pacific + [-155.0, 45.0], // Mid Pacific + [-140.0, 43.0], // East Pacific + [-130.0, 42.0], // Approach US + [-125.0, 40.5], // Near US + [-122.3, 37.4], // US West Coast + ], + }, + + // ═══ GOKI (Incheon → Yellow Sea → south → Okinawa → Guam) ═══ + { + id: 'goki', + name: 'GOKI', + color: '#ff5722', + landingPoints: ['인천', '오키나와', '괌'], + rfsYear: 2020, + length: '3,400 km', + owners: 'KT 등', + route: [ + [126.59, 37.46], // Incheon coast + [126.0, 36.5], // Yellow Sea (open water, west of Korea) + [125.5, 35.5], // Yellow Sea + [125.0, 34.5], // Yellow Sea south + [125.0, 33.5], // South of Korea (open sea, south of Jeju) + [125.5, 32.0], // East China Sea + [126.5, 30.0], // East China Sea + [127.5, 27.5], // Ryukyu arc + [127.8, 26.3], // Okinawa coast + [129.0, 23.0], // Philippine Sea + [133.0, 19.0], // Philippine Sea + [138.0, 16.0], // Philippine Sea + [144.8, 13.5], // Guam coast + ], + }, + + // ═══ China direct cables (Yellow Sea crossing) ═══ + { + id: 'c2c', + name: 'C2C (China-Korea)', + color: '#f44336', + landingPoints: ['태안', '칭다오(중국)'], + rfsYear: 2001, + length: '1,100 km', + owners: 'KT, China Netcom', + route: [ + [126.35, 36.75], // Taean coast (west coast Korea) + [125.5, 36.5], // Yellow Sea + [124.0, 36.0], // Yellow Sea mid + [122.5, 35.8], // Yellow Sea + [121.0, 36.0], // Approach Qingdao + [120.4, 36.1], // Qingdao coast + ], + }, + { + id: 'ckc', + name: 'CKC (China-Korea Cable)', + color: '#ff7043', + landingPoints: ['태안', '상하이(중국)'], + rfsYear: 2006, + length: '1,300 km', + owners: 'KT, China Telecom', + route: [ + [126.35, 36.75], // Taean coast (west coast Korea) + [125.5, 36.0], // Yellow Sea + [124.5, 35.0], // Yellow Sea mid + [123.5, 34.0], // Yellow Sea south + [123.0, 33.0], // East China Sea + [122.5, 32.0], // East China Sea + [122.0, 31.5], // Approach Shanghai + [121.8, 31.2], // Shanghai coast + ], + }, + { + id: 'flag-fea', + name: 'FEA (Flag Europe Asia)', + color: '#ab47bc', + landingPoints: ['부산', '상하이(중국)', '홍콩'], + rfsYear: 2001, + length: '28,000 km', + owners: 'Reliance Globalcom', + route: [ + [129.08, 35.18], // Busan coast + [128.0, 34.8], // South Sea (water, south of coast) + [127.0, 34.5], // South Sea + [126.0, 33.8], // South of Jeju area (water) + [125.0, 33.0], // East China Sea + [124.0, 32.0], // East China Sea + [123.0, 31.5], // Approach Shanghai + [121.8, 31.2], // Shanghai coast + ], + }, + + // ═══ Domestic cables ═══ + { + id: 'jeju-mainland-2', + name: '제주-본토 해저케이블 2', + color: '#e0e0e0', + landingPoints: ['제주', '해남(전남)'], + rfsYear: 2013, + owners: 'KT', + route: [ + [126.53, 33.51], // Jeju north coast + [126.50, 33.75], // Jeju Strait + [126.48, 34.00], // Jeju Strait mid + [126.50, 34.25], // Approach mainland + [126.57, 34.57], // Haenam coast + ], + }, + { + id: 'jeju-mainland-3', + name: '제주-본토 해저케이블 3', + color: '#bdbdbd', + landingPoints: ['제주', '진도(전남)'], + rfsYear: 2019, + owners: 'KT', + route: [ + [126.53, 33.51], // Jeju north coast + [126.30, 33.80], // Jeju Strait west + [126.15, 34.10], // Jeju Strait mid + [126.20, 34.35], // Approach Jindo + [126.26, 34.49], // Jindo coast + ], + }, + { + id: 'ulleung-mainland', + name: '울릉-본토 해저케이블', + color: '#90a4ae', + landingPoints: ['울릉도', '포항'], + rfsYear: 2016, + owners: 'KT', + route: [ + [130.91, 37.48], // Ulleungdo coast + [130.50, 37.15], // East Sea + [130.10, 36.75], // East Sea + [129.70, 36.35], // Approach Pohang + [129.34, 36.02], // Pohang coast + ], + }, +]; + +// Landing points in Korea for marker display +export interface CableLandingPoint { + name: string; + lat: number; + lng: number; + cables: string[]; // cable IDs +} + +export const KOREA_LANDING_POINTS: CableLandingPoint[] = [ + { name: '부산', lat: 35.18, lng: 129.08, cables: ['apcn-2', 'apg', 'eac-c2c', 'kjcn', 'flag-north-asia', 'ncp', 'sjc', 'sjc2', 'flag-fea'] }, + { name: '거제', lat: 34.88, lng: 128.62, cables: ['tpe'] }, + { name: '태안', lat: 36.75, lng: 126.35, cables: ['c2c', 'ckc'] }, + { name: '인천', lat: 37.46, lng: 126.59, cables: ['goki'] }, + { name: '제주', lat: 33.51, lng: 126.53, cables: ['jeju-mainland-2', 'jeju-mainland-3'] }, + { name: '해남', lat: 34.57, lng: 126.57, cables: ['jeju-mainland-2'] }, + { name: '진도', lat: 34.49, lng: 126.26, cables: ['jeju-mainland-3'] }, + { name: '울릉도', lat: 37.48, lng: 130.91, cables: ['ulleung-mainland'] }, + { name: '포항', lat: 36.02, lng: 129.34, cables: ['ulleung-mainland'] }, +]; diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css new file mode 100644 index 0000000..8a51938 --- /dev/null +++ b/frontend/src/styles/tailwind.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import './tokens.css'; diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..1aa821d --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,181 @@ +/* ═══ KCG Monitoring Design Tokens ═══ + * 모든 색상을 --kcg-* CSS 변수로 관리. + * data-theme 속성으로 dark/light 전환. + * @theme 블록으로 Tailwind 유틸리티 클래스에 매핑. + */ + +/* ── Dark Theme (기본 — 현재 UI와 동일) ── */ +:root, +[data-theme='dark'] { + /* 배경 */ + --kcg-bg: #0a0a1a; + --kcg-surface: #111127; + --kcg-card: #1a1a2e; + --kcg-overlay: rgba(10, 10, 26, 0.96); + --kcg-subtle: rgba(255, 255, 255, 0.03); + + /* 텍스트 */ + --kcg-text: #e0e0e0; + --kcg-text-secondary: #ccc; + --kcg-muted: #888; + --kcg-dim: #666; + + /* 보더 */ + --kcg-border: #333; + --kcg-border-light: #444; + --kcg-border-heavy: #555; + + /* 시맨틱 */ + --kcg-accent: #3b82f6; + --kcg-accent-hover: #2563eb; + --kcg-danger: #ef4444; + --kcg-warning: #eab308; + --kcg-success: #22c55e; + --kcg-info: #06b6d4; + + /* 이벤트 타입 */ + --kcg-event-airstrike: #ef4444; + --kcg-event-explosion: #f97316; + --kcg-event-missile: #eab308; + --kcg-event-intercept: #3b82f6; + --kcg-event-impact: #ff0000; + --kcg-event-alert: #f97316; + --kcg-event-osint: #06b6d4; + + /* 선박 MT 분류 */ + --kcg-ship-cargo: #f0a830; + --kcg-ship-tanker: #e74c3c; + --kcg-ship-passenger: #4caf50; + --kcg-ship-fishing: #42a5f5; + --kcg-ship-military: #d32f2f; + --kcg-ship-tug: #2e7d32; + --kcg-ship-pleasure: #e91e8c; + --kcg-ship-highspeed: #ff9800; + --kcg-ship-other: #5c6bc0; + --kcg-ship-unknown: #9e9e9e; + + /* 항공기 카테고리 */ + --kcg-ac-fighter: #ff4444; + --kcg-ac-military: #ff6600; + --kcg-ac-surveillance: #ffcc00; + --kcg-ac-tanker: #00ccff; + --kcg-ac-cargo: #a78bfa; + --kcg-ac-civilian: #FFD700; + --kcg-ac-unknown: #7CFC00; + + /* 국기/해군 */ + --kcg-navy-us: #4a90d9; + --kcg-navy-kr: #00c73c; + --kcg-navy-jp: #ff6b6b; + --kcg-navy-cn: #ff4444; + --kcg-navy-ir: #ff8c00; + + /* 글래스/투명 */ + --kcg-glass: rgba(10, 10, 26, 0.92); + --kcg-glass-dense: rgba(10, 10, 26, 0.96); + --kcg-hover: rgba(255, 255, 255, 0.05); + --kcg-hover-strong: rgba(255, 255, 255, 0.08); + --kcg-accent-bg: rgba(59, 130, 246, 0.12); + --kcg-danger-bg: rgba(239, 68, 68, 0.15); + + /* 패널 그림자 */ + --kcg-panel-shadow: none; +} + +/* ── Light Theme ── */ +[data-theme='light'] { + /* 배경 */ + --kcg-bg: #f0f2f5; + --kcg-surface: #ffffff; + --kcg-card: #f8fafc; + --kcg-overlay: rgba(255, 255, 255, 0.96); + --kcg-subtle: rgba(0, 0, 0, 0.02); + + /* 텍스트 */ + --kcg-text: #1a1a2e; + --kcg-text-secondary: #374151; + --kcg-muted: #6b7280; + --kcg-dim: #9ca3af; + + /* 보더 */ + --kcg-border: #d1d5db; + --kcg-border-light: #e5e7eb; + --kcg-border-heavy: #9ca3af; + + /* 시맨틱 (데이터 시각화 색상은 테마 불변) */ + --kcg-accent: #2563eb; + --kcg-accent-hover: #1d4ed8; + + /* 글래스/투명 */ + --kcg-glass: rgba(255, 255, 255, 0.95); + --kcg-glass-dense: rgba(255, 255, 255, 0.98); + --kcg-hover: rgba(0, 0, 0, 0.04); + --kcg-hover-strong: rgba(0, 0, 0, 0.08); + --kcg-accent-bg: rgba(37, 99, 235, 0.08); + --kcg-danger-bg: rgba(239, 68, 68, 0.08); + + /* 패널 그림자 — light에서 영역 구분 강화 (outline 제거 — 폰트 가독성) */ + --kcg-panel-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +/* ── Tailwind @theme mapping ── */ +@theme { + /* 배경 */ + --color-kcg-bg: var(--kcg-bg); + --color-kcg-surface: var(--kcg-surface); + --color-kcg-card: var(--kcg-card); + --color-kcg-overlay: var(--kcg-overlay); + --color-kcg-subtle: var(--kcg-subtle); + + /* 텍스트 */ + --color-kcg-text: var(--kcg-text); + --color-kcg-text-secondary: var(--kcg-text-secondary); + --color-kcg-muted: var(--kcg-muted); + --color-kcg-dim: var(--kcg-dim); + + /* 보더 */ + --color-kcg-border: var(--kcg-border); + --color-kcg-border-light: var(--kcg-border-light); + + /* 시맨틱 */ + --color-kcg-accent: var(--kcg-accent); + --color-kcg-danger: var(--kcg-danger); + --color-kcg-warning: var(--kcg-warning); + --color-kcg-success: var(--kcg-success); + --color-kcg-info: var(--kcg-info); + + /* 이벤트 */ + --color-kcg-event-airstrike: var(--kcg-event-airstrike); + --color-kcg-event-explosion: var(--kcg-event-explosion); + --color-kcg-event-missile: var(--kcg-event-missile); + --color-kcg-event-intercept: var(--kcg-event-intercept); + --color-kcg-event-impact: var(--kcg-event-impact); + --color-kcg-event-osint: var(--kcg-event-osint); + + /* 선박 */ + --color-kcg-ship-cargo: var(--kcg-ship-cargo); + --color-kcg-ship-tanker: var(--kcg-ship-tanker); + --color-kcg-ship-passenger: var(--kcg-ship-passenger); + --color-kcg-ship-fishing: var(--kcg-ship-fishing); + --color-kcg-ship-military: var(--kcg-ship-military); + --color-kcg-ship-tug: var(--kcg-ship-tug); + --color-kcg-ship-unknown: var(--kcg-ship-unknown); + + /* 항공기 */ + --color-kcg-ac-fighter: var(--kcg-ac-fighter); + --color-kcg-ac-military: var(--kcg-ac-military); + --color-kcg-ac-surveillance: var(--kcg-ac-surveillance); + --color-kcg-ac-tanker: var(--kcg-ac-tanker); + --color-kcg-ac-cargo: var(--kcg-ac-cargo); + --color-kcg-ac-civilian: var(--kcg-ac-civilian); + + /* 글래스 */ + --color-kcg-glass: var(--kcg-glass); + --color-kcg-hover: var(--kcg-hover); + --color-kcg-accent-bg: var(--kcg-accent-bg); + --color-kcg-danger-bg: var(--kcg-danger-bg); + + /* 폰트 */ + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..1bad617 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,148 @@ +export interface GeoEvent { + id: string; + timestamp: number; // unix ms + lat: number; + lng: number; + type: 'airstrike' | 'explosion' | 'missile_launch' | 'intercept' | 'alert' | 'impact' | 'osint'; + source?: 'US' | 'IL' | 'IR' | 'proxy'; // 공격 주체: 미국, 이스라엘, 이란, 대리세력 + label: string; + description?: string; + intensity?: number; // 0-100 + imageUrl?: string; // 뉴스/위성 사진 URL + imageCaption?: string; // 사진 설명 +} + +export interface SensorLog { + timestamp: number; + seismic: number; // seismic activity level 0-100 + airPressure: number; // hPa + noiseLevel: number; // dB + radiationLevel: number; // uSv/h +} + +export interface ReplayState { + isPlaying: boolean; + currentTime: number; // unix ms + startTime: number; // unix ms + endTime: number; // unix ms + speed: number; // 1x, 2x, 4x, 8x +} + +export interface ApiConfig { + eventsEndpoint: string; + sensorEndpoint: string; + pollIntervalMs: number; +} + +// Aircraft tracking +export type AircraftCategory = 'military' | 'tanker' | 'surveillance' | 'fighter' | 'cargo' | 'civilian' | 'unknown'; + +export interface Aircraft { + icao24: string; // ICAO 24-bit hex address + callsign: string; + lat: number; + lng: number; + altitude: number; // meters + velocity: number; // m/s + heading: number; // degrees + verticalRate: number; // m/s + onGround: boolean; + category: AircraftCategory; + typecode?: string; // e.g. "KC135", "RC135", "RQ4" + typeDesc?: string; // e.g. "AIRBUS A-350-1000" + registration?: string; // e.g. "A6-XWC" + operator?: string; // e.g. "United Arab Emirates" + squawk?: string; // transponder code + trail?: [number, number][]; // recent positions [lat, lng] + lastSeen: number; // unix ms + activeStart?: number; // unix ms - when aircraft enters area + activeEnd?: number; // unix ms - when aircraft leaves area +} + +// Satellite tracking +export interface Satellite { + noradId: number; + name: string; + tle1: string; + tle2: string; + category: 'reconnaissance' | 'communications' | 'navigation' | 'weather' | 'other'; +} + +export interface SatellitePosition { + noradId: number; + name: string; + lat: number; + lng: number; + altitude: number; // km + category: Satellite['category']; + groundTrack?: [number, number][]; // predicted ground track +} + +// Ship tracking (AIS) +export type ShipCategory = 'warship' | 'carrier' | 'destroyer' | 'submarine' | 'cargo' | 'tanker' | 'patrol' | 'civilian' | 'unknown'; + +export interface Ship { + mmsi: string; // Maritime Mobile Service Identity + name: string; + lat: number; + lng: number; + heading: number; // degrees + speed: number; // knots + course: number; // course over ground + category: ShipCategory; + flag?: string; // country code + typecode?: string; + typeDesc?: string; // e.g. "Cargo ship" + imo?: string; // IMO number + callSign?: string; + status?: string; // "Under way using engine", "Anchored", etc. + destination?: string; + eta?: string; // ISO date + draught?: number; // meters + length?: number; // meters + width?: number; // meters + trail?: [number, number][]; + lastSeen: number; // unix ms + activeStart?: number; // unix ms - when ship enters area + activeEnd?: number; // unix ms - when ship leaves area + shipImagePath?: string | null; // signal-batch image path + shipImageCount?: number; // number of available images +} + +// Iran oil/gas facility +export type OilFacilityType = 'refinery' | 'oilfield' | 'gasfield' | 'terminal' | 'petrochemical' | 'desalination'; + +export interface OilFacility { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + type: OilFacilityType; + capacityBpd?: number; // barrels per day (oil) + capacityMcfd?: number; // million cubic feet per day (gas) + capacityMgd?: number; // million gallons per day (desalination) + reservesBbl?: number; // billion barrels (oil reserves) + reservesTcf?: number; // trillion cubic feet (gas reserves) + operator?: string; + description?: string; + damaged?: boolean; // hit during strikes + damagedAt?: number; // unix ms — time when facility was struck + planned?: boolean; // planned US strike target + plannedLabel?: string; // planned strike description +} + +// Layer visibility +export interface LayerVisibility { + events: boolean; + aircraft: boolean; + satellites: boolean; + ships: boolean; + koreanShips: boolean; + airports: boolean; + sensorCharts: boolean; + oilFacilities: boolean; + militaryOnly: boolean; +} + +export type AppMode = 'replay' | 'live'; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..dad0789 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,120 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [tailwindcss(), react()], + server: { + proxy: { + '/api/ais': { + target: 'https://aisapi.maritime.spglobal.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/ais/, ''), + secure: true, + }, + '/api/rss': { + target: 'https://news.google.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/rss/, ''), + secure: true, + }, + '/api/gdelt': { + target: 'https://api.gdeltproject.org', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/gdelt/, ''), + secure: true, + }, + '/api/nitter1': { + target: 'https://xcancel.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/nitter1/, ''), + secure: true, + }, + '/api/nitter2': { + target: 'https://nitter.privacydev.net', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/nitter2/, ''), + secure: true, + }, + '/api/nitter3': { + target: 'https://nitter.poast.org', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/nitter3/, ''), + secure: true, + }, + '/api/rsshub': { + target: 'https://rsshub.app', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/rsshub/, ''), + secure: true, + }, + '/api/overpass': { + target: 'https://overpass-api.de', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/overpass/, ''), + secure: true, + }, + '/api/khoa-hls': { + target: 'https://www.khoa.go.kr', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/khoa-hls/, ''), + secure: true, + }, + '/api/kbs-hls': { + target: 'https://kbsapi.loomex.net', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/kbs-hls/, ''), + secure: true, + }, + '/api/twsyndication': { + target: 'https://syndication.twitter.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/twsyndication/, ''), + secure: true, + }, + '/api/publish-twitter': { + target: 'https://publish.twitter.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''), + secure: true, + }, + '/api/airplaneslive': { + target: 'https://api.airplanes.live', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/airplaneslive/, ''), + secure: true, + }, + '/api/opensky': { + target: 'https://opensky-network.org', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/opensky/, ''), + secure: true, + }, + '/api/celestrak': { + target: 'https://celestrak.org', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/celestrak/, ''), + secure: true, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; KCG-Monitor/1.0)', + }, + }, + '/api/kcg': { + target: 'http://localhost:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'), + }, + '/signal-batch': { + target: 'https://wing.gc-si.dev', + changeOrigin: true, + secure: false, + }, + '/shipimg': { + target: 'https://wing.gc-si.dev', + changeOrigin: true, + secure: false, + }, + }, + }, +}) diff --git a/prediction/README.md b/prediction/README.md new file mode 100644 index 0000000..7f0ff41 --- /dev/null +++ b/prediction/README.md @@ -0,0 +1,17 @@ +# KCG Prediction Service + +FastAPI 기반 예측 서비스 (향후 구현 예정) + +## 실행 + +```bash +cd prediction +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +## 엔드포인트 + +- `GET /health` - 헬스 체크 diff --git a/prediction/main.py b/prediction/main.py new file mode 100644 index 0000000..fbccfe3 --- /dev/null +++ b/prediction/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(title="KCG Prediction Service", version="0.1.0") + + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/prediction/requirements.txt b/prediction/requirements.txt new file mode 100644 index 0000000..531efda --- /dev/null +++ b/prediction/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn==0.30.6