feat: KCG 모니터링 대시보드 초기 프로젝트 구성

React 19 + TypeScript + Vite + MapLibre 기반 해양 모니터링 대시보드.
선박 AIS, 항공기, CCTV, 위성, 해양 인프라 등 다중 레이어 지원.
ESLint React Compiler 규칙 조정 및 lint 에러 수정 포함.
This commit is contained in:
htlee 2026-03-17 09:01:18 +09:00
부모 348e84c913
커밋 ccdfb3517b
84개의 변경된 파일20880개의 추가작업 그리고 3개의 파일을 삭제

38
.gitignore vendored
파일 보기

@ -29,6 +29,42 @@ coverage/
.prettiercache .prettiercache
*.tsbuildinfo *.tsbuildinfo
# === Claude Code (개인 설정) === # === Claude Code ===
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
!.claude/
.claude/settings.local.json .claude/settings.local.json
.claude/CLAUDE.local.md .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

파일 보기

@ -1,3 +1,73 @@
# kcg-monitoring # React + TypeScript + Vite
KCG 모니터링 대시보드 This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

34
eslint.config.js Normal file
파일 보기

@ -0,0 +1,34 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// React Compiler rules — too strict for ref-in-useMemo patterns, disable for now
'react-hooks/refs': 'off',
'react-hooks/purity': 'off',
'react-hooks/set-state-in-effect': 'off',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
}],
},
},
])

13
index.html Normal file
파일 보기

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iran-airstrike-replay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4341
package-lock.json generated Normal file

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

39
package.json Normal file
파일 보기

@ -0,0 +1,39 @@
{
"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": {
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
"leaflet": "^1.9.4",
"maplibre-gl": "^5.19.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-map-gl": "^8.1.0",
"recharts": "^3.8.0",
"satellite.js": "^6.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

BIN
public/ships/440034000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 113 KiB

BIN
public/ships/440150000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 112 KiB

BIN
public/ships/440272000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 86 KiB

BIN
public/ships/440272000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.2 MiB

BIN
public/ships/440274000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 70 KiB

BIN
public/ships/440323000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 98 KiB

BIN
public/ships/440384000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 107 KiB

BIN
public/ships/440880000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 114 KiB

BIN
public/ships/441046000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 112 KiB

BIN
public/ships/441345000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 112 KiB

BIN
public/ships/441345000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 1.3 MiB

BIN
public/ships/441353000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 123 KiB

BIN
public/ships/441393000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 226 KiB

BIN
public/ships/441423000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 119 KiB

BIN
public/ships/441548000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 146 KiB

BIN
public/ships/441708000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 2.4 MiB

BIN
public/ships/441866000.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 123 KiB

1
public/vite.svg Normal file
파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  크기: 1.5 KiB

1885
src/App.css Normal file

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

1046
src/App.tsx Normal file

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

1
src/assets/react.svg Normal file
파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  크기: 4.0 KiB

파일 보기

@ -0,0 +1,274 @@
import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import type { Aircraft, AircraftCategory } from '../types';
interface Props {
aircraft: Aircraft[];
militaryOnly: boolean;
}
// ═══ tar1090 / Airplanes.live style SVG icons ═══
const SHAPES: Record<string, { viewBox: string; w: number; h: number; path: string }> = {
airliner: {
viewBox: '-1 -2 34 34', w: 24, h: 24,
path: 'M16 1c-.17 0-.67.58-.9 1.03-.6 1.21-.6 1.15-.65 5.2-.04 2.97-.08 3.77-.18 3.9-.15.17-1.82 1.1-1.98 1.1-.08 0-.1-.25-.05-.83.03-.5.01-.92-.05-1.08-.1-.25-.13-.26-.71-.26-.82 0-.86.07-.78 1.5.03.6.08 1.17.11 1.25.05.12-.02.2-.25.33l-8 4.2c-.2.2-.18.1-.19 1.29 3.9-1.2 3.71-1.21 3.93-1.21.06 0 .1 0 .13.14.08.3.28.3.28-.04 0-.25.03-.27 1.16-.6.65-.2 1.22-.35 1.28-.35.05 0 .12.04.15.17.07.3.27.27.27-.08 0-.25.01-.27.7-.47.68-.1.98-.09 1.47-.1.18 0 .22 0 .26.18.06.34.22.35.27-.01.04-.2.1-.17 1.06-.14l1.07.02.05 4.2c.05 3.84.07 4.28.26 5.09.11.49.2.99.2 1.11 0 .19-.31.43-1.93 1.5l-1.93 1.26v1.02l4.13-.95.63 1.54c.05.07.12.09.19.09s.14-.02.19-.09l.63-1.54 4.13.95V29.3l-1.93-1.27c-1.62-1.06-1.93-1.3-1.93-1.49 0-.12.09-.62.2-1.11.19-.81.2-1.25.26-5.09l.05-4.2 1.07-.02c.96-.03 1.02-.05 1.06.14.05.36.21.35.27 0 .04-.17.08-.16.26-.16.49 0 .8-.02 1.48.1.68.2.69.21.69.46 0 .35.2.38.27.08.03-.13.1-.17.15-.17.06 0 .63.15 1.28.34 1.13.34 1.16.36 1.16.61 0 .35.2.34.28.04.03-.13.07-.14.13-.14.22 0 .03 0 3.93 1.2-.01-1.18.02-1.07-.19-1.27l-8-4.21c-.23-.12-.3-.21-.25-.33.03-.08.08-.65.11-1.25.08-1.43.04-1.5-.78-1.5-.58 0-.61.01-.71.26-.06.16-.08.58-.05 1.08.04.58.03.83-.05.83-.16 0-1.83-.93-1.98-1.1-.1-.13-.14-.93-.18-3.9-.05-4.05-.05-3.99-.65-5.2C16.67 1.58 16.17 1 16 1z',
},
hi_perf: {
viewBox: '-7.8 0 80 80', w: 24, h: 24,
path: 'M 30.82,61.32 29.19,54.84 29.06,60.19 27.70,60.70 22.27,60.63 21.68,59.60 l -0.01,-2.71 6.26,-5.52 -0.03,-3.99 -13.35,-0.01 -3e-6,1.15 -1.94,0.00 -0.01,-1.31 0.68,-0.65 L 13.30,37.20 c -0.01,-0.71 0.57,-0.77 0.60,0 l 0.05,1.57 0.28,0.23 0.26,4.09 L 19.90,38.48 c 0,0 -0.04,-1.26 0.20,-1.28 0.16,-0.02 0.20,0.98 0.20,0.98 l 4.40,-3.70 c 0,0 0.02,-1.28 0.20,-1.28 0.14,-0.00 0.20,0.98 0.20,0.98 l 1.80,-1.54 C 27.02,28.77 28.82,25.58 29,21.20 c 0.06,-1.41 0.23,-3.34 0.86,-3.85 0.21,-4.40 1.32,-11.03 2.39,-11.03 1.07,0 2.17,6.64 2.39,11.03 0.63,0.51 0.80,2.45 0.86,3.85 0.18,4.38 1.98,7.57 2.10,11.44 l 1.80,1.54 c 0,0 0.06,-0.99 0.20,-0.98 0.18,0.01 0.20,1.28 0.20,1.28 l 4.40,3.70 c 0,0 0.04,-1.00 0.20,-0.98 0.24,0.03 0.20,1.28 0.20,1.28 l 5.41,4.60 0.26,-4.09 0.28,-0.23 L 50.59,37.20 c 0.03,-0.77 0.61,-0.71 0.60,0 l 0.02,9.37 0.68,0.65 -0.01,1.31 -1.94,-0.00 -3e-6,-1.15 -13.35,0.01 -0.03,3.99 6.26,5.52 L 42.81,59.60 42.22,60.63 36.79,60.70 35.43,60.19 35.30,54.84 33.67,61.32 Z',
},
jet_nonSweep: {
viewBox: '-2 -2.4 22 22', w: 18, h: 18,
path: 'M9,17.09l-3.51.61v-.3c0-.65.11-1,.33-1.09L8.5,15a5.61,5.61,0,0,1-.28-1.32l-.53-.41-.1-.69H7.12l0-.21a7.19,7.19,0,0,1-.15-2.19L.24,9.05V8.84c0-1.1.51-1.15.61-1.15L7.8,7.18V2.88C7.8.64,8.89.3,8.93.28L9,.26l.07,0s1.13.36,1.13,2.6v4.3l7,.51c.09,0,.59.06.59,1.15v.21l-6.69,1.16a7.17,7.17,0,0,1-.15,2.19l0,.21h-.47l-.1.69-.53.41A5.61,5.61,0,0,1,9.5,15l2.74,1.28c.2.07.31.43.31,1.08v.3Z',
},
heavy_2e: {
viewBox: '0 -3.2 64.2 64.2', w: 26, h: 26,
path: 'm 31.414,2.728 c -0.314,0.712 -1.296,2.377 -1.534,6.133 l -0.086,13.379 c 0.006,0.400 -0.380,0.888 -0.945,1.252 l -2.631,1.729 c 0.157,-0.904 0.237,-3.403 -0.162,-3.850 l -2.686,0.006 c -0.336,1.065 -0.358,2.518 -0.109,4.088 h 0.434 L 24.057,26.689 8.611,36.852 7.418,38.432 7.381,39.027 8.875,38.166 l 8.295,-2.771 0.072,0.730 0.156,-0.004 0.150,-0.859 3.799,-1.234 0.074,0.727 0.119,0.004 0.117,-0.832 2.182,-0.730 h 1.670 l 0.061,0.822 h 0.176 l 0.062,-0.822 4.018,-0.002 v 13.602 c 0.051,1.559 0.465,3.272 0.826,4.963 l -6.836,5.426 c -0.097,0.802 -0.003,1.372 0.049,1.885 l 7.734,-2.795 0.477,1.973 h 0.232 l 0.477,-1.973 7.736,2.795 c 0.052,-0.513 0.146,-1.083 0.049,-1.885 l -6.836,-5.426 c 0.361,-1.691 0.775,-3.404 0.826,-4.963 V 33.193 l 4.016,0.002 0.062,0.822 h 0.178 L 38.875,33.195 h 1.672 l 2.182,0.730 0.117,0.832 0.119,-0.004 0.072,-0.727 3.799,1.234 0.152,0.859 0.154,0.004 0.072,-0.730 8.297,2.771 1.492,0.861 -0.037,-0.596 -1.191,-1.580 -15.447,-10.162 0.363,-1.225 H 41.125 c 0.248,-1.569 0.225,-3.023 -0.111,-4.088 l -2.686,-0.006 c -0.399,0.447 -0.317,2.945 -0.160,3.850 L 35.535,23.492 C 34.970,23.128 34.584,22.640 34.590,22.240 L 34.504,8.910 C 34.193,4.926 33.369,3.602 32.934,2.722 32.442,1.732 31.894,1.828 31.414,2.728 Z',
},
helicopter: {
viewBox: '-13 -13 90 90', w: 22, h: 22,
path: 'm 24.698,60.712 c 0,0 -0.450,2.134 -0.861,2.142 -0.561,0.011 -0.480,-3.836 -0.593,-5.761 -0.064,-1.098 1.381,-1.192 1.481,-0.042 l 5.464,0.007 -0.068,-9.482 -0.104,-1.108 c -2.410,-2.131 -3.028,-3.449 -3.152,-7.083 l -12.460,13.179 c -0.773,0.813 -2.977,0.599 -3.483,-0.428 L 26.920,35.416 26.866,29.159 11.471,14.513 c -0.813,-0.773 -0.599,-2.977 0.428,-3.483 l 14.971,14.428 0.150,-5.614 c -0.042,-1.324 1.075,-4.784 3.391,-5.633 0.686,-0.251 2.131,-0.293 3.033,0.008 2.349,0.783 3.433,4.309 3.391,5.633 l 0.073,4.400 12.573,-12.763 c 0.779,-0.807 2.977,-0.599 3.483,0.428 L 37.054,28.325 37.027,35.027 52.411,49.365 c 0.813,0.773 0.599,2.977 -0.428,3.483 L 36.992,38.359 c -0.124,3.634 -0.742,5.987 -3.152,8.118 l -0.104,1.108 -0.068,9.482 5.321,-0.068 c 0.101,-1.150 1.546,-1.057 1.481,0.042 -0.113,1.925 -0.032,5.772 -0.593,5.761 -0.412,-0.008 -0.861,-2.142 -0.861,-2.142 l -5.387,-0.011 0.085,9.377 -1.094,2.059 -1.386,-0.018 -1.093,-2.049 0.085,-9.377 z',
},
cessna: {
viewBox: '0 -1 32 31', w: 20, h: 20,
path: 'M16.36 20.96l2.57.27s.44.05.4.54l-.02.63s-.03.47-.45.54l-2.31.34-.44-.74-.22 1.63-.25-1.62-.38.73-2.35-.35s-.44-.1-.43-.6l-.02-.6s0-.5.48-.5l2.5-.27-.56-5.4-3.64-.1-5.83-1.02h-.45v-2.06s-.07-.37.46-.34l5.8-.17 3.55.12s-.1-2.52.52-2.82l-1.68-.04s-.1-.06 0-.14l1.94-.03s.35-1.18.7 0l1.91.04s.11.05 0 .14l-1.7.02s.62-.09.56 2.82l3.54-.1 5.81.17s.51-.04.48.35l-.01 2.06h-.47l-5.8 1-3.67.11z',
},
ground: {
viewBox: '0 0 24 24', w: 12, h: 12,
path: 'M12 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
},
};
function getShape(ac: Aircraft) {
if (ac.onGround) return SHAPES.ground;
switch (ac.category) {
case 'fighter': case 'military': return SHAPES.hi_perf;
case 'tanker': case 'surveillance': case 'cargo': return SHAPES.heavy_2e;
case 'civilian': return SHAPES.airliner;
default: return SHAPES.jet_nonSweep;
}
}
const ALT_COLORS: [number, string][] = [
[0, '#00c000'], [150, '#2AD62A'], [300, '#55EC55'], [600, '#7CFC00'],
[1200, '#BFFF00'], [1800, '#FFFF00'], [3000, '#FFD700'], [6000, '#FF8C00'],
[9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'],
];
const MIL_COLORS: Partial<Record<AircraftCategory, string>> = {
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff',
};
function getAltitudeColor(altMeters: number): string {
if (altMeters <= 0) return ALT_COLORS[0][1];
for (let i = ALT_COLORS.length - 1; i >= 0; i--) {
if (altMeters >= ALT_COLORS[i][0]) return ALT_COLORS[i][1];
}
return ALT_COLORS[0][1];
}
function getAircraftColor(ac: Aircraft): string {
const milColor = MIL_COLORS[ac.category];
if (milColor) return milColor;
if (ac.onGround) return '#555555';
return getAltitudeColor(ac.altitude);
}
const CATEGORY_LABELS: Record<AircraftCategory, string> = {
fighter: 'FIGHTER', tanker: 'TANKER', surveillance: 'ISR',
cargo: 'CARGO', military: 'MIL', civilian: 'CIV', unknown: '???',
};
// ═══ Planespotters.net photo API ═══
interface PhotoResult { url: string; photographer: string; link: string; }
const photoCache = new Map<string, PhotoResult | null>();
function AircraftPhoto({ hex }: { hex: string }) {
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
photoCache.has(hex) ? photoCache.get(hex) : undefined,
);
useEffect(() => {
if (photo !== undefined) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`https://api.planespotters.net/pub/photos/hex/${hex}`);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
if (cancelled) return;
if (data.photos && data.photos.length > 0) {
const p = data.photos[0];
const result: PhotoResult = {
url: p.thumbnail_large?.src || p.thumbnail?.src || '',
photographer: p.photographer || '',
link: p.link || '',
};
photoCache.set(hex, result);
setPhoto(result);
} else {
photoCache.set(hex, null);
setPhoto(null);
}
} catch {
photoCache.set(hex, null);
setPhoto(null);
}
})();
return () => { cancelled = true; };
}, [hex, photo]);
if (photo === undefined) {
return <div style={{ textAlign: 'center', padding: 8, color: '#888', fontSize: 10 }}>Loading photo...</div>;
}
if (!photo) return null;
return (
<div style={{ marginBottom: 6 }}>
<a href={photo.link} target="_blank" rel="noopener noreferrer">
<img src={photo.url} alt="Aircraft"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</a>
{photo.photographer && (
<div style={{ fontSize: 9, color: '#999', marginTop: 2, textAlign: 'right' }}>
&copy; {photo.photographer}
</div>
)}
</div>
);
}
// ═══ Main layer ═══
export function AircraftLayer({ aircraft, militaryOnly }: Props) {
const filtered = useMemo(() => {
if (militaryOnly) {
return aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown');
}
return aircraft;
}, [aircraft, militaryOnly]);
// Aircraft trails as GeoJSON
const trailData = useMemo(() => ({
type: 'FeatureCollection' as const,
features: filtered
.filter(ac => ac.trail && ac.trail.length > 1)
.map(ac => ({
type: 'Feature' as const,
properties: { color: getAircraftColor(ac) },
geometry: {
type: 'LineString' as const,
coordinates: ac.trail!.map(([lat, lng]) => [lng, lat]),
},
})),
}), [filtered]);
return (
<>
{trailData.features.length > 0 && (
<Source id="aircraft-trails" type="geojson" data={trailData}>
<Layer
id="aircraft-trail-lines"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-width': 1.5,
'line-opacity': 0.4,
'line-dasharray': [4, 4],
}}
/>
</Source>
)}
{filtered.map(ac => (
<AircraftMarker key={ac.icao24} ac={ac} />
))}
</>
);
}
// ═══ Aircraft Marker ═══
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const [showPopup, setShowPopup] = useState(false);
const color = getAircraftColor(ac);
const shape = getShape(ac);
const size = shape.w;
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
return (
<>
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div
style={{
width: size, height: size, cursor: 'pointer',
transform: `rotate(${ac.heading}deg)`,
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
}}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
>
<svg viewBox={shape.viewBox} width={size} height={size}
fill={color} stroke="#000" strokeWidth={strokeWidth}
strokeLinejoin="round" opacity={0.95}>
<path d={shape.path} />
</svg>
</div>
{showLabel && (
<div className="gl-marker-label" style={{ color }}>
{ac.callsign || ac.icao24}
</div>
)}
</div>
</Marker>
{showPopup && (
<Popup longitude={ac.lng} latitude={ac.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup">
<div style={{ minWidth: 240, maxWidth: 300, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<strong style={{ fontSize: 14 }}>{ac.callsign || 'N/A'}</strong>
<span style={{
background: color, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginLeft: 'auto',
}}>
{CATEGORY_LABELS[ac.category]}
</span>
</div>
<AircraftPhoto hex={ac.icao24} />
<table style={{ width: '100%', fontSize: 11, borderCollapse: 'collapse' }}>
<tbody>
<tr><td style={{ color: '#888', paddingRight: 8 }}>Hex</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
{ac.registration && <tr><td style={{ color: '#888' }}>Reg.</td><td><strong>{ac.registration}</strong></td></tr>}
{ac.operator && <tr><td style={{ color: '#888' }}>Operator</td><td>{ac.operator}</td></tr>}
{ac.typecode && (
<tr><td style={{ color: '#888' }}>Type</td>
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? `${ac.typeDesc}` : ''}</td></tr>
)}
{ac.squawk && <tr><td style={{ color: '#888' }}>Squawk</td><td>{ac.squawk}</td></tr>}
<tr><td style={{ color: '#888' }}>Alt</td>
<td>{ac.onGround ? 'GROUND' : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
<tr><td style={{ color: '#888' }}>Speed</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
<tr><td style={{ color: '#888' }}>Hdg</td><td>{Math.round(ac.heading)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>V/S</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
</tbody>
</table>
<div style={{ marginTop: 6, fontSize: 10, textAlign: 'right' }}>
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
Airplanes.live &rarr;
</a>
</div>
</div>
</Popup>
)}
</>
);
}, (prev, next) => {
const a = prev.ac, b = next.ac;
return a.icao24 === b.icao24 &&
Math.abs(a.lat - b.lat) < 0.001 &&
Math.abs(a.lng - b.lng) < 0.001 &&
Math.round(a.heading / 10) === Math.round(b.heading / 10) &&
a.category === b.category &&
Math.round(a.altitude / 500) === Math.round(b.altitude / 500);
});

파일 보기

@ -0,0 +1,138 @@
import { memo, useMemo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { Airport } from '../data/airports';
const US_BASE_ICAOS = new Set([
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
]);
function isUSBase(airport: Airport): boolean {
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
}
const FLAG_EMOJI: Record<string, string> = {
IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}',
AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}',
BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}',
TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}',
SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}',
DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}',
};
const TYPE_LABELS: Record<Airport['type'], string> = {
large: 'International Airport', medium: 'Airport',
small: 'Regional Airport', military: 'Military Airbase',
};
interface Props { airports: Airport[]; }
const TYPE_PRIORITY: Record<Airport['type'], number> = {
military: 3, large: 2, medium: 1, small: 0,
};
// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small.
function deduplicateByArea(airports: Airport[]): Airport[] {
const sorted = [...airports].sort((a, b) => {
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
return pb - pa;
});
const kept: Airport[] = [];
for (const ap of sorted) {
const tooClose = kept.some(
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
);
if (!tooClose) kept.push(ap);
}
return kept;
}
export const AirportLayer = memo(function AirportLayer({ airports }: Props) {
const filtered = useMemo(() => deduplicateByArea(airports), [airports]);
return (
<>
{filtered.map(ap => (
<AirportMarker key={ap.icao} airport={ap} />
))}
</>
);
});
function AirportMarker({ airport }: { airport: Airport }) {
const [showPopup, setShowPopup] = useState(false);
const isMil = airport.type === 'military';
const isUS = isUSBase(airport);
const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b';
const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16;
const flag = FLAG_EMOJI[airport.country] || '';
// Single circle with airplane inside (plane shifted down to center in circle)
const plane = isMil
? <path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z"
fill={color} />
: <path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z"
fill={color} />;
const icon = (
<svg viewBox="0 0 24 24" width={size} height={size}>
<circle cx={12} cy={12} r={10} fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth={2} />
{plane}
</svg>
);
return (
<>
<Marker longitude={airport.lng} latitude={airport.lat} anchor="center">
<div style={{ width: size, height: size, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
{icon}
</div>
</Marker>
{showPopup && (
<Popup longitude={airport.lng} latitude={airport.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e',
color: '#fff', padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
<strong style={{ fontSize: 13, flex: 1 }}>{airport.name}</strong>
</div>
{airport.nameKo && (
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
)}
<div style={{ marginBottom: 8 }}>
<span style={{
background: color, color: isUS || isMil ? '#fff' : '#000',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
</span>
</div>
<div style={{ fontSize: 11, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px' }}>
{airport.iata && <div><span style={{ color: '#888' }}>IATA : </span><strong>{airport.iata}</strong></div>}
<div><span style={{ color: '#888' }}>ICAO : </span><strong>{airport.icao}</strong></div>
{airport.city && <div><span style={{ color: '#888' }}>City : </span>{airport.city}</div>}
<div><span style={{ color: '#888' }}>Country : </span>{airport.country}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}
</div>
{airport.iata && (
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.flightradar24.com/airport/${airport.iata.toLowerCase()}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
Flightradar24 &rarr;
</a>
</div>
)}
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,319 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import Hls from 'hls.js';
import { KOREA_CCTV_CAMERAS } from '../services/cctv';
import type { CctvCamera } from '../services/cctv';
const REGION_COLOR: Record<string, string> = {
'제주': '#ff6b6b',
'남해': '#ffa94d',
'서해': '#69db7c',
'동해': '#74c0fc',
};
const TYPE_LABEL: Record<string, string> = {
tide: '조위관측',
fog: '해무관측',
};
/** KHOA HLS → vite 프록시 경유 */
function toProxyUrl(cam: CctvCamera): string {
return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls');
}
export function CctvLayer() {
const [selected, setSelected] = useState<CctvCamera | null>(null);
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
return (
<>
{KOREA_CCTV_CAMERAS.map(cam => {
const color = REGION_COLOR[cam.region] || '#aaa';
return (
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
<div style={{
position: 'relative', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 2px ${color}88)`,
}}>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
</circle>
</svg>
<div style={{
fontSize: 6, color: '#fff', marginTop: 0,
textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700,
letterSpacing: 0.2,
}}>
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: REGION_COLOR[selected.region] || '#888', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>📹</span> {selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: '#22c55e', color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}> LIVE</span>
<span style={{
background: REGION_COLOR[selected.region] || '#888', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{selected.region}</span>
<span style={{
background: '#333', color: '#ccc',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{TYPE_LABEL[selected.type] || selected.type}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>KHOA</span>
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ fontSize: 9, color: '#666' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
<button
onClick={() => { setStreamCam(selected); setSelected(null); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: '#2563eb', color: '#fff',
padding: '4px 10px', borderRadius: 4, fontSize: 11,
fontWeight: 700, marginTop: 4,
justifyContent: 'center', border: 'none', cursor: 'pointer',
fontFamily: 'monospace',
}}
>
📺
</button>
</div>
</div>
</Popup>
)}
{/* CCTV HLS Stream Modal */}
{streamCam && (
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
)}
</>
);
}
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const proxied = toProxyUrl(cam);
setStatus('loading');
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxied);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setStatus('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setStatus('error');
});
return () => destroyHls();
}
// Safari 네이티브 HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxied;
const onLoaded = () => setStatus('playing');
const onError = () => setStatus('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
setStatus('error');
return () => destroyHls();
}, [cam, destroyHls]);
const color = REGION_COLOR[cam.region] || '#888';
return (
/* Backdrop */
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)',
}}
>
{/* Modal */}
<div
onClick={e => e.stopPropagation()}
style={{
width: 640, maxWidth: '90vw',
background: '#0a0a1a',
border: `1px solid ${color}`,
borderRadius: 8,
overflow: 'hidden',
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 14px',
background: 'rgba(10,10,26,0.95)',
borderBottom: '1px solid #222',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontFamily: 'monospace', fontSize: 11, color: '#ddd',
}}>
<span style={{
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
color: '#fff', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>
{status === 'playing' ? 'LIVE' : status === 'loading' ? '연결중' : 'ERROR'}
</span>
<span style={{ fontWeight: 700 }}>📹 {cam.name}</span>
<span style={{
background: color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>{cam.region}</span>
</div>
<button
onClick={onClose}
style={{
background: '#333', border: 'none', color: '#fff',
width: 24, height: 24, borderRadius: 4,
cursor: 'pointer', fontSize: 14, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
></button>
</div>
{/* Video */}
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', background: '#000' }}>
<video
ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
muted autoPlay playsInline
/>
{status === 'loading' && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.8)',
}}>
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}>📹</div>
<div style={{ fontSize: 11, color: '#888', fontFamily: 'monospace' }}> ...</div>
</div>
)}
{status === 'error' && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.8)',
}}>
<div style={{ fontSize: 28, opacity: 0.4, marginBottom: 8 }}></div>
<div style={{ fontSize: 12, color: '#ef4444', fontFamily: 'monospace', marginBottom: 8 }}> </div>
<a
href={cam.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 10, color: '#3b82f6', fontFamily: 'monospace', textDecoration: 'underline' }}
>badatime.com에서 </a>
</div>
)}
{status === 'playing' && (
<>
<div style={{
position: 'absolute', top: 10, left: 10,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{
fontSize: 10, fontWeight: 700, fontFamily: 'monospace',
padding: '2px 8px', borderRadius: 3,
background: 'rgba(0,0,0,0.7)', color: '#fff',
}}>{cam.name}</span>
<span style={{
fontSize: 9, fontWeight: 700,
padding: '2px 6px', borderRadius: 3,
background: 'rgba(239,68,68,0.3)', color: '#f87171',
}}> REC</span>
</div>
<div style={{
position: 'absolute', bottom: 10, left: 10,
fontSize: 9, fontFamily: 'monospace',
padding: '2px 8px', borderRadius: 3,
background: 'rgba(0,0,0,0.7)', color: '#888',
}}>
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · KHOA
</div>
</>
)}
</div>
{/* Footer info */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 14px',
background: 'rgba(10,10,26,0.95)',
borderTop: '1px solid #222',
fontFamily: 'monospace', fontSize: 9, color: '#555',
}}>
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
<span>KHOA </span>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,131 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../services/coastGuard';
import type { CoastGuardFacility, CoastGuardType } from '../services/coastGuard';
const TYPE_COLOR: Record<CoastGuardType, string> = {
hq: '#ff6b6b',
regional: '#ffa94d',
station: '#4dabf7',
substation: '#69db7c',
vts: '#da77f2',
};
const TYPE_SIZE: Record<CoastGuardType, number> = {
hq: 24,
regional: 20,
station: 16,
substation: 13,
vts: 14,
};
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number }) {
const color = TYPE_COLOR[type];
const isVts = type === 'vts';
if (isVts) {
// VTS: 레이더/안테나 아이콘
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 안테나 */}
<line x1="12" y1="18" x2="12" y2="10" stroke={color} strokeWidth="1.5" />
<circle cx="12" cy="9" r="2" fill="none" stroke={color} strokeWidth="1" />
{/* 전파 */}
<path d="M7 7 Q12 3 17 7" fill="none" stroke={color} strokeWidth="0.8" opacity="0.6" />
<path d="M9 5.5 Q12 3 15 5.5" fill="none" stroke={color} strokeWidth="0.8" opacity="0.4" />
</svg>
);
}
// 해경 로고: 방패 + 앵커
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* 방패 배경 */}
<path d="M12 2 L20 6 L20 13 Q20 19 12 22 Q4 19 4 13 L4 6 Z"
fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.2" />
{/* 앵커 */}
<circle cx="12" cy="9" r="2" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="12" y1="11" x2="12" y2="18" stroke="#fff" strokeWidth="1" />
<path d="M8 16 Q12 20 16 16" fill="none" stroke="#fff" strokeWidth="1" />
<line x1="9" y1="13" x2="15" y2="13" stroke="#fff" strokeWidth="0.8" />
{/* 별 (본청/지방청) */}
{(type === 'hq' || type === 'regional') && (
<circle cx="12" cy="9" r="1" fill={color} />
)}
</svg>
);
}
export function CoastGuardLayer() {
const [selected, setSelected] = useState<CoastGuardFacility | null>(null);
return (
<>
{COAST_GUARD_FACILITIES.map(f => {
const size = TYPE_SIZE[f.type];
return (
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 3px ${TYPE_COLOR[f.type]}66)`,
}}>
<CoastGuardIcon type={f.type} size={size} />
{(f.type === 'hq' || f.type === 'regional') && (
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
textShadow: `0 0 3px ${TYPE_COLOR[f.type]}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700,
}}>
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
</div>
)}
{f.type === 'vts' && (
<div style={{
fontSize: 5, color: '#da77f2', marginTop: 0,
textShadow: '0 0 3px #da77f2, 0 0 2px #000',
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.5,
}}>
VTS
</div>
)}
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: TYPE_COLOR[selected.type], color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: TYPE_COLOR[selected.type], color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{CG_TYPE_LABEL[selected.type]}</span>
<span style={{
background: '#1a1a2e', color: '#4dabf7',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,151 @@
import { useMemo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { damagedShips } from '../data/damagedShips';
import type { DamagedShip } from '../data/damagedShips';
interface Props {
currentTime: number;
}
const FLAG_EMOJI: Record<string, string> = {
GR: '\u{1F1EC}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', KR: '\u{1F1F0}\u{1F1F7}',
IR: '\u{1F1EE}\u{1F1F7}', US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}',
PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', CN: '\u{1F1E8}\u{1F1F3}',
};
const DAMAGE_COLORS: Record<DamagedShip['damage'], string> = {
sunk: '#ff0000',
severe: '#ef4444',
moderate: '#f97316',
minor: '#eab308',
};
const DAMAGE_LABELS: Record<DamagedShip['damage'], string> = {
sunk: '침몰',
severe: '중파',
moderate: '중손',
minor: '경미',
};
const KST_OFFSET = 9 * 3600_000;
function formatKST(ts: number): string {
const d = new Date(ts + KST_OFFSET);
return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} KST`;
}
export function DamagedShipLayer({ currentTime }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const visible = useMemo(
() => damagedShips.filter(s => currentTime >= s.damagedAt),
[currentTime],
);
const selected = selectedId ? visible.find(s => s.id === selectedId) ?? null : null;
return (
<>
{visible.map(ship => {
const color = DAMAGE_COLORS[ship.damage];
const isSunk = ship.damage === 'sunk';
const ageH = (currentTime - ship.damagedAt) / 3600_000;
const isRecent = ageH <= 24;
const size = isRecent ? 28 : 22;
const c = size / 2;
return (
<Marker key={ship.id} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div
style={{ cursor: 'pointer', position: 'relative' }}
onClick={(e) => { e.stopPropagation(); setSelectedId(ship.id); }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* outer ring */}
<circle cx={c} cy={c} r={c - 2} fill="none" stroke={color}
strokeWidth={isRecent ? 2.5 : 1.5} opacity={isRecent ? 0.9 : 0.5}
strokeDasharray={isSunk ? 'none' : '3 2'}
/>
{/* ship icon (simplified) */}
<path
d={`M${c} ${c * 0.35} L${c * 1.45} ${c * 1.3} L${c * 1.25} ${c * 1.55} L${c * 0.75} ${c * 1.55} L${c * 0.55} ${c * 1.3} Z`}
fill={color} fillOpacity={isRecent ? 0.8 : 0.4}
/>
{/* X mark for damage */}
<line x1={c * 0.55} y1={c * 0.55} x2={c * 1.45} y2={c * 1.45}
stroke="#fff" strokeWidth={2} opacity={0.9} />
<line x1={c * 1.45} y1={c * 0.55} x2={c * 0.55} y2={c * 1.45}
stroke="#fff" strokeWidth={2} opacity={0.9} />
{/* inner X in color */}
<line x1={c * 0.6} y1={c * 0.6} x2={c * 1.4} y2={c * 1.4}
stroke={color} strokeWidth={1.2} />
<line x1={c * 1.4} y1={c * 0.6} x2={c * 0.6} y2={c * 1.4}
stroke={color} strokeWidth={1.2} />
</svg>
{/* label */}
<div style={{
position: 'absolute', top: size, left: '50%', transform: 'translateX(-50%)',
whiteSpace: 'nowrap', fontSize: 9, fontWeight: 700,
color, textShadow: '0 0 3px #000, 0 0 6px #000',
pointerEvents: 'none',
}}>
{isRecent && <span style={{
background: color, color: '#000', padding: '0 3px',
borderRadius: 2, marginRight: 3, fontSize: 8,
}}>NEW</span>}
{ship.name}
</div>
{/* pulse for recent */}
{isRecent && (
<div style={{
position: 'absolute', top: 0, left: 0, width: size, height: size,
borderRadius: '50%', border: `2px solid ${color}`,
animation: 'damaged-ship-pulse 2s ease-out infinite',
pointerEvents: 'none',
}} />
)}
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelectedId(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ minWidth: 260, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: DAMAGE_COLORS[selected.damage], color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{FLAG_EMOJI[selected.flag] && <span style={{ fontSize: 16 }}>{FLAG_EMOJI[selected.flag]}</span>}
<strong style={{ flex: 1 }}>{selected.name}</strong>
<span style={{
background: 'rgba(0,0,0,0.3)', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{DAMAGE_LABELS[selected.damage]}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 12px', fontSize: 11 }}>
<div><span style={{ color: '#888' }}> : </span>{selected.type}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.flag}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.cause}</div>
<div><span style={{ color: '#888' }}> : </span>{formatKST(selected.damagedAt)}</div>
</div>
<div style={{ marginTop: 6, fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>
{selected.description}
</div>
</div>
</Popup>
)}
<style>{`
@keyframes damaged-ship-pulse {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
</>
);
}

127
src/components/EezLayer.tsx Normal file
파일 보기

@ -0,0 +1,127 @@
import { Source, Layer } from 'react-map-gl/maplibre';
import { KOREA_EEZ_BOUNDARY, KOREA_CHINA_PMZ, NLL_WEST_SEA, NLL_EAST_SEA } from '../services/koreaEez';
import type { FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl';
// Convert [lat, lng][] to GeoJSON [lng, lat][] ring
function toRing(coords: [number, number][]): [number, number][] {
return coords.map(([lat, lng]) => [lng, lat]);
}
function toLineCoords(coords: [number, number][]): [number, number][] {
return coords.map(([lat, lng]) => [lng, lat]);
}
const eezGeoJSON: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [
// EEZ 경계 폴리곤
{
type: 'Feature',
properties: { type: 'eez' },
geometry: {
type: 'Polygon',
coordinates: [toRing(KOREA_EEZ_BOUNDARY)],
},
},
// 한중 잠정조치수역
{
type: 'Feature',
properties: { type: 'pmz' },
geometry: {
type: 'Polygon',
coordinates: [toRing(KOREA_CHINA_PMZ)],
},
},
// 서해 NLL
{
type: 'Feature',
properties: { type: 'nll' },
geometry: {
type: 'LineString',
coordinates: toLineCoords(NLL_WEST_SEA),
},
},
// 동해 NLL
{
type: 'Feature',
properties: { type: 'nll' },
geometry: {
type: 'LineString',
coordinates: toLineCoords(NLL_EAST_SEA),
},
},
],
};
const eezFillStyle: FillLayerSpecification = {
id: 'eez-fill',
type: 'fill',
source: 'eez-source',
filter: ['==', ['get', 'type'], 'eez'],
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.06,
},
};
const eezLineStyle: LineLayerSpecification = {
id: 'eez-line',
type: 'line',
source: 'eez-source',
filter: ['==', ['get', 'type'], 'eez'],
paint: {
'line-color': '#3b82f6',
'line-width': 1.5,
'line-dasharray': [4, 3],
'line-opacity': 0.6,
},
};
const pmzFillStyle: FillLayerSpecification = {
id: 'pmz-fill',
type: 'fill',
source: 'eez-source',
filter: ['==', ['get', 'type'], 'pmz'],
paint: {
'fill-color': '#eab308',
'fill-opacity': 0.08,
},
};
const pmzLineStyle: LineLayerSpecification = {
id: 'pmz-line',
type: 'line',
source: 'eez-source',
filter: ['==', ['get', 'type'], 'pmz'],
paint: {
'line-color': '#eab308',
'line-width': 1.2,
'line-dasharray': [3, 2],
'line-opacity': 0.5,
},
};
const nllLineStyle: LineLayerSpecification = {
id: 'nll-line',
type: 'line',
source: 'eez-source',
filter: ['==', ['get', 'type'], 'nll'],
paint: {
'line-color': '#ef4444',
'line-width': 2,
'line-dasharray': [6, 4],
'line-opacity': 0.7,
},
};
export function EezLayer() {
return (
<Source id="eez-source" type="geojson" data={eezGeoJSON}>
<Layer {...eezFillStyle} />
<Layer {...eezLineStyle} />
<Layer {...pmzFillStyle} />
<Layer {...pmzLineStyle} />
<Layer {...nllLineStyle} />
</Source>
);
}

747
src/components/EventLog.tsx Normal file
파일 보기

@ -0,0 +1,747 @@
import { useMemo } from 'react';
import type { GeoEvent, Ship } from '../types';
import type { OsintItem } from '../services/osint';
type DashboardTab = 'iran' | 'korea';
interface Props {
events: GeoEvent[];
currentTime: number;
totalShipCount: number;
koreanShips: Ship[];
koreanShipsByCategory: Record<string, number>;
chineseShips?: Ship[];
osintFeed?: OsintItem[];
isLive?: boolean;
dashboardTab?: DashboardTab;
onTabChange?: (tab: DashboardTab) => void;
ships?: Ship[];
}
// ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══
interface BreakingNews {
id: string;
timestamp: number;
category: 'trump' | 'oil' | 'diplomacy' | 'economy';
headline: string;
detail?: string;
}
const T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime();
const HOUR_MS = 3600_000;
const DAY_MS = 24 * HOUR_MS;
const _MIN_MS = 60_000;
const BREAKING_NEWS: BreakingNews[] = [
// DAY 1
{
id: 'bn1', timestamp: T0_NEWS - 11 * HOUR_MS,
category: 'trump', headline: '트럼프: "이란 정권 제거 작전 개시"',
detail: '백악관 긴급 브리핑. "미국은 이란의 핵위협을 더 이상 용납하지 않겠다."',
},
{
id: 'bn2', timestamp: T0_NEWS - 8 * HOUR_MS,
category: 'oil', headline: 'WTI 원유 $140 돌파 — 호르무즈 해협 봉쇄 우려',
detail: '브렌트유 $145, 아시아 선물시장 급등. 호르무즈 해협 통과 원유 일일 2,100만 배럴.',
},
{
id: 'bn3', timestamp: T0_NEWS - 3 * HOUR_MS,
category: 'oil', headline: '호르무즈 해협 봉쇄 선언 — 유가 40% 급등',
detail: 'IRGC 해군 해협 봉쇄. WTI $165, 브렌트 $170. 글로벌 공급망 마비 우려.',
},
{
id: 'bn4', timestamp: T0_NEWS + 2 * HOUR_MS,
category: 'trump', headline: '트럼프: "이란은 매우 큰 대가를 치를 것"',
detail: '알우데이드 미군 3명 전사 확인 후 성명. "미국 군인에 대한 공격은 10배로 갚겠다."',
},
{
id: 'bn5', timestamp: T0_NEWS + 4 * HOUR_MS,
category: 'oil', headline: 'WTI $180 돌파 — 사상 최고가 경신',
detail: '이란 보복 공격으로 걸프 원유 수출 완전 중단. S&P 500 -7% 서킷브레이커.',
},
{
id: 'bn6', timestamp: T0_NEWS + 6 * HOUR_MS,
category: 'economy', headline: '미 국방부: "2,000명 추가 병력 중동 긴급 배치"',
detail: '제82공수사단 신속대응여단 카타르행. 추가 패트리어트 포대 배치.',
},
{
id: 'bn7', timestamp: T0_NEWS + 10 * HOUR_MS,
category: 'economy', headline: '한국 비상 에너지 대책 — 전략비축유 방출 검토',
detail: '산업부, 유류비 급등 대응 비상대책 발표. 걸프 한국 교민 대피 명령.',
},
// DAY 2
{
id: 'bn8', timestamp: T0_NEWS + 1 * DAY_MS,
category: 'oil', headline: 'WTI $185 — 호르무즈 기뢰 추가 배치',
detail: 'IRGC 해협 기뢰 추가 설치. 보험료 1,000% 급등, 유조선 통행 사실상 중단.',
},
{
id: 'bn9', timestamp: T0_NEWS + 1 * DAY_MS + 6 * HOUR_MS,
category: 'trump', headline: '트럼프: "이란 석유시설 전면 타격 승인"',
detail: '"이란이 해협을 닫으면 우리는 이란의 모든 석유시설을 파괴할 것."',
},
{
id: 'bn10', timestamp: T0_NEWS + 1 * DAY_MS + 10 * HOUR_MS,
category: 'economy', headline: 'IEA 긴급 비축유 방출 — 6,000만 배럴',
detail: 'IEA 회원국 전략비축유 협조 방출 합의. 미국 3,000만 배럴 선도 방출.',
},
// DAY 3
{
id: 'bn11', timestamp: T0_NEWS + 2 * DAY_MS,
category: 'oil', headline: '호르무즈 유조선 기뢰 접촉 — 원유 유출',
detail: '그리스 VLCC "아테나 글로리" 기뢰 접촉. 200만 배럴 유출 위기. WTI $190.',
},
{
id: 'bn12', timestamp: T0_NEWS + 2 * DAY_MS + 6 * HOUR_MS,
category: 'trump', headline: '트럼프: "해군에 호르무즈 기뢰 제거 명령"',
detail: '"미 해군 소해정 부대 투입. 해협 72시간 내 재개방 목표."',
},
{
id: 'bn13', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS,
category: 'economy', headline: '한국 선박 12척 오만만 긴급 대피',
detail: '청해부대 호위 하 호르무즈 인근 한국 선박 대피. 해운업계 손실 하루 2,000억원.',
},
// DAY 4
{
id: 'bn14', timestamp: T0_NEWS + 3 * DAY_MS,
category: 'oil', headline: 'WTI $195 — 헤즈볼라 하이파 정유시설 타격',
detail: '이스라엘 하이파 정유시설 화재. 중동 전면전 우려 극대화.',
},
{
id: 'bn15', timestamp: T0_NEWS + 3 * DAY_MS + 8 * HOUR_MS,
category: 'trump', headline: '트럼프: "디모나 공격은 레드라인 — 핵옵션 배제 안 해"',
detail: '디모나 핵시설 인근 피격 후 강경 성명. 세계 핵전쟁 공포 확산.',
},
// DAY 5
{
id: 'bn16', timestamp: T0_NEWS + 4 * DAY_MS,
category: 'economy', headline: '이란 사이버공격 — 이스라엘 전력망 마비',
detail: '이란 APT, 이스라엘 전력망 해킹. 텔아비브 일대 12시간 정전.',
},
{
id: 'bn17', timestamp: T0_NEWS + 4 * DAY_MS + 4 * HOUR_MS,
category: 'oil', headline: 'WTI $200 돌파 — 사우디 라스타누라 드론 피격',
detail: '사우디 최대 석유수출터미널 피격. 글로벌 석유 공급 일 500만 배럴 감소.',
},
{
id: 'bn18', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS,
category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 방출 개시',
detail: '유류비 급등 대응 비상대책. 비축유 500만 배럴 방출. 주유소 가격 L당 2,800원 돌파.',
},
// DAY 6
{
id: 'bn19', timestamp: T0_NEWS + 5 * DAY_MS,
category: 'oil', headline: '이란 항모전단 공격 — WTI $210',
detail: 'IRGC 대함미사일 발사, 이지스 전탄 요격. 해상보험료 역사적 최고치.',
},
{
id: 'bn20', timestamp: T0_NEWS + 5 * DAY_MS + 4 * HOUR_MS,
category: 'trump', headline: '트럼프: "이란 해군 완전히 소탕하겠다"',
detail: '"페르시아만에서 이란 함정이 하나도 남지 않을 때까지 작전 지속."',
},
// DAY 7
{
id: 'bn21', timestamp: T0_NEWS + 6 * DAY_MS + 4 * HOUR_MS,
category: 'trump', headline: '트럼프: "48시간 최후통첩 — 정권교체 불사"',
detail: '"이란이 48시간 내 미사일 발사를 중단하지 않으면 정권 교체 작전을 개시하겠다."',
},
{
id: 'bn22', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS,
category: 'oil', headline: 'WTI $195 소폭 하락 — 휴전 기대감',
detail: '트럼프 최후통첩 후 휴전 기대감. 그러나 IRGC 거부 성명으로 다시 반등.',
},
// DAY 8
{
id: 'bn23', timestamp: T0_NEWS + 7 * DAY_MS,
category: 'diplomacy', headline: 'ICRC: "중동 인도적 위기 — 이란 의약품 고갈"',
detail: '이란 내 의약품·식수 부족 심각. 이스라엘·바레인 민간인 사상자 수천 명.',
},
{
id: 'bn24', timestamp: T0_NEWS + 7 * DAY_MS + 6 * HOUR_MS,
category: 'trump', headline: '트럼프: "이란 정보부 본부도 파괴 — 끝까지 간다"',
detail: 'B-2 이란 정보부(VAJA) 타격 후 성명. "이란에 남은 건 항복뿐."',
},
{
id: 'bn25', timestamp: T0_NEWS + 7 * DAY_MS + 10 * HOUR_MS,
category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국',
detail: '군 수송기 투입. 청해부대 한국 선박 호위 지속. 해운업계 일 3,000억원 손실.',
},
// DAY 9
{
id: 'bn26', timestamp: T0_NEWS + 8 * DAY_MS,
category: 'diplomacy', headline: '러시아, 이란에 휴전 수용 비공식 권고',
detail: '푸틴, 추가 무기 지원 거부. 이란 고립 심화.',
},
{
id: 'bn27', timestamp: T0_NEWS + 8 * DAY_MS + 4 * HOUR_MS,
category: 'oil', headline: '이란 미사일 재고 80% 소진 — WTI $180으로 하락',
detail: '미 정보기관 분석 공개. 이란 잔존 이동식 발사대 10기 이하.',
},
{
id: 'bn28', timestamp: T0_NEWS + 8 * DAY_MS + 8 * HOUR_MS,
category: 'trump', headline: '트럼프: "나탄즈 완전 파괴 — 이란 핵프로그램 종식"',
detail: '"이란의 핵 야망은 영원히 끝났다. 역사가 나를 기억할 것."',
},
{
id: 'bn29', timestamp: T0_NEWS + 8 * DAY_MS + 10 * HOUR_MS,
category: 'diplomacy', headline: 'UN 72시간 인도적 휴전 결의안 채택',
detail: '안보리 찬성 13, 기권 2. 미국·이란 모두 입장 미정.',
},
{
id: 'bn30', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS,
category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분"',
detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.',
},
];
// ═══ 한국 전용 속보 (리플레이) ═══
const BREAKING_NEWS_KR: BreakingNews[] = [
// DAY 1
{ id: 'kr1', timestamp: T0_NEWS - 6 * HOUR_MS, category: 'economy', headline: '한국 NSC 긴급소집 — 호르무즈 사태 대응 논의', detail: '외교·국방·산업부 장관 참석. 교민 보호·에너지 수급 점검.' },
{ id: 'kr2', timestamp: T0_NEWS + 2 * HOUR_MS, category: 'economy', headline: '코스피 -4.2% 급락 — 유가 폭등 충격', detail: '한국 원유 수입의 70% 호르무즈 해협 경유. 정유·항공·운송주 급락.' },
{ id: 'kr3', timestamp: T0_NEWS + 6 * HOUR_MS, category: 'oil', headline: '국내 유가 L당 2,200원 돌파 — 주유소 대란 시작', detail: '수도권 주유소 재고 부족 속출. 산업부 긴급 유류 배급 체계 가동 검토.' },
{ id: 'kr4', timestamp: T0_NEWS + 10 * HOUR_MS, category: 'economy', headline: '한국 전략비축유 방출 검토 — 산업부 비상대책', detail: '걸프 한국 교민 대피 명령. 청해부대 한국 선박 호위 태세.' },
// DAY 2
{ id: 'kr5', timestamp: T0_NEWS + 1 * DAY_MS, category: 'oil', headline: '한국행 원유 탱커 5척 오만만 대기 — 통행 불가', detail: 'VLCC 5척(250만 배럴) 호르무즈 해협 진입 불가. 정유사 원유 재고 2주분.' },
{ id: 'kr6', timestamp: T0_NEWS + 1 * DAY_MS + 8 * HOUR_MS, category: 'economy', headline: '현대·삼성중공업 조선 수주 취소 우려 — 해운보험료 급등', detail: '걸프 향 선박 보험료 1,000% 인상. 해운업계 일 2,000억원 손실.' },
// DAY 3
{ id: 'kr7', timestamp: T0_NEWS + 2 * DAY_MS, category: 'economy', headline: '한국 교민 1,200명 UAE·카타르 대피 중', detail: '외교부 특별기 2대 투입. 청해부대 ROKS 최영함 한국 선박 호위.' },
{ id: 'kr8', timestamp: T0_NEWS + 2 * DAY_MS + 10 * HOUR_MS, category: 'oil', headline: '한국 정유사 가동률 70% 감축 — 원유 부족', detail: 'SK에너지·GS칼텍스·에쓰오일 감산. LPG·석유화학 제품 공급 차질.' },
// DAY 4
{ id: 'kr9', timestamp: T0_NEWS + 3 * DAY_MS, category: 'economy', headline: '코스피 -8.5% — 서킷브레이커 발동', detail: '외국인 6조원 순매도. 원/달러 1,550원 돌파. 한국 CDS 급등.' },
{ id: 'kr10', timestamp: T0_NEWS + 3 * DAY_MS + 6 * HOUR_MS, category: 'oil', headline: '한국, 미국·캐나다 긴급 원유 도입 협상', detail: '비(非)호르무즈 경유 원유 확보. 미 전략비축유 한국 우선배분 요청.' },
// DAY 5
{ id: 'kr11', timestamp: T0_NEWS + 4 * DAY_MS, category: 'economy', headline: '한국 비상경제대책 발동 — 전략비축유 500만 배럴 방출', detail: '주유소 가격 L당 2,800원 돌파. 택시·화물차 운행 감축 논의.' },
{ id: 'kr12', timestamp: T0_NEWS + 4 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한국 외교부, 이란에 교민 안전 보장 요청', detail: '이란 주재 한국대사관 최소 인원 운영. 한국인 체류자 150명 잔류.' },
// DAY 6
{ id: 'kr13', timestamp: T0_NEWS + 5 * DAY_MS, category: 'oil', headline: '한국 LNG 긴급 수입 — 호주·카타르 장기계약 가동', detail: 'LNG 스팟 가격 MMBtu $35 돌파. 가스공사 비축량 2주분.' },
{ id: 'kr14', timestamp: T0_NEWS + 5 * DAY_MS + 6 * HOUR_MS, category: 'economy', headline: '한국 해운 3사 호르무즈 회항 — 희망봉 우회', detail: 'HMM·팬오션·대한해운 전 선박 희망봉 우회. 운항 일수 +14일, 비용 +40%.' },
// DAY 7
{ id: 'kr15', timestamp: T0_NEWS + 6 * DAY_MS, category: 'economy', headline: '한국 제조업 PMI 42.1 — 3년 최저', detail: '석유화학·철강·자동차 부품 공급 차질. 수출 전년비 -15% 전망.' },
{ id: 'kr16', timestamp: T0_NEWS + 6 * DAY_MS + 8 * HOUR_MS, category: 'diplomacy', headline: '한미 정상 긴급통화 — 에너지 안보 협력 강화', detail: '미국, 한국에 전략비축유 500만 배럴 추가 배분. 원유 수송 해군 호위 합의.' },
// DAY 8
{ id: 'kr17', timestamp: T0_NEWS + 7 * DAY_MS, category: 'economy', headline: '한국 교민 350명 두바이 경유 긴급 귀국', detail: '군 수송기 C-130J 2대 투입. 청해부대 한국 선박 호위 지속.' },
{ id: 'kr18', timestamp: T0_NEWS + 7 * DAY_MS + 8 * HOUR_MS, category: 'oil', headline: '한국 원유 비축 45일분으로 감소 — 경고 수준', detail: 'IEA 권고 90일 대비 절반. 추가 긴축 조치 불가피.' },
// DAY 9
{ id: 'kr19', timestamp: T0_NEWS + 8 * DAY_MS, category: 'diplomacy', headline: '한국, UN 휴전 결의안 공동 발의', detail: '인도적 위기 해소와 호르무즈 재개방을 위한 72시간 휴전 촉구.' },
{ id: 'kr20', timestamp: T0_NEWS + 8 * DAY_MS + 12 * HOUR_MS, category: 'economy', headline: '한국 NSC: "에너지 비상계획 수립 — 비축유 90일분 목표"', detail: '호르무즈 봉쇄 장기화 대비. LNG 대체수입선 확보 논의.' },
];
const TYPE_LABELS: Record<GeoEvent['type'], string> = {
airstrike: 'STRIKE',
explosion: 'EXPLOSION',
missile_launch: 'LAUNCH',
intercept: 'INTERCEPT',
alert: 'ALERT',
impact: 'IMPACT',
};
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
// 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 labels and colors
const MT_CATEGORIES: Record<string, { label: string; color: string }> = {
cargo: { label: '화물선', color: '#8bc34a' }, // green
tanker: { label: '유조선', color: '#e91e63' }, // red/pink
passenger: { label: '여객선', color: '#2196f3' }, // blue
high_speed: { label: '고속선', color: '#ff9800' }, // orange
tug_special: { label: '예인선/특수선', color: '#00bcd4' }, // teal
fishing: { label: '어선', color: '#ff5722' }, // deep orange
pleasure: { label: '레저선', color: '#9c27b0' }, // purple
military: { label: '군함', color: '#607d8b' }, // blue-grey
unspecified: { label: '미분류', color: '#9e9e9e' }, // grey
};
const NEWS_CATEGORY_STYLE: Record<BreakingNews['category'], { icon: string; color: string; label: string }> = {
trump: { icon: '🇺🇸', color: '#ef4444', label: '트럼프' },
oil: { icon: '🛢️', color: '#f59e0b', label: '유가' },
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
economy: { icon: '📊', color: '#3b82f6', label: '경제' },
};
// OSINT category styles
const OSINT_CAT_STYLE: Record<string, { icon: string; color: string; label: string }> = {
military: { icon: '🎯', color: '#ef4444', label: '군사' },
oil: { icon: '🛢', color: '#f59e0b', label: '에너지' },
diplomacy: { icon: '🌐', color: '#8b5cf6', label: '외교' },
shipping: { icon: '🚢', color: '#06b6d4', label: '해운' },
nuclear: { icon: '☢', color: '#f97316', label: '핵' },
maritime_accident: { icon: '🚨', color: '#ef4444', label: '해양사고' },
fishing: { icon: '🐟', color: '#22c55e', label: '어선/수산' },
maritime_traffic: { icon: '🚢', color: '#3b82f6', label: '해상교통' },
general: { icon: '📰', color: '#6b7280', label: '일반' },
};
const EMPTY_OSINT: OsintItem[] = [];
const EMPTY_SHIPS: import('../types').Ship[] = [];
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return '방금';
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
}
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 visibleEvents = useMemo(
() => events.filter(e => e.timestamp <= currentTime).reverse(),
[events, currentTime],
);
const visibleNews = useMemo(
() => events.length > 0
? BREAKING_NEWS.filter(n => n.timestamp <= currentTime).reverse()
: [],
[events.length, currentTime],
);
const visibleNewsKR = useMemo(
() => events.length > 0
? BREAKING_NEWS_KR.filter(n => n.timestamp <= currentTime).reverse()
: [],
[events.length, currentTime],
);
// Iran-related ships (military + Iranian flag)
const _iranMilitaryShips = useMemo(() =>
ships.filter(s =>
s.flag === 'IR' ||
s.category === 'carrier' || s.category === 'destroyer' ||
s.category === 'warship' || s.category === 'patrol'
).sort((a, b) => {
const order: Record<string, number> = { carrier: 0, destroyer: 1, warship: 2, patrol: 3, tanker: 4, cargo: 5, civilian: 6, unknown: 7 };
return (order[a.category] ?? 9) - (order[b.category] ?? 9);
}),
[ships],
);
return (
<div className="event-log">
{/*
IRAN TAB
*/}
{dashboardTab === 'iran' && (
<>
{/* Breaking News Section (replay) */}
{visibleNews.length > 0 && (
<div className="breaking-news-section">
<div className="breaking-news-header">
<span className="breaking-flash">BREAKING</span>
<span className="breaking-title"> / </span>
</div>
<div className="breaking-news-list">
{visibleNews.map(n => {
const style = NEWS_CATEGORY_STYLE[n.category];
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
</span>
<span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
</span>
</div>
<div className="breaking-news-headline">{n.headline}</div>
{n.detail && <div className="breaking-news-detail">{n.detail}</div>}
</div>
);
})}
</div>
</div>
)}
{/* Korean Ship Overview (Iran dashboard) */}
{koreanShips.length > 0 && (
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total" style={{ color: '#ef4444' }}>{koreanShips.length}</span>
</div>
<div className="iran-mil-list">
{koreanShips.slice(0, 30).map(s => {
const mt = MT_CATEGORIES[getShipMTCategory(s.typecode, s.category)] || { label: '기타', color: '#888' };
return (
<div key={s.mmsi} className="iran-mil-item">
<span className="iran-mil-flag">🇰🇷</span>
<span className="iran-mil-name">{s.name}</span>
<span className="iran-mil-cat" style={{ color: mt.color, background: `${mt.color}22` }}>
{mt.label}
</span>
{s.speed != null && s.speed > 0.5 ? (
<span style={{ fontSize: 9, color: '#22c55e', marginLeft: 'auto' }}>{s.speed.toFixed(1)}kn</span>
) : (
<span style={{ fontSize: 9, color: '#ef4444', marginLeft: 'auto' }}></span>
)}
</div>
);
})}
</div>
</div>
)}
{/* OSINT Live Feed (live mode) */}
{isLive && osintFeed.length > 0 && (
<>
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">OSINT LIVE FEED</span>
<span className="osint-count">{osintFeed.length}</span>
</div>
<div className="osint-list">
{osintFeed.map(item => {
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
const isRecent = Date.now() - item.timestamp < 3600_000;
return (
<a
key={item.id}
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
>
<div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
</span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span>
</div>
<div className="osint-item-title">{item.title}</div>
<div className="osint-item-source">{item.source}</div>
</a>
);
})}
</div>
</>
)}
{isLive && osintFeed.length === 0 && (
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">OSINT LIVE FEED</span>
<span className="osint-loading">Loading...</span>
</div>
)}
{/* Event Log (replay mode) */}
{!isLive && (
<>
<h3>Event Log</h3>
<div className="event-list">
{visibleEvents.length === 0 && (
<div className="event-empty">No events yet. Press play to start replay.</div>
)}
{visibleEvents.map(e => {
const isNew = currentTime - e.timestamp < 86_400_000;
return (
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid #ff0000' } : undefined}>
<span
className="event-tag"
style={{ backgroundColor: TYPE_COLORS[e.type] }}
>
{TYPE_LABELS[e.type]}
</span>
<div className="event-content">
<div className="event-label">
{isNew && (
<span style={{
background: '#ff0000', color: '#fff', padding: '0 4px',
borderRadius: 2, fontSize: 9, marginRight: 4, fontWeight: 700,
}}>NEW</span>
)}
{e.label}
</div>
<div className="event-time">
{new Date(e.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')} KST
</div>
{e.description && (
<div className="event-desc">{e.description}</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
</>
)}
{/*
KOREA TAB
*/}
{dashboardTab === 'korea' && (
<>
{/* 한국 속보 (replay) */}
{visibleNewsKR.length > 0 && (
<div className="breaking-news-section" style={{ borderLeftColor: '#3b82f6' }}>
<div className="breaking-news-header">
<span className="breaking-flash" style={{ background: '#3b82f6' }}></span>
<span className="breaking-title">🇰🇷 </span>
</div>
<div className="breaking-news-list">
{visibleNewsKR.map(n => {
const style = NEWS_CATEGORY_STYLE[n.category];
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
return (
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
<div className="breaking-news-top">
<span className="breaking-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
</span>
<span className="breaking-news-time">
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
</span>
</div>
<div className="breaking-news-headline">{n.headline}</div>
{n.detail && <div className="breaking-news-detail">{n.detail}</div>}
</div>
);
})}
</div>
</div>
)}
{/* 한국 선박 현황 — 선종별 분류 */}
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇰🇷</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total">{koreanShips.length}</span>
</div>
{koreanShips.length > 0 && (() => {
// 선종별 그룹핑
const groups: Record<string, Ship[]> = {};
for (const s of koreanShips) {
const cat = getShipMTCategory(s.typecode, s.category);
if (!groups[cat]) groups[cat] = [];
groups[cat].push(s);
}
// 정렬 순서: 군함 → 유조선 → 화물선 → 여객선 → 어선 → 예인선 → 기타
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
const sorted = order.filter(k => groups[k]?.length);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
{sorted.map(cat => {
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
const list = groups[cat];
const moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving;
return (
<div key={cat} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
}}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: mt.color, flexShrink: 0,
}} />
<span style={{
fontSize: 11, fontWeight: 700, color: mt.color,
minWidth: 70, fontFamily: 'monospace',
}}>{mt.label}</span>
<span style={{
fontSize: 13, fontWeight: 700, color: '#fff',
fontFamily: 'monospace',
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}></span></span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
{moving > 0 && <span style={{ color: '#22c55e' }}> {moving}</span>}
{anchored > 0 && <span style={{ color: '#ef4444' }}> {anchored}</span>}
</span>
</div>
);
})}
</div>
);
})()}
</div>
{/* 중국 선박 현황 */}
<div className="iran-ship-summary">
<div className="area-ship-header">
<span className="area-ship-icon">🇨🇳</span>
<span className="area-ship-title"> </span>
<span className="area-ship-total">{chineseShips.length}</span>
</div>
{chineseShips.length > 0 && (() => {
const groups: Record<string, Ship[]> = {};
for (const s of chineseShips) {
const cat = getShipMTCategory(s.typecode, s.category);
if (!groups[cat]) groups[cat] = [];
groups[cat].push(s);
}
const order = ['military', 'tanker', 'cargo', 'passenger', 'fishing', 'tug_special', 'high_speed', 'pleasure', 'unspecified'];
const sorted = order.filter(k => groups[k]?.length);
const fishingCount = groups['fishing']?.length || 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 0' }}>
{fishingCount > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 8px', marginBottom: 2,
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 4,
}}>
<span style={{ fontSize: 14 }}>🚨</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', fontFamily: 'monospace' }}>
{fishingCount}
</span>
</div>
)}
{sorted.map(cat => {
const mt = MT_CATEGORIES[cat] || MT_CATEGORIES.unspecified;
const list = groups[cat];
const moving = list.filter(s => s.speed > 0.5).length;
const anchored = list.length - moving;
return (
<div key={cat} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 8px',
background: `${mt.color}0a`,
borderLeft: `3px solid ${mt.color}`,
borderRadius: '0 4px 4px 0',
}}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: mt.color, flexShrink: 0,
}} />
<span style={{
fontSize: 11, fontWeight: 700, color: mt.color,
minWidth: 70, fontFamily: 'monospace',
}}>{mt.label}</span>
<span style={{
fontSize: 13, fontWeight: 700, color: '#fff',
fontFamily: 'monospace',
}}>{list.length}<span style={{ fontSize: 9, color: '#888', fontWeight: 400 }}></span></span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9, fontFamily: 'monospace' }}>
{moving > 0 && <span style={{ color: '#22c55e' }}> {moving}</span>}
{anchored > 0 && <span style={{ color: '#ef4444' }}> {anchored}</span>}
</span>
</div>
);
})}
</div>
);
})()}
</div>
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
{osintFeed.length > 0 && (
<>
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">🇰🇷 OSINT LIVE</span>
<span className="osint-count">{(() => {
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
const seen = new Set<string>();
return filtered.filter(i => {
const key = i.title.replace(/\s+/g, '').slice(0, 30).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
}).length;
})()}</span>
</div>
<div className="osint-list">
{(() => {
const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil');
const seen = new Set<string>();
return filtered.filter(item => {
const key = item.title.replace(/\s+/g, '').slice(0, 30).toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
})().map(item => {
const style = OSINT_CAT_STYLE[item.category] || OSINT_CAT_STYLE.general;
const isRecent = Date.now() - item.timestamp < 3600_000;
return (
<a
key={item.id}
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
>
<div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: style.color }}>
{style.icon} {style.label}
</span>
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
<span className="osint-time">{timeAgo(item.timestamp)}</span>
</div>
<div className="osint-item-title">{item.title}</div>
<div className="osint-item-source">{item.source}</div>
</a>
);
})}
</div>
</>
)}
{osintFeed.length === 0 && (
<div className="osint-header">
<span className="osint-live-dot" />
<span className="osint-title">🇰🇷 OSINT LIVE</span>
<span className="osint-loading">Loading...</span>
</div>
)}
</>
)}
</div>
);
}

파일 보기

@ -0,0 +1,137 @@
import { useMemo, useState } from 'react';
import type { GeoEvent } from '../types';
interface Props {
events: GeoEvent[];
currentTime: number;
startTime: number;
endTime: number;
onEventClick: (event: GeoEvent) => void;
}
const KST_OFFSET = 9 * 3600_000;
const TYPE_COLORS: Record<string, string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
const TYPE_LABELS_KO: Record<string, string> = {
airstrike: '공습',
explosion: '폭발',
missile_launch: '미사일',
intercept: '요격',
alert: '경보',
impact: '피격',
osint: 'OSINT',
};
const SOURCE_LABELS_KO: Record<string, string> = {
US: '미국',
IL: '이스라엘',
IR: '이란',
proxy: '대리세력',
};
interface EventGroup {
dateKey: string; // "2026-03-01"
dateLabel: string; // "03/01 (토)"
events: GeoEvent[];
}
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토'];
export function EventStrip({ events, currentTime, onEventClick }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const groups = useMemo(() => {
const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
const map = new Map<string, GeoEvent[]>();
for (const ev of sorted) {
const d = new Date(ev.timestamp + KST_OFFSET);
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(ev);
}
const result: EventGroup[] = [];
for (const [dateKey, evs] of map) {
const d = new Date(evs[0].timestamp + KST_OFFSET);
const dayName = DAY_NAMES[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]);
// Auto-open the first group if none selected
const effectiveOpen = openDate ?? (groups.length > 0 ? groups[0].dateKey : null);
const formatTimeKST = (ts: number) => {
const d = new Date(ts + KST_OFFSET);
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
};
return (
<div className="event-strip">
{/* Date tabs */}
<div className="es-tabs">
<span className="es-label">STRIKES</span>
{groups.map(g => {
const isActive = effectiveOpen === g.dateKey;
const passedCount = g.events.filter(e => e.timestamp <= currentTime).length;
return (
<button
key={g.dateKey}
className={`es-tab ${isActive ? 'active' : ''}`}
onClick={() => setOpenDate(isActive ? null : g.dateKey)}
>
<span className="es-tab-date">{g.dateLabel}</span>
<span className="es-tab-count">{passedCount}/{g.events.length}</span>
</button>
);
})}
</div>
{/* Expanded event list for selected date */}
{effectiveOpen && (() => {
const group = groups.find(g => g.dateKey === effectiveOpen);
if (!group) return null;
return (
<div className="es-events">
{group.events.map(ev => {
const isPast = ev.timestamp <= currentTime;
const color = TYPE_COLORS[ev.type] || '#888';
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
return (
<button
key={ev.id}
className={`es-event ${isPast ? 'past' : 'future'}`}
style={{ '--dot-color': color } as React.CSSProperties}
onClick={() => onEventClick(ev)}
title={ev.description || ev.label}
>
<span className="es-dot" />
<span className="es-time">{formatTimeKST(ev.timestamp)}</span>
{source && (
<span className="es-source" style={{ background: color }}>{source}</span>
)}
<span className="es-name">{ev.label}</span>
<span className="es-type">{typeLabel}</span>
</button>
);
})}
</div>
);
})()}
</div>
);
}

232
src/components/GlobeMap.tsx Normal file
파일 보기

@ -0,0 +1,232 @@
import { useRef, useEffect } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { countryLabelsGeoJSON } from '../data/countryLabels';
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../types';
interface Props {
events: GeoEvent[];
currentTime: number;
aircraft: Aircraft[];
satellites: SatellitePosition[];
ships: Ship[];
layers: LayerVisibility;
}
const EVENT_COLORS: Record<string, string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
// Navy flag-based colors for military vessels
const NAVY_COLORS: Record<string, string> = {
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261',
};
const SHIP_COLORS: Record<string, string> = {
carrier: '#ef4444',
destroyer: '#f97316',
warship: '#fb923c',
patrol: '#fbbf24',
submarine: '#8b5cf6',
tanker: '#22d3ee',
cargo: '#94a3b8',
civilian: '#64748b',
};
const MIL_SHIP_CATS = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
function getGlobeShipColor(cat: string, flag?: string): string {
if (MIL_SHIP_CATS.includes(cat) && flag && NAVY_COLORS[flag]) return NAVY_COLORS[flag];
return SHIP_COLORS[cat] || '#64748b';
}
const AC_COLORS: Record<string, string> = {
fighter: '#ef4444',
bomber: '#dc2626',
surveillance: '#f59e0b',
tanker: '#22d3ee',
transport: '#10b981',
cargo: '#6366f1',
helicopter: '#a855f7',
civilian: '#64748b',
unknown: '#475569',
};
export function GlobeMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markersRef = useRef<maplibregl.Marker[]>([]);
// Initialize map
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
'dark-tiles': {
type: 'raster',
tiles: ['https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'],
tileSize: 256,
attribution: '&copy; 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
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Clear old markers
for (const m of markersRef.current) m.remove();
markersRef.current = [];
const addMarker = (lng: number, lat: number, color: string, size: number, tooltip: string) => {
const el = document.createElement('div');
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.borderRadius = '50%';
el.style.background = color;
el.style.border = `1.5px solid ${color}`;
el.style.boxShadow = `0 0 ${size}px ${color}80`;
el.style.cursor = 'pointer';
el.title = tooltip;
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
markersRef.current.push(marker);
};
const addTriangle = (lng: number, lat: number, color: string, size: number, heading: number, tooltip: string) => {
const el = document.createElement('div');
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.transform = `rotate(${heading}deg)`;
el.style.cursor = 'pointer';
el.title = tooltip;
el.innerHTML = `<svg viewBox="0 0 10 10" width="${size}" height="${size}">
<polygon points="5,0 0,10 10,10" fill="${color}" stroke="#fff" stroke-width="0.5" opacity="0.9"/>
</svg>`;
const marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
markersRef.current.push(marker);
};
// Events
if (layers.events) {
const visible = events.filter(e => e.timestamp <= currentTime);
for (const e of visible) {
const color = EVENT_COLORS[e.type] || '#888';
const size = e.type === 'impact' ? 14 : 8;
addMarker(e.lng, e.lat, color, size, `${e.label}\n${new Date(e.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST`);
}
}
// Aircraft
if (layers.aircraft) {
const filtered = layers.militaryOnly
? aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown')
: aircraft;
for (const ac of filtered) {
const color = AC_COLORS[ac.category] || '#64748b';
addTriangle(ac.lng, ac.lat, color, 10, ac.heading || 0,
`${ac.callsign || ac.icao24} [${ac.category}]\nAlt: ${ac.altitude?.toFixed(0) || '?'}ft`);
}
}
// Satellites
if (layers.satellites) {
for (const sat of satellites) {
addMarker(sat.lng, sat.lat, '#ef4444', 5, `${sat.name}\nAlt: ${sat.altitude?.toFixed(0)}km`);
}
}
// Ships
if (layers.ships) {
const filtered = layers.militaryOnly
? ships.filter(s => !['civilian', 'cargo', 'tanker'].includes(s.category))
: ships;
for (const s of filtered) {
const color = getGlobeShipColor(s.category, s.flag);
addTriangle(s.lng, s.lat, color, 10, s.heading || 0,
`${s.name} [${s.category}]\n${s.flag || ''}`);
}
}
}, [events, currentTime, aircraft, satellites, ships, layers]);
return (
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
);
}

파일 보기

@ -0,0 +1,171 @@
import { useMemo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { PowerFacility } from '../services/infra';
// SVG Wind Turbine Icon
function WindTurbineIcon({ color, size = 14 }: { color: string; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* Tower */}
<line x1="12" y1="10" x2="11" y2="23" stroke={color} strokeWidth="1.5" />
<line x1="12" y1="10" x2="13" y2="23" stroke={color} strokeWidth="1.5" />
{/* Hub */}
<circle cx="12" cy="9" r="1.5" fill={color} />
{/* Blade 1 - top */}
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={color} opacity="0.9" />
{/* Blade 2 - bottom-right */}
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={color} opacity="0.9" />
{/* Blade 3 - bottom-left */}
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={color} opacity="0.9" />
{/* Base */}
<line x1="8" y1="23" x2="16" y2="23" stroke={color} strokeWidth="1.5" />
</svg>
);
}
interface Props {
facilities: PowerFacility[];
}
// Source → icon & color
const SOURCE_STYLE: Record<string, { icon: string; color: string; label: string }> = {
nuclear: { icon: '☢️', color: '#e040fb', label: '원자력' },
coal: { icon: '🏭', color: '#795548', label: '석탄' },
gas: { icon: '🔥', color: '#ff9800', label: 'LNG' },
oil: { icon: '🛢️', color: '#5d4037', label: '석유' },
hydro: { icon: '💧', color: '#2196f3', label: '수력' },
solar: { icon: '☀️', color: '#ffc107', label: '태양광' },
wind: { icon: '🌀', color: '#00bcd4', label: '풍력' },
biomass: { icon: '🌿', color: '#4caf50', label: '바이오' },
};
const SUBSTATION_STYLE = { icon: '⚡', color: '#ffeb3b', label: '변전소' };
function getStyle(f: PowerFacility) {
if (f.type === 'substation') return SUBSTATION_STYLE;
return SOURCE_STYLE[f.source || ''] || { icon: '⚡', color: '#9e9e9e', label: '발전소' };
}
function formatVoltage(v?: string): string {
if (!v) return '';
const kv = parseInt(v) / 1000;
if (isNaN(kv)) return v;
return `${kv}kV`;
}
export function InfraLayer({ facilities }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const plants = useMemo(() => facilities.filter(f => f.type === 'plant'), [facilities]);
const substations = useMemo(() => facilities.filter(f => f.type === 'substation'), [facilities]);
const selected = selectedId ? facilities.find(f => f.id === selectedId) ?? null : null;
return (
<>
{/* Substations — smaller, show at higher zoom */}
{substations.map(f => {
const s = getStyle(f);
return (
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
<div style={{
width: 7, height: 7, borderRadius: 1,
background: '#1a1a2e', border: `1px solid ${s.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 4, cursor: 'pointer',
}}>
<span>{s.icon}</span>
</div>
</Marker>
);
})}
{/* Power plants — larger, always visible */}
{plants.map(f => {
const s = getStyle(f);
const isWind = f.source === 'wind';
return (
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedId(f.id); }}>
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
cursor: 'pointer', pointerEvents: 'auto',
}}>
{isWind ? (
<WindTurbineIcon color={s.color} size={14} />
) : (
<div style={{
width: 12, height: 12, borderRadius: 2,
background: '#111', border: `1px solid ${s.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, boxShadow: `0 0 3px ${s.color}33`,
}}>
<span>{s.icon}</span>
</div>
)}
<div style={{
fontSize: 6, color: s.color, marginTop: 1,
textShadow: '0 0 3px #000, 0 0 3px #000',
whiteSpace: 'nowrap', fontWeight: 600,
}}>
{f.name.length > 10 ? f.name.slice(0, 10) + '..' : f.name}
</div>
</div>
</Marker>
);
})}
{/* Popup */}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelectedId(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: getStyle(selected).color, color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>{getStyle(selected).icon}</span>
{selected.name}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: getStyle(selected).color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{getStyle(selected).label}
</span>
<span style={{
background: '#333', color: '#ccc',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>
{selected.type === 'plant' ? '발전소' : '변전소'}
</span>
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>
{selected.output && (
<div><span style={{ color: '#888' }}>: </span><strong>{selected.output}</strong></div>
)}
{selected.voltage && (
<div><span style={{ color: '#888' }}>: </span><strong>{formatVoltage(selected.voltage)}</strong></div>
)}
{selected.operator && (
<div><span style={{ color: '#888' }}>: </span>{selected.operator}</div>
)}
{selected.source && (
<div><span style={{ color: '#888' }}>: </span>{selected.source}</div>
)}
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,80 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { KOREAN_AIRPORTS } from '../services/airports';
import type { KoreanAirport } from '../services/airports';
export function KoreaAirportLayer() {
const [selected, setSelected] = useState<KoreanAirport | null>(null);
return (
<>
{KOREAN_AIRPORTS.map(ap => {
const isIntl = ap.intl;
const color = isIntl ? '#a78bfa' : '#7c8aaa';
const size = isIntl ? 20 : 16;
return (
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 3px ${color}88)`,
}}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
{/* 비행기 모양 (위를 향한 여객기) */}
<path d="M12 5 C11.4 5 11 5.4 11 5.8 L11 10 L6 13 L6 14.2 L11 12.5 L11 16.5 L9.2 17.8 L9.2 18.8 L12 18 L14.8 18.8 L14.8 17.8 L13 16.5 L13 12.5 L18 14.2 L18 13 L13 10 L13 5.8 C13 5.4 12.6 5 12 5Z"
fill={color} stroke="#fff" strokeWidth="0.3" />
</svg>
<div style={{
fontSize: 6, color: '#fff', marginTop: 1,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
}}>
{ap.nameKo.replace('국제공항', '').replace('공항', '')}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="260px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
<div style={{
background: selected.intl ? '#a78bfa' : '#7c8aaa', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
{selected.intl && (
<span style={{
background: '#a78bfa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
)}
{selected.domestic && (
<span style={{
background: '#7c8aaa', color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
)}
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.id} / {selected.icao}</span>
</div>
<div style={{ fontSize: 9, color: '#666' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}

428
src/components/KoreaMap.tsx Normal file
파일 보기

@ -0,0 +1,428 @@
import { useRef, useState, useEffect, useCallback } from 'react';
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[];
militaryOnly: boolean;
osintFeed: OsintItem[];
currentTime: number;
koreaFilters: KoreaFiltersState;
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
}
interface KoreaLayers {
ships: boolean;
aircraft: boolean;
satellites: boolean;
infra: boolean;
cables: boolean;
cctv: boolean;
airports: boolean;
coastGuard: boolean;
navWarning: boolean;
osint: boolean;
eez: boolean;
piracy: boolean;
militaryOnly: boolean;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
const MAP_STYLE = {
version: 8 as const,
sources: {
'satellite': {
type: 'raster' as const,
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; Esri, Maxar',
},
'carto-dark': {
type: 'raster' as const,
tiles: [
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
},
'opensea': {
type: 'raster' as const,
tiles: [
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 18,
},
},
layers: [
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0d1f3c' } },
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.75, 'raster-saturation': -0.1, 'raster-contrast': 0.05 } },
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.25 } },
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.5 } },
],
};
// Korea-centered view
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
const KOREA_MAP_ZOOM = 6;
const FILTER_LABELS: Record<string, { label: string; color: string; icon: string }> = {
illegalFishing: { label: '불법어선 감시', color: '#ef4444', icon: '🚫🐟' },
illegalTransship: { label: '불법환적 감시', color: '#f97316', icon: '⚓' },
darkVessel: { label: '다크베셀 감시', color: '#8b5cf6', icon: '👻' },
cableWatch: { label: '해저케이블 감시', color: '#00e5ff', icon: '🔌' },
dokdoWatch: { label: '독도감시', color: '#22c55e', icon: '🏝️' },
ferryWatch: { label: '여객선감시', color: '#2196f3', icon: '🚢' },
};
export function KoreaMap({ ships, aircraft, satellites, militaryOnly, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [layers, setLayers] = useState<KoreaLayers>({
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,
});
useEffect(() => {
setLayers(prev => ({ ...prev, militaryOnly }));
}, [militaryOnly]);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
const toggle = useCallback((key: keyof KoreaLayers) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []);
const milCount = aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length;
return (
<Map
ref={mapRef}
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
>
<NavigationControl position="top-right" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 6,
}}
paint={{
'text-color': '#e2e8f0',
'text-halo-color': '#000000',
'text-halo-width': 2,
'text-opacity': 0.9,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 4,
}}
paint={{
'text-color': '#94a3b8',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': 0.85,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 2,
}}
paint={{
'text-color': '#64748b',
'text-halo-color': '#000000',
'text-halo-width': 1,
'text-opacity': 0.75,
}}
/>
</Source>
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
{/* 환적 의심 라벨 */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: 'rgba(249,115,22,0.9)', color: '#fff',
padding: '1px 5px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: '1px solid #f97316',
textShadow: '0 0 2px #000',
whiteSpace: 'nowrap',
animation: 'pulse 2s ease-in-out infinite',
}}>
</div>
</Marker>
))}
{/* 해저케이블 의심 라벨 */}
{cableWatchSuspects.size > 0 && ships.filter(s => cableWatchSuspects.has(s.mmsi)).map(s => (
<Marker key={`cw-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: 'rgba(0,229,255,0.9)', color: '#000',
padding: '1px 5px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: '1px solid #00e5ff',
textShadow: '0 0 2px rgba(255,255,255,0.5)',
whiteSpace: 'nowrap',
animation: 'pulse 2s ease-in-out infinite',
}}>
🔌
</div>
</Marker>
))}
{/* 독도감시 라벨 (일본 선박) */}
{dokdoWatchSuspects.size > 0 && ships.filter(s => dokdoWatchSuspects.has(s.mmsi)).map(s => {
const dist = Math.round(Math.hypot(
(s.lng - 131.8647) * Math.cos((37.2417 * Math.PI) / 180),
s.lat - 37.2417,
) * 111);
const inTerritorial = dist < 22;
return (
<Marker key={`dk-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
<div style={{
background: inTerritorial ? 'rgba(239,68,68,0.95)' : 'rgba(234,179,8,0.9)',
color: '#fff',
padding: '2px 6px', borderRadius: 3,
fontSize: 9, fontWeight: 700, fontFamily: 'monospace',
border: `1px solid ${inTerritorial ? '#ef4444' : '#eab308'}`,
textShadow: '0 0 2px #000',
whiteSpace: 'nowrap',
animation: 'pulse 1.5s ease-in-out infinite',
}}>
{inTerritorial ? '🚨' : '⚠'} 🇯🇵 {inTerritorial ? '침범' : '접근'} {dist}km
</div>
</Marker>
);
})}
{layers.infra && infra.length > 0 && <InfraLayer facilities={infra} />}
{layers.satellites && satellites.length > 0 && <SatelliteLayer satellites={satellites} />}
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
{layers.eez && <EezLayer />}
{layers.piracy && <PiracyLayer />}
{/* Filter Status Banner */}
{(() => {
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
if (active.length === 0) return null;
return (
<div style={{
position: 'absolute', top: 10, left: '50%', transform: 'translateX(-50%)', zIndex: 20,
display: 'flex', gap: 6, backdropFilter: 'blur(8px)',
}}>
{active.map(k => {
const f = FILTER_LABELS[k];
return (
<div key={k} style={{
background: `${f.color}22`, border: `1px solid ${f.color}88`,
borderRadius: 8, padding: '6px 12px',
fontFamily: 'monospace', fontSize: 11, color: f.color,
fontWeight: 700, display: 'flex', alignItems: 'center', gap: 6,
animation: 'pulse 2s ease-in-out infinite',
}}>
<span style={{ fontSize: 13 }}>{f.icon}</span>
{f.label}
</div>
);
})}
<div style={{
background: 'rgba(10,10,26,0.85)', border: '1px solid #555',
borderRadius: 8, padding: '6px 12px',
fontFamily: 'monospace', fontSize: 12, color: '#fff',
fontWeight: 700, display: 'flex', alignItems: 'center',
}}>
{ships.length}
</div>
</div>
);
})()}
{/* Layer Panel */}
<div style={{
position: 'absolute', top: 10, left: 10, zIndex: 10,
background: 'rgba(10,10,26,0.92)', borderRadius: 8,
border: '1px solid #333', padding: '10px 12px',
fontFamily: 'monospace', fontSize: 11, minWidth: 160,
backdropFilter: 'blur(8px)',
}}>
<div style={{ fontWeight: 700, fontSize: 10, color: '#888', marginBottom: 6, letterSpacing: 1 }}>LAYERS</div>
<LayerBtn label={`선박 (${ships.length})`} color="#fb923c" active={layers.ships} onClick={() => toggle('ships')} />
<LayerBtn label={`항공기 (${aircraft.length})`} color="#22d3ee" active={layers.aircraft} onClick={() => toggle('aircraft')} />
<LayerBtn label={`위성 (${satellites.length})`} color="#ef4444" active={layers.satellites} onClick={() => toggle('satellites')} />
<LayerBtn label={`발전/변전 (${infra.length})`} color="#ffc107" active={layers.infra} onClick={() => toggle('infra')} />
<LayerBtn label="해저케이블" color="#00e5ff" active={layers.cables} onClick={() => toggle('cables')} />
<LayerBtn label="CCTV (15)" color="#ff6b6b" active={layers.cctv} onClick={() => toggle('cctv')} />
<LayerBtn label="공항 (16)" color="#a78bfa" active={layers.airports} onClick={() => toggle('airports')} />
<LayerBtn label="해경 (46)" color="#4dabf7" active={layers.coastGuard} onClick={() => toggle('coastGuard')} />
<LayerBtn label="항행경보" color="#eab308" active={layers.navWarning} onClick={() => toggle('navWarning')} />
<LayerBtn label="OSINT 사고" color="#ef4444" active={layers.osint} onClick={() => toggle('osint')} />
<LayerBtn label="EEZ / NLL" color="#3b82f6" active={layers.eez} onClick={() => toggle('eez')} />
<LayerBtn label="해적 위험해역" color="#ef4444" active={layers.piracy} onClick={() => toggle('piracy')} />
<div style={{ borderTop: '1px solid #333', margin: '6px 0' }} />
<LayerBtn label={`군사만 (${milCount})`} color="#f97316" active={layers.militaryOnly} onClick={() => toggle('militaryOnly')} />
{layers.aircraft && aircraft.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 9, color: '#666', fontWeight: 700, marginBottom: 3 }}>AIRCRAFT</div>
{(['fighter', 'military', 'surveillance', 'tanker', 'cargo', 'civilian'] as const).map(cat => {
const count = aircraft.filter(a => a.category === cat).length;
if (count === 0) return null;
const colors: Record<string, string> = {
fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00',
tanker: '#00ccff', cargo: '#a78bfa', civilian: '#FFD700',
};
return (
<div key={cat} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: colors[cat] || '#888' }}>
<span>{cat.toUpperCase()}</span>
<span style={{ color: '#aaa' }}>{count}</span>
</div>
);
})}
</div>
)}
</div>
{/* 독도감시 알림 패널 */}
{dokdoAlerts.length > 0 && koreaFilters.dokdoWatch && (
<div style={{
position: 'absolute', top: 10, right: 50, zIndex: 20,
background: 'rgba(10,10,26,0.95)', borderRadius: 8,
border: '1px solid #ef4444', padding: '8px 10px',
fontFamily: 'monospace', fontSize: 11, minWidth: 220,
maxHeight: 200, overflowY: 'auto',
backdropFilter: 'blur(8px)',
boxShadow: '0 0 20px rgba(239,68,68,0.3)',
}}>
<div style={{ fontWeight: 700, fontSize: 10, color: '#ef4444', marginBottom: 6, letterSpacing: 1, display: 'flex', alignItems: 'center', gap: 4 }}>
🚨
</div>
{dokdoAlerts.map((a, i) => (
<div key={`${a.mmsi}-${i}`} style={{
padding: '4px 0', borderBottom: i < dokdoAlerts.length - 1 ? '1px solid #222' : 'none',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: a.dist < 22 ? '#ef4444' : '#eab308', fontWeight: 700, fontSize: 10 }}>
{a.dist < 22 ? '🚨 영해침범' : '⚠ 접근경고'}
</span>
<span style={{ color: '#555', fontSize: 9 }}>
{new Date(a.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<div style={{ color: '#ccc', fontSize: 10 }}>
🇯🇵 {a.name} {a.dist}km
</div>
</div>
))}
</div>
)}
</Map>
);
}
function LayerBtn({ label, color, active, onClick }: {
label: string; color: string; active: boolean; onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', padding: '3px 0', border: 'none',
background: 'transparent', cursor: 'pointer', textAlign: 'left',
fontSize: 11, fontFamily: 'monospace',
color: active ? '#ddd' : '#555',
opacity: active ? 1 : 0.5,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: active ? color : '#444', flexShrink: 0,
}} />
{label}
</button>
);
}

파일 보기

@ -0,0 +1,241 @@
import type { LayerVisibility } from '../types';
// Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = {
fighter: '#ff4444',
military: '#ff6600',
surveillance: '#ffcc00',
tanker: '#00ccff',
cargo: '#a78bfa',
civilian: '#FFD700', // mid-altitude yellow (representative)
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 type color legend (MarineTraffic style)
const SHIP_TYPE_LEGEND: [string, string][] = [
['Cargo', '#f0a830'],
['Tanker', '#e74c3c'],
['Passenger', '#4caf50'],
['Fishing', '#42a5f5'],
['Yacht', '#e91e8c'],
['Military', '#d32f2f'],
['Tug/Special', '#2e7d32'],
['Other', '#5c6bc0'],
['Unknown', '#9e9e9e'],
];
interface Props {
layers: LayerVisibility;
onToggle: (key: keyof LayerVisibility) => void;
aircraftCount: number;
militaryCount: number;
satelliteCount: number;
shipCount: number;
koreanShipCount: number;
aircraftByCategory: Record<string, number>;
shipsByCategory: Record<string, number>;
}
export function LayerPanel({
layers,
onToggle,
aircraftCount,
militaryCount,
satelliteCount,
shipCount,
koreanShipCount,
aircraftByCategory,
shipsByCategory,
}: Props) {
return (
<div className="layer-panel">
<h3>LAYERS</h3>
<div className="layer-items">
<LayerToggle
label="Events"
color="#a855f7"
active={layers.events}
onClick={() => onToggle('events')}
/>
<LayerToggle
label={`Aircraft (${aircraftCount})`}
color="#22d3ee"
active={layers.aircraft}
onClick={() => onToggle('aircraft')}
/>
<LayerToggle
label={`Satellites (${satelliteCount})`}
color="#ef4444"
active={layers.satellites}
onClick={() => onToggle('satellites')}
/>
<LayerToggle
label={`Ships (${shipCount})`}
color="#fb923c"
active={layers.ships}
onClick={() => onToggle('ships')}
/>
<LayerToggle
label={`\u{1F1F0}\u{1F1F7} 한국 선박 (${koreanShipCount})`}
color="#00e5ff"
active={layers.koreanShips}
onClick={() => onToggle('koreanShips')}
indent
/>
<LayerToggle
label="Airports"
color="#f59e0b"
active={layers.airports}
onClick={() => onToggle('airports')}
/>
<LayerToggle
label="Oil/Gas Facilities"
color="#d97706"
active={layers.oilFacilities}
onClick={() => onToggle('oilFacilities')}
/>
<LayerToggle
label="Sensor Charts"
color="#22c55e"
active={layers.sensorCharts}
onClick={() => onToggle('sensorCharts')}
/>
<div className="layer-divider" />
<LayerToggle
label={`Military Only (${militaryCount})`}
color="#f97316"
active={layers.militaryOnly}
onClick={() => onToggle('militaryOnly')}
/>
</div>
{layers.aircraft && (
<div className="layer-stats">
<div className="stat-header">AIRCRAFT</div>
{Object.entries(aircraftByCategory)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<div key={cat} className="stat-row">
<span className="stat-cat" style={{ color: AC_CAT_COLORS[cat] || '#888' }}>
{cat.toUpperCase()}
</span>
<span className="stat-count">{count}</span>
</div>
))}
{/* Altitude color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>ALTITUDE</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{ALT_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: color, display: 'inline-block', flexShrink: 0,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
{/* Military color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>MILITARY</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{MIL_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: color, display: 'inline-block', flexShrink: 0,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
</div>
)}
{layers.ships && (
<div className="layer-stats">
<div className="stat-header">SHIPS</div>
{Object.entries(shipsByCategory)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<div key={`ship-${cat}`} className="stat-row">
<span className="stat-cat">{cat.toUpperCase()}</span>
<span className="stat-count">{count}</span>
</div>
))}
{/* Ship type color legend */}
<div className="stat-header" style={{ marginTop: 6 }}>VESSEL TYPE</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, fontSize: 9, opacity: 0.85 }}>
{SHIP_TYPE_LEGEND.map(([label, color]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 0, height: 0, flexShrink: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: `10px solid ${color}`,
}} />
<span style={{ color: '#aaa' }}>{label}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function LayerToggle({
label,
color,
active,
onClick,
indent,
}: {
label: string;
color: string;
active: boolean;
onClick: () => void;
indent?: boolean;
}) {
return (
<button
className={`layer-toggle ${active ? 'active' : ''}`}
onClick={onClick}
style={indent ? { paddingLeft: 18, fontSize: 10 } : undefined}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
</button>
);
}

파일 보기

@ -0,0 +1,55 @@
import { format } from 'date-fns';
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 kstTime = format(new Date(currentTime + 9 * 3600_000), "yyyy-MM-dd HH:mm:ss 'KST'");
return (
<div className="live-controls">
<div className="live-indicator">
<span className="live-dot" />
<span className="live-label">LIVE</span>
</div>
<div className="live-clock">{kstTime}</div>
<div style={{ flex: 1 }} />
<div className="history-controls">
<span className="history-label">HISTORY</span>
<div className="history-presets">
{HISTORY_PRESETS.map(p => (
<button
key={p.label}
className={`history-btn ${historyMinutes === p.minutes ? 'active' : ''}`}
onClick={() => onHistoryChange(p.minutes)}
>
{p.label}
</button>
))}
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,123 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { NAV_WARNINGS, NW_LEVEL_LABEL, NW_ORG_LABEL } from '../services/navWarning';
import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWarning';
const LEVEL_COLOR: Record<NavWarningLevel, string> = {
danger: '#ef4444',
caution: '#eab308',
info: '#3b82f6',
};
const ORG_COLOR: Record<TrainingOrg, string> = {
'해군': '#8b5cf6',
'해병대': '#22c55e',
'공군': '#f97316',
'육군': '#ef4444',
'해경': '#3b82f6',
'국과연': '#eab308',
};
function WarningIcon({ level, org, size }: { level: NavWarningLevel; org: TrainingOrg; size: number }) {
const color = ORG_COLOR[org];
if (level === 'danger') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 3 L22 20 L2 20 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.5" />
<line x1="12" y1="9" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
<circle cx="12" cy="17" r="1" fill={color} />
</svg>
);
}
// caution (해경 등)
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path d="M12 2 L22 12 L12 22 L2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
<line x1="12" y1="8" x2="12" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<circle cx="12" cy="16" r="1" fill={color} />
</svg>
);
}
export function NavWarningLayer() {
const [selected, setSelected] = useState<NavWarning | null>(null);
return (
<>
{NAV_WARNINGS.map(w => {
const color = ORG_COLOR[w.org];
const size = w.level === 'danger' ? 16 : 14;
return (
<Marker key={w.id} longitude={w.lng} latitude={w.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(w); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 4px ${color}88)`,
}}>
<WarningIcon level={w.level} org={w.org} size={size} />
<div style={{
fontSize: 5, color, marginTop: 0,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
}}>
{w.id}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
<div style={{
background: ORG_COLOR[selected.org], color: '#fff',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
{selected.title}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{NW_LEVEL_LABEL[selected.level]}</span>
<span style={{
background: ORG_COLOR[selected.org] + '33', color: ORG_COLOR[selected.org],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
border: `1px solid ${ORG_COLOR[selected.org]}44`,
}}>{NW_ORG_LABEL[selected.org]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.area}</span>
</div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
{selected.description}
</div>
<div style={{ fontSize: 9, color: '#666', display: 'flex', flexDirection: 'column', gap: 2 }}>
<div>: {selected.altitude}</div>
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
<div>: {selected.source}</div>
</div>
<a
href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block', marginTop: 6,
fontSize: 10, color: '#3b82f6', textDecoration: 'underline',
}}
>KHOA </a>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,363 @@
import { memo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { OilFacility, OilFacilityType } from '../types';
interface Props {
facilities: OilFacility[];
currentTime: number;
}
const TYPE_COLORS: Record<OilFacilityType, string> = {
refinery: '#f59e0b', oilfield: '#10b981', gasfield: '#6366f1',
terminal: '#ec4899', petrochemical: '#8b5cf6', desalination: '#06b6d4',
};
const TYPE_LABELS: Record<OilFacilityType, string> = {
refinery: '정유소', oilfield: '유전', gasfield: '가스전',
terminal: '수출터미널', petrochemical: '석유화학', desalination: '담수화시설',
};
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
function getTooltipLabel(f: OilFacility): string {
if (f.capacityMgd) return `${formatNumber(f.capacityMgd)} MGD`;
if (f.capacityBpd) return `${formatNumber(f.capacityBpd)} bpd`;
if (f.reservesBbl) return `${f.reservesBbl}B bbl`;
if (f.reservesTcf) return `${f.reservesTcf} Tcf`;
if (f.capacityMcfd) return `${formatNumber(f.capacityMcfd)} Mcf/d`;
return '';
}
function getIconSize(f: OilFacility): number {
if (f.type === 'desalination') { const m = f.capacityMgd ?? 0; return m >= 200 ? 14 : m >= 80 ? 12 : 10; }
if (f.type === 'terminal') return (f.capacityBpd ?? 0) >= 1_000_000 ? 20 : 16;
if (f.type === 'oilfield') { const b = f.reservesBbl ?? 0; return b >= 20 ? 20 : b >= 10 ? 18 : 14; }
if (f.type === 'gasfield') { const t = f.reservesTcf ?? 0; return t >= 100 ? 20 : t >= 50 ? 18 : 14; }
if (f.type === 'refinery') { const b = f.capacityBpd ?? 0; return b >= 300_000 ? 20 : b >= 100_000 ? 18 : 14; }
return 16;
}
// Shared damage overlay (X mark + circle)
function DamageOverlay() {
return (
<>
<line x1={4} y1={4} x2={32} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<line x1={32} y1={4} x2={4} y2={32} stroke="#ff0000" strokeWidth={2.5} opacity={0.8} />
<circle cx={18} cy={18} r={15} fill="none" stroke="#ff0000" strokeWidth={1.5} opacity={0.4} />
</>
);
}
// SVG icon renderers (JSX versions)
function RefineryIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Factory/refinery silhouette on gradient circle background (no white)
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Gradient circle background */}
<defs>
<linearGradient id={`refGrad-${damaged ? 'd' : 'n'}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.5} />
<stop offset="100%" stopColor={color} stopOpacity={0.2} />
</linearGradient>
</defs>
<circle cx={18} cy={18} r={17} fill={`url(#refGrad-${damaged ? 'd' : 'n'})`}
stroke={sc} strokeWidth={damaged ? 2 : 1} opacity={0.9} />
{/* Factory building base */}
<rect x={6} y={19} width={24} height={11} rx={1} fill={color} opacity={0.6} />
{/* Tall chimney/tower (center) */}
<rect x={16} y={7} width={4} height={13} fill={color} opacity={0.7} />
{/* Short tower (left) */}
<rect x={9} y={12} width={4} height={8} fill={color} opacity={0.65} />
{/* Medium tower (right) */}
<rect x={23} y={10} width={4} height={10} fill={color} opacity={0.65} />
{/* Smoke/emission from chimneys */}
<circle cx={11} cy={10} r={1.5} fill={color} opacity={0.3} />
<circle cx={18} cy={5} r={2} fill={color} opacity={0.3} />
<circle cx={25} cy={8} r={1.5} fill={color} opacity={0.3} />
{/* Windows on building */}
<rect x={10} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={16} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
<rect x={23} y={22} width={3} height={3} rx={0.5} fill="rgba(0,0,0,0.4)" />
{/* Pipe details */}
<line x1={13} y1={15} x2={16} y2={15} stroke={color} strokeWidth={0.8} opacity={0.5} />
<line x1={20} y1={13} x2={23} y2={13} stroke={color} strokeWidth={0.8} opacity={0.5} />
{damaged && <DamageOverlay />}
</svg>
);
}
function OilFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Oil pumpjack (nodding donkey) icon — transparent style
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Base platform */}
<rect x={4} y={31} width={28} height={2.5} rx={1} fill={color} opacity={0.7} />
{/* Support A-frame (tripod legs) */}
<line x1={18} y1={14} x2={12} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
<line x1={18} y1={14} x2={24} y2={31} stroke={sc} strokeWidth={1.5} opacity={0.85} />
{/* Cross brace on A-frame */}
<line x1={14} y1={25} x2={22} y2={25} stroke={color} strokeWidth={1} opacity={0.7} />
{/* Walking beam (horizontal arm) */}
<line x1={4} y1={12} x2={28} y2={10} stroke={sc} strokeWidth={2} opacity={0.9} />
{/* Pivot point on top of A-frame */}
<circle cx={18} cy={13} r={2} fill={color} opacity={0.8} stroke={sc} strokeWidth={1} />
{/* Horse head (front end, left side) */}
<path d="M4 12 L4 17 L7 17 L7 12" fill="none" stroke={sc} strokeWidth={1.5} opacity={0.85} />
{/* Polished rod (well string going down) */}
<line x1={5.5} y1={17} x2={5.5} y2={31} stroke={color} strokeWidth={1.2} opacity={0.75} />
{/* Counterweight (back end, right side) */}
<rect x={25} y={10} width={5} height={6} rx={1} fill={color} opacity={0.6} stroke={sc} strokeWidth={1} />
{/* Crank arm + pitman arm */}
<line x1={27.5} y1={16} x2={27.5} y2={24} stroke={color} strokeWidth={1.2} opacity={0.75} />
{/* Motor/gear box */}
<rect x={24} y={24} width={7} height={5} rx={1} fill={color} opacity={0.55} stroke={sc} strokeWidth={1} />
{/* Wellhead at bottom */}
<rect x={3} y={28} width={5} height={3} rx={0.5} fill={color} opacity={0.65} stroke={sc} strokeWidth={0.8} />
{/* Oil drop symbol on wellhead */}
<path d="M5.5 29 C5.5 29 4.5 30 4.5 30.5 C4.5 31 5 31.2 5.5 31.2 C6 31.2 6.5 31 6.5 30.5 C6.5 30 5.5 29 5.5 29Z"
fill={color} opacity={0.85} />
{damaged && <DamageOverlay />}
</svg>
);
}
function GasFieldIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Spherical gas storage tank with support legs (transparent style)
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Support legs */}
<line x1={10} y1={24} x2={8} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={14} y1={25} x2={13} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={22} y1={25} x2={23} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
<line x1={26} y1={24} x2={28} y2={33} stroke={color} strokeWidth={1.5} opacity={0.7} />
{/* Cross braces */}
<line x1={9} y1={29} x2={14} y2={27} stroke={color} strokeWidth={0.8} opacity={0.55} />
<line x1={22} y1={27} x2={27} y2={29} stroke={color} strokeWidth={0.8} opacity={0.55} />
{/* Base platform */}
<line x1={7} y1={33} x2={29} y2={33} stroke={color} strokeWidth={1.2} opacity={0.6} />
{/* Spherical tank body */}
<ellipse cx={18} cy={16} rx={12} ry={10} fill={color} opacity={0.45}
stroke={damaged ? '#ff0000' : color} strokeWidth={1.5} />
{/* Highlight arc (top reflection) */}
<ellipse cx={16} cy={12} rx={7} ry={5} fill={color} opacity={0.3} />
{/* Equator band */}
<ellipse cx={18} cy={16} rx={12} ry={2.5} fill="none" stroke={color} strokeWidth={0.8} opacity={0.55} />
{/* Top valve/pipe */}
<rect x={16.5} y={4} width={3} height={3} rx={0.5} fill={color} opacity={0.7} />
<line x1={18} y1={4} x2={18} y2={6} stroke={color} strokeWidth={1.5} opacity={0.7} />
{damaged && <DamageOverlay />}
</svg>
);
}
function TerminalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<circle cx={18} cy={10} r={4} fill="none" stroke={damaged ? '#ff0000' : color} strokeWidth={2} />
<line x1={18} y1={14} x2={18} y2={28} stroke={color} strokeWidth={2} />
<path d="M10 24 C10 28 14 32 18 32 C22 32 26 28 26 24" fill="none" stroke={color} strokeWidth={2} />
<line x1={18} y1={8} x2={18} y2={6} stroke={color} strokeWidth={2} />
<line x1={16} y1={6} x2={20} y2={6} stroke={color} strokeWidth={2.5} />
<path d="M6 24 L10 24" stroke={color} strokeWidth={1.5} />
<path d="M26 24 L30 24" stroke={color} strokeWidth={1.5} />
<polygon points="5,24 8,22 8,26" fill={color} opacity={0.7} />
<polygon points="31,24 28,22 28,26" fill={color} opacity={0.7} />
{damaged && <DamageOverlay />}
</svg>
);
}
function PetrochemIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
<path d="M14 6 L14 16 L8 30 L28 30 L22 16 L22 6Z" fill={color} opacity={0.7} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={1} />
<rect x={13} y={4} width={10} height={4} rx={1} fill={color} opacity={0.9} stroke={damaged ? '#ff0000' : '#fff'} strokeWidth={0.8} />
<path d="M11 22 L25 22 L28 30 L8 30Z" fill={color} opacity={0.5} />
<circle cx={16} cy={25} r={1.5} fill="#c4b5fd" opacity={0.7} />
<circle cx={20} cy={23} r={1} fill="#c4b5fd" opacity={0.6} />
<circle cx={18} cy={27} r={1.2} fill="#c4b5fd" opacity={0.5} />
{damaged && <DamageOverlay />}
</svg>
);
}
function DesalIcon({ size, color, damaged }: { size: number; color: string; damaged: boolean }) {
// Water drop + faucet + filter container — desalination plant (transparent)
const sc = damaged ? '#ff0000' : color;
return (
<svg viewBox="0 0 36 36" width={size} height={size}>
{/* Large water drop (left side) */}
<path d="M14 3 C14 3 6 14 6 19 C6 23.5 9.5 27 14 27 C18.5 27 22 23.5 22 19 C22 14 14 3 14 3Z"
fill={color} opacity={0.4} stroke={sc} strokeWidth={1.2} />
{/* Inner drop ripple */}
<path d="M14 10 C14 10 10 16 10 19 C10 21.5 11.8 23.5 14 23.5 C16.2 23.5 18 21.5 18 19 C18 16 14 10 14 10Z"
fill={color} opacity={0.3} />
{/* Faucet/tap (top right) */}
<rect x={24} y={5} width={6} height={3} rx={1} fill={color} opacity={0.5} stroke={sc} strokeWidth={0.8} />
<line x1={27} y1={8} x2={27} y2={12} stroke={sc} strokeWidth={1.5} opacity={0.7} />
<path d="M24 8 L24 10 Q24 12 26 12 L28 12 Q30 12 30 10 L30 8"
fill="none" stroke={sc} strokeWidth={0.8} opacity={0.6} />
{/* Water drops from faucet */}
<circle cx={27} cy={14.5} r={1} fill={color} opacity={0.55} />
<circle cx={27} cy={17} r={0.7} fill={color} opacity={0.45} />
{/* Filter/treatment container (bottom right) */}
<rect x={23} y={20} width={9} height={12} rx={1.5} fill={color} opacity={0.4}
stroke={sc} strokeWidth={1} />
{/* Filter layers inside container */}
<line x1={24} y1={24} x2={31} y2={24} stroke={color} strokeWidth={0.6} opacity={0.5} />
<line x1={24} y1={27} x2={31} y2={27} stroke={color} strokeWidth={0.6} opacity={0.5} />
{/* Pipe connecting drop to container */}
<path d="M18 22 L23 22" stroke={sc} strokeWidth={1} opacity={0.6} />
{/* Output pipe from container */}
<line x1={27.5} y1={32} x2={27.5} y2={34} stroke={color} strokeWidth={1} opacity={0.55} />
{/* Base */}
<line x1={4} y1={34} x2={33} y2={34} stroke={color} strokeWidth={1} opacity={0.25} />
{damaged && <DamageOverlay />}
</svg>
);
}
function FacilityIconSvg({ facility, damaged }: { facility: OilFacility; damaged: boolean }) {
const color = TYPE_COLORS[facility.type];
const size = getIconSize(facility);
switch (facility.type) {
case 'refinery': return <RefineryIcon size={size} color={color} damaged={damaged} />;
case 'oilfield': return <OilFieldIcon size={size} color={color} damaged={damaged} />;
case 'gasfield': return <GasFieldIcon size={size} color={color} damaged={damaged} />;
case 'terminal': return <TerminalIcon size={size} color={color} damaged={damaged} />;
case 'petrochemical': return <PetrochemIcon size={size} color={color} damaged={damaged} />;
case 'desalination': return <DesalIcon size={size} color={color} damaged={damaged} />;
}
}
export const OilFacilityLayer = memo(function OilFacilityLayer({ facilities, currentTime }: Props) {
return (
<>
{facilities.map(f => (
<FacilityMarker key={f.id} facility={f} currentTime={currentTime} />
))}
</>
);
});
function FacilityMarker({ facility, currentTime }: { facility: OilFacility; currentTime: number }) {
const [showPopup, setShowPopup] = useState(false);
const color = TYPE_COLORS[facility.type];
const isDamaged = !!(facility.damaged && facility.damagedAt && currentTime >= facility.damagedAt);
const isPlanned = !!facility.planned && !isDamaged;
const stat = getTooltipLabel(facility);
return (
<>
<Marker longitude={facility.lng} latitude={facility.lat} anchor="center">
<div style={{ position: 'relative' }}>
{/* Planned strike targeting ring */}
{isPlanned && (
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 36, height: 36, borderRadius: '50%',
border: '2px dashed #ff6600',
animation: 'planned-pulse 2s ease-in-out infinite',
pointerEvents: 'none',
}}>
{/* Crosshair lines */}
<div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', bottom: -6, left: '50%', transform: 'translateX(-50%)', width: 1, height: 6, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', left: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
<div style={{ position: 'absolute', right: -6, top: '50%', transform: 'translateY(-50%)', width: 6, height: 1, background: '#ff6600', opacity: 0.7 }} />
</div>
)}
<div style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
<FacilityIconSvg facility={facility} damaged={isDamaged} />
</div>
<div className="gl-marker-label" style={{
color: isDamaged ? '#ff0000' : isPlanned ? '#ff6600' : color, fontSize: 8,
}}>
{isDamaged ? '\u{1F4A5} ' : isPlanned ? '\u{1F3AF} ' : ''}{facility.nameKo}
{stat && <span style={{ color: '#aaa', fontSize: 7, marginLeft: 3 }}>{stat}</span>}
</div>
</div>
</Marker>
{showPopup && (
<Popup longitude={facility.lng} latitude={facility.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginBottom: 6 }}>
<span style={{
background: color, color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{TYPE_LABELS[facility.type]}</span>
{isDamaged && (
<span style={{
background: '#ff0000', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}></span>
)}
{isPlanned && (
<span style={{
background: '#ff6600', color: '#fff', padding: '2px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}> </span>
)}
</div>
<div style={{ fontWeight: 700, fontSize: 13, margin: '4px 0' }}>{facility.nameKo}</div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 6 }}>{facility.name}</div>
<div style={{
background: 'rgba(0,0,0,0.3)', borderRadius: 4, padding: '6px 8px',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11,
}}>
{facility.capacityBpd != null && (
<><span style={{ color: '#888' }}>/</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityBpd)} bpd</span></>
)}
{facility.capacityMgd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMgd)} MGD</span></>
)}
{facility.capacityMcfd != null && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff', fontWeight: 600 }}>{formatNumber(facility.capacityMcfd)} Mcf/d</span></>
)}
{facility.reservesBbl != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesBbl}B </span></>
)}
{facility.reservesTcf != null && (
<><span style={{ color: '#888' }}>()</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{facility.reservesTcf} Tcf</span></>
)}
{facility.operator && (
<><span style={{ color: '#888' }}></span>
<span style={{ color: '#fff' }}>{facility.operator}</span></>
)}
</div>
{facility.description && (
<p style={{ margin: '6px 0 0', fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>{facility.description}</p>
)}
{isPlanned && facility.plannedLabel && (
<div style={{
margin: '6px 0 0', padding: '4px 8px', fontSize: 11,
background: 'rgba(255,102,0,0.15)', border: '1px solid rgba(255,102,0,0.4)',
borderRadius: 4, color: '#ff9933', lineHeight: 1.4,
}}>
{facility.plannedLabel}
</div>
)}
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
{facility.lat.toFixed(4)}°N, {facility.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,131 @@
import { useState, useMemo } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import type { OsintItem } from '../services/osint';
const CAT_COLOR: Record<string, string> = {
maritime_accident: '#ef4444',
fishing: '#22c55e',
maritime_traffic: '#3b82f6',
military: '#f97316',
shipping: '#eab308',
};
const CAT_ICON: Record<string, string> = {
maritime_accident: '🚨',
fishing: '🐟',
maritime_traffic: '🚢',
military: '🎯',
shipping: '🚢',
};
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}분 전`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}시간 전`;
return `${Math.floor(h / 24)}일 전`;
}
interface Props {
osintFeed: OsintItem[];
currentTime: number;
}
const THREE_HOURS = 3 * 60 * 60 * 1000;
const ONE_HOUR = 3600000;
const MAP_CATEGORIES = new Set(['maritime_accident', 'fishing', 'maritime_traffic', 'shipping', 'military']);
export function OsintMapLayer({ osintFeed, currentTime }: Props) {
const [selected, setSelected] = useState<OsintItem | null>(null);
// 좌표가 있고, 해양 관련 카테고리이며, 최근 3시간 이내인 OSINT만 표시
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; // 1시간 이내
return (
<Marker key={item.id} longitude={item.lng} latitude={item.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(item); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 6px ${color}aa)`,
}}>
<div style={{
width: 22, height: 22, borderRadius: '50%',
background: `rgba(0,0,0,0.6)`,
border: `2px solid ${color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12,
animation: isRecent ? 'pulse 2s ease-in-out infinite' : undefined,
}}>
{CAT_ICON[item.category] || '📰'}
</div>
{isRecent && (
<div style={{
fontSize: 5, color, marginTop: 1,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
fontWeight: 700, letterSpacing: 0.3,
}}>
NEW
</div>
)}
</div>
</Marker>
);
})}
{selected && selected.lat != null && selected.lng != null && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 240 }}>
<div style={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 11,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>{CAT_ICON[selected.category] || '📰'}</span>
OSINT
</div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.4 }}>
{selected.title}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: CAT_COLOR[selected.category] || '#888', color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700,
}}>{selected.category.replace('_', ' ').toUpperCase()}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 9,
}}>{selected.source}</span>
<span style={{
background: '#1a1a2e', color: '#666',
padding: '1px 6px', borderRadius: 3, fontSize: 9,
}}>{timeAgo(selected.timestamp)}</span>
</div>
{selected.url && (
<a
href={selected.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }}
> </a>
)}
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,103 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR, PIRACY_LEVEL_LABEL } from '../services/piracy';
import type { PiracyZone } from '../services/piracy';
function SkullIcon({ color, size }: { color: string; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
{/* skull */}
<ellipse cx="12" cy="10" rx="8" ry="9" fill="rgba(0,0,0,0.6)" stroke={color} strokeWidth="1.5" />
{/* eyes */}
<ellipse cx="8.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
<ellipse cx="15.5" cy="9" rx="2" ry="2.2" fill={color} opacity="0.9" />
{/* nose */}
<path d="M11 13 L12 14.5 L13 13" stroke={color} strokeWidth="1" fill="none" />
{/* jaw */}
<path d="M7 17 Q12 21 17 17" stroke={color} strokeWidth="1.2" fill="none" />
{/* crossbones */}
<line x1="4" y1="20" x2="20" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<line x1="20" y1="20" x2="4" y2="24" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function PiracyLayer() {
const [selected, setSelected] = useState<PiracyZone | null>(null);
return (
<>
{PIRACY_ZONES.map(zone => {
const color = PIRACY_LEVEL_COLOR[zone.level];
const size = zone.level === 'critical' ? 28 : zone.level === 'high' ? 24 : 20;
return (
<Marker key={zone.id} longitude={zone.lng} latitude={zone.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(zone); }}>
<div style={{
cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
filter: `drop-shadow(0 0 8px ${color}aa)`,
animation: zone.level === 'critical' ? 'pulse 2s ease-in-out infinite' : undefined,
}}>
<SkullIcon color={color} size={size} />
<div style={{
fontSize: 7, color, marginTop: 1,
textShadow: `0 0 3px ${color}, 0 0 2px #000`,
whiteSpace: 'nowrap', fontWeight: 700, letterSpacing: 0.3,
fontFamily: 'monospace',
}}>
{PIRACY_LEVEL_LABEL[zone.level]}
</div>
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 260 }}>
<div style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '5px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 12,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 14 }}></span>
{selected.nameKo}
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: PIRACY_LEVEL_COLOR[selected.level], color: '#fff',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{PIRACY_LEVEL_LABEL[selected.level]}</span>
<span style={{
background: '#1a1a2e', color: '#888',
padding: '1px 6px', borderRadius: 3, fontSize: 10,
}}>{selected.name}</span>
{selected.recentIncidents != null && (
<span style={{
background: '#1a1a2e', color: PIRACY_LEVEL_COLOR[selected.level],
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
border: `1px solid ${PIRACY_LEVEL_COLOR[selected.level]}44`,
}}> 1 {selected.recentIncidents}</span>
)}
</div>
<div style={{ fontSize: 11, color: '#ccc', marginBottom: 6, lineHeight: 1.5 }}>
{selected.description}
</div>
<div style={{ fontSize: 10, color: '#999', lineHeight: 1.4 }}>
{selected.detail}
</div>
<div style={{ fontSize: 9, color: '#666', marginTop: 6 }}>
{selected.lat.toFixed(2)}°N, {selected.lng.toFixed(2)}°E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,168 @@
import { useState, useCallback } from 'react';
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 [showPicker, setShowPicker] = useState(false);
const [customStart, setCustomStart] = useState(toKSTInput(startTime));
const [customEnd, setCustomEnd] = useState(toKSTInput(endTime));
const handlePreset = useCallback((preset: typeof PRESETS[number]) => {
onRangeChange(preset.start, preset.end);
setCustomStart(toKSTInput(preset.start));
setCustomEnd(toKSTInput(preset.end));
}, [onRangeChange]);
const handleCustomApply = useCallback(() => {
const s = fromKSTInput(customStart);
const e = fromKSTInput(customEnd);
if (s < e) {
onRangeChange(s, e);
setShowPicker(false);
}
}, [customStart, customEnd, onRangeChange]);
// Find which preset is active
const activePreset = PRESETS.find(p => p.start === startTime && p.end === endTime);
return (
<div className="replay-controls">
{/* Left: transport controls */}
<button className="ctrl-btn" onClick={onReset} title="Reset">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
</button>
<button className="ctrl-btn play-btn" onClick={isPlaying ? onPause : onPlay}>
{isPlaying ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21" />
</svg>
)}
</button>
<div className="speed-controls">
{SPEEDS.map(s => (
<button
key={s}
className={`speed-btn ${speed === s ? 'active' : ''}`}
onClick={() => onSpeedChange(s)}
>
{s}x
</button>
))}
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Right: range presets + custom picker */}
<div className="range-controls">
<div className="range-presets">
{PRESETS.map(p => (
<button
key={p.label}
className={`range-btn ${activePreset === p ? 'active' : ''}`}
onClick={() => handlePreset(p)}
>
{p.label}
</button>
))}
<button
className={`range-btn custom-btn ${showPicker ? 'active' : ''}`}
onClick={() => setShowPicker(!showPicker)}
title="Custom range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
</div>
{showPicker && (
<div className="range-picker">
<div className="range-picker-row">
<label>
<span>FROM (KST)</span>
<input
type="datetime-local"
value={customStart}
onChange={e => setCustomStart(e.target.value)}
/>
</label>
<label>
<span>TO (KST)</span>
<input
type="datetime-local"
value={customEnd}
onChange={e => setCustomEnd(e.target.value)}
/>
</label>
<button className="range-apply-btn" onClick={handleCustomApply}>
APPLY
</button>
</div>
</div>
)}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,438 @@
import { useEffect, useMemo, useState, useRef } from 'react';
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: '&copy; Esri, Maxar',
},
'carto-dark': {
type: 'raster' as const,
tiles: [
'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
],
tileSize: 256,
},
'opensea': {
type: 'raster' as const,
tiles: [
'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
],
tileSize: 256,
maxzoom: 18,
},
},
layers: [
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#0b1526' } },
{ id: 'satellite-base', type: 'raster' as const, source: 'satellite', paint: { 'raster-brightness-max': 0.45, 'raster-saturation': -0.3, 'raster-contrast': 0.1 } },
{ id: 'dark-overlay', type: 'raster' as const, source: 'carto-dark', paint: { 'raster-opacity': 0.55 } },
{ id: 'seamark', type: 'raster' as const, source: 'opensea', paint: { 'raster-opacity': 0.6 } },
],
};
const EVENT_COLORS: Record<GeoEvent['type'], string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
const SOURCE_COLORS: Record<string, string> = {
US: '#ef4444',
IL: '#22c55e',
IR: '#ff0000',
proxy: '#f59e0b',
};
function getEventColor(event: GeoEvent): string {
if (event.type === 'impact') return '#ff0000';
if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source];
return EVENT_COLORS[event.type];
}
const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
airstrike: 12,
explosion: 10,
missile_launch: 8,
intercept: 7,
alert: 6,
impact: 14,
osint: 8,
};
export function ReplayMap({ events, currentTime, aircraft, satellites, ships, layers, flyToTarget, onFlyToDone, initialCenter, initialZoom }: Props) {
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
useEffect(() => {
if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({
center: [flyToTarget.lng, flyToTarget.lat],
zoom: flyToTarget.zoom ?? 8,
duration: 1200,
});
onFlyToDone?.();
}
}, [flyToTarget, onFlyToDone]);
const visibleEvents = useMemo(
() => events.filter(e => e.timestamp <= currentTime),
[events, currentTime],
);
const impactEvents = useMemo(
() => visibleEvents.filter(e => e.type === 'impact'),
[visibleEvents],
);
const otherEvents = useMemo(
() => visibleEvents.filter(e => e.type !== 'impact'),
[visibleEvents],
);
const newEvents = useMemo(
() => visibleEvents.filter(e => {
const age = currentTime - e.timestamp;
return age >= 0 && age < 600_000;
}),
[visibleEvents, currentTime],
);
const justActivated = useMemo(
() => visibleEvents.filter(e => {
const age = currentTime - e.timestamp;
return age >= 0 && age < 120_000 &&
(e.type === 'airstrike' || e.type === 'impact' || e.type === 'explosion');
}),
[visibleEvents, currentTime],
);
const trajectoryData = useMemo(() => {
const launches = visibleEvents.filter(e => e.type === 'missile_launch');
const targets = visibleEvents.filter(e => e.type === 'impact' || e.type === 'airstrike' || e.type === 'explosion');
if (launches.length === 0 || targets.length === 0) {
return { type: 'FeatureCollection' as const, features: [] as GeoJSON.Feature[] };
}
return {
type: 'FeatureCollection' as const,
features: launches.map(launch => ({
type: 'Feature' as const,
properties: {},
geometry: {
type: 'LineString' as const,
coordinates: [[launch.lng, launch.lat], [targets[0].lng, targets[0].lat]],
},
})),
};
}, [visibleEvents]);
const selectedEvent = selectedEventId
? visibleEvents.find(e => e.id === selectedEventId) ?? null
: null;
return (
<Map
ref={mapRef}
initialViewState={{ longitude: initialCenter?.lng ?? 44, latitude: initialCenter?.lat ?? 31.5, zoom: initialZoom ?? 5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
>
<NavigationControl position="top-right" />
{/* 한글 국가명 라벨 */}
<Source id="country-labels" type="geojson" data={countryLabelsGeoJSON()}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-size': 15,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 6,
}}
paint={{
'text-color': '#e2e8f0',
'text-halo-color': '#000000',
'text-halo-width': 2,
'text-opacity': 0.9,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 4,
}}
paint={{
'text-color': '#94a3b8',
'text-halo-color': '#000000',
'text-halo-width': 1.5,
'text-opacity': 0.85,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={5}
layout={{
'text-field': ['get', 'name'],
'text-size': 10,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-allow-overlap': false,
'text-ignore-placement': false,
'text-padding': 2,
}}
paint={{
'text-color': '#64748b',
'text-halo-color': '#000000',
'text-halo-width': 1,
'text-opacity': 0.75,
}}
/>
</Source>
{layers.events && (
<>
{trajectoryData.features.length > 0 && (
<Source id="trajectories" type="geojson" data={trajectoryData}>
<Layer
id="trajectory-lines"
type="line"
paint={{
'line-color': '#eab308',
'line-width': 1.5,
'line-opacity': 0.4,
'line-dasharray': [8, 4],
}}
/>
</Source>
)}
{newEvents.map(event => {
const color = getEventColor(event);
const size = EVENT_RADIUS[event.type] * 5;
return (
<Marker key={`pulse-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-pulse-ring" style={{
width: size, height: size, borderRadius: '50%',
border: `2px solid ${color}`, pointerEvents: 'none',
}} />
</Marker>
);
})}
{justActivated.map(event => {
const color = getEventColor(event);
const size = event.type === 'impact' ? 100 : 70;
return (
<Marker key={`shock-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-shockwave" style={{
width: size, height: size, borderRadius: '50%',
border: `3px solid ${color}`, pointerEvents: 'none',
}} />
</Marker>
);
})}
{justActivated.map(event => {
const color = getEventColor(event);
const size = event.type === 'impact' ? 40 : 30;
return (
<Marker key={`flash-${event.id}`} longitude={event.lng} latitude={event.lat} anchor="center">
<div className="gl-strike-flash" style={{
width: size, height: size, borderRadius: '50%',
background: color, opacity: 0.6, pointerEvents: 'none',
}} />
</Marker>
);
})}
{otherEvents.map(event => {
const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000;
const DAY_H = 24;
// 최근 1일 이내: 진하게 (opacity 0.85~1.0), 그 이후: 흐리게 (0.15~0.4)
const isRecent = ageHours <= DAY_H;
const opacity = isRecent
? Math.max(0.85, 1 - ageHours * 0.006) // 1일 내: 1.0→0.85
: Math.max(0.15, 0.4 - (ageHours - DAY_H) * 0.005); // 1일 후: 0.4→0.15
const color = getEventColor(event);
const isNew = ageMs >= 0 && ageMs < 600_000;
const baseR = EVENT_RADIUS[event.type];
const r = isNew ? baseR * 1.3 : isRecent ? baseR * 1.1 : baseR * 0.85;
const size = r * 2;
return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div
className={isNew ? 'gl-event-flash' : undefined}
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
cx={r} cy={r} r={r - 1}
fill={color} fillOpacity={isNew ? 0.9 : isRecent ? opacity * 0.8 : opacity * 0.4}
stroke={color} strokeWidth={isNew ? 3 : isRecent ? 2.5 : 1}
opacity={isNew ? 1 : opacity}
/>
</svg>
</div>
</Marker>
);
})}
{impactEvents.map(event => {
const ageMs = currentTime - event.timestamp;
const ageHours = ageMs / 3600_000;
const isRecent = ageHours <= 24;
// 최근 1일: 진하게, 이후: 흐리게
const impactOpacity = isRecent
? Math.max(0.8, 1 - ageHours * 0.008)
: Math.max(0.2, 0.45 - (ageHours - 24) * 0.005);
const s = isRecent ? 22 : 16;
const c = s / 2;
const sw = isRecent ? 1.5 : 1;
return (
<Marker key={event.id} longitude={event.lng} latitude={event.lat} anchor="center">
<div style={{ position: 'relative', cursor: 'pointer', opacity: impactOpacity }}
onClick={(e) => { e.stopPropagation(); setSelectedEventId(event.id); }}>
<svg viewBox={`0 0 ${s} ${s}`} width={s} height={s}>
<circle cx={c} cy={c} r={c * 0.77} fill="none" stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<circle cx={c} cy={c} r={c * 0.32} fill="none" stroke="#ff0000" strokeWidth={sw * 0.7} opacity={0.9} />
<circle cx={c} cy={c} r={c * 0.14} fill="#ff0000" opacity={1} />
<line x1={c} y1={0.5} x2={c} y2={c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={c} y1={s - 0.5} x2={c} y2={s - c * 0.23} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={0.5} y1={c} x2={c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
<line x1={s - 0.5} y1={c} x2={s - c * 0.23} y2={c} stroke="#ff0000" strokeWidth={sw} opacity={0.8} />
</svg>
<div className="gl-impact-label">
{isRecent && <span className="gl-new-badge">NEW</span>}
{event.label}
</div>
</div>
</Marker>
);
})}
{selectedEvent && (
<Popup
longitude={selectedEvent.lng}
latitude={selectedEvent.lat}
onClose={() => setSelectedEventId(null)}
closeOnClick={false}
anchor="bottom"
maxWidth="320px"
className="gl-popup"
>
<div style={{ minWidth: 200, maxWidth: 320 }}>
{selectedEvent.source && (
<span style={{
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
}}>
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
</span>
)}
{selectedEvent.type === 'impact' && (
<div style={{
background: '#ff0000', color: '#fff', padding: '3px 8px',
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
</div>
)}
<div><strong>{selectedEvent.label}</strong></div>
<span style={{ fontSize: 12, color: '#888' }}>
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
</span>
{selectedEvent.description && (
<p style={{ margin: '6px 0 0', fontSize: 13 }}>{selectedEvent.description}</p>
)}
{selectedEvent.imageUrl && (
<div style={{ marginTop: 8 }}>
<img
src={selectedEvent.imageUrl}
alt={selectedEvent.imageCaption || selectedEvent.label}
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
{selectedEvent.imageCaption && (
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
)}
</div>
)}
{selectedEvent.type === 'impact' && (
<div style={{ fontSize: 10, color: '#888', marginTop: 6 }}>
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
</div>
)}
</div>
</Popup>
)}
</>
)}
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
{layers.ships && <DamagedShipLayer currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
</Map>
);
}

파일 보기

@ -0,0 +1,182 @@
import { memo, useMemo, useState } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import type { SatellitePosition } from '../types';
interface Props {
satellites: SatellitePosition[];
}
const CAT_COLORS: Record<SatellitePosition['category'], string> = {
reconnaissance: '#ef4444',
communications: '#3b82f6',
navigation: '#22c55e',
weather: '#a855f7',
other: '#6b7280',
};
const CAT_LABELS: Record<SatellitePosition['category'], string> = {
reconnaissance: 'RECON', communications: 'COMMS',
navigation: 'NAV', weather: 'WX', other: 'SAT',
};
const SVG_RECON = (
<>
<rect x={8} y={6} width={8} height={12} rx={1.5} fill="currentColor" opacity={0.9} />
<rect x={1} y={8} width={6} height={3} rx={0.5} fill="currentColor" opacity={0.7} />
<rect x={17} y={8} width={6} height={3} rx={0.5} fill="currentColor" opacity={0.7} />
<circle cx={12} cy={16} r={1.8} fill="none" stroke="currentColor" strokeWidth={1} opacity={0.6} />
<circle cx={12} cy={16} r={0.6} fill="currentColor" opacity={0.6} />
</>
);
const SVG_COMMS = (
<>
<rect x={9} y={7} width={6} height={10} rx={1} fill="currentColor" opacity={0.9} />
<rect x={1} y={9} width={7} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<rect x={16} y={9} width={7} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<path d="M10 4 Q12 2 14 4" fill="none" stroke="currentColor" strokeWidth={1.2} opacity={0.8} />
<line x1={12} y1={4} x2={12} y2={7} stroke="currentColor" strokeWidth={0.8} />
</>
);
const SVG_NAV = (
<>
<rect x={9} y={6} width={6} height={12} rx={1} fill="currentColor" opacity={0.9} />
<rect x={2} y={8} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<rect x={16} y={8} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<line x1={12} y1={3} x2={12} y2={6} stroke="currentColor" strokeWidth={1} />
<circle cx={12} cy={2.5} r={1} fill="currentColor" opacity={0.7} />
</>
);
const SVG_WEATHER = (
<>
<rect x={8} y={7} width={8} height={10} rx={1.5} fill="currentColor" opacity={0.9} />
<rect x={1} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<rect x={17} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<circle cx={12} cy={12} r={2.5} fill="none" stroke="currentColor" strokeWidth={0.8} opacity={0.5} />
</>
);
const SVG_OTHER = (
<>
<rect x={9} y={7} width={6} height={10} rx={1} fill="currentColor" opacity={0.9} />
<rect x={2} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
<rect x={16} y={9} width={6} height={2.5} rx={0.5} fill="currentColor" opacity={0.7} />
</>
);
const SVG_MAP: Record<SatellitePosition['category'], React.ReactNode> = {
reconnaissance: SVG_RECON, communications: SVG_COMMS,
navigation: SVG_NAV, weather: SVG_WEATHER, other: SVG_OTHER,
};
export function SatelliteLayer({ satellites }: Props) {
// Ground tracks as GeoJSON
const trackData = useMemo(() => {
const features: GeoJSON.Feature[] = [];
for (const sat of satellites) {
if (!sat.groundTrack || sat.groundTrack.length < 2) continue;
const color = CAT_COLORS[sat.category];
// Break at antimeridian crossings
let segment: [number, number][] = [];
for (let i = 0; i < sat.groundTrack.length; i++) {
const [lat, lng] = sat.groundTrack[i];
if (i > 0) {
const [, prevLng] = sat.groundTrack[i - 1];
if (Math.abs(lng - prevLng) > 180) {
if (segment.length > 1) {
features.push({
type: 'Feature',
properties: { color },
geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) },
});
}
segment = [];
}
}
segment.push([lat, lng]);
}
if (segment.length > 1) {
features.push({
type: 'Feature',
properties: { color },
geometry: { type: 'LineString', coordinates: segment.map(([la, lo]) => [lo, la]) },
});
}
}
return { type: 'FeatureCollection' as const, features };
}, [satellites]);
return (
<>
{trackData.features.length > 0 && (
<Source id="satellite-tracks" type="geojson" data={trackData}>
<Layer
id="satellite-track-lines"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-width': 1,
'line-opacity': 0.25,
'line-dasharray': [6, 4],
}}
/>
</Source>
)}
{satellites.map(sat => (
<SatelliteMarker key={sat.noradId} sat={sat} />
))}
</>
);
}
const SatelliteMarker = memo(function SatelliteMarker({ sat }: { sat: SatellitePosition }) {
const [showPopup, setShowPopup] = useState(false);
const color = CAT_COLORS[sat.category];
const svgBody = SVG_MAP[sat.category];
const size = 22;
return (
<>
<Marker longitude={sat.lng} latitude={sat.lat} anchor="center">
<div style={{ position: 'relative' }}>
<div
style={{ width: size, height: size, color, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
>
<svg viewBox="0 0 24 24" width={size} height={size} style={{ color }}>
{svgBody}
</svg>
</div>
<div className="gl-marker-label" style={{ color, fontSize: 10 }}>
{sat.name}
</div>
</div>
</Marker>
{showPopup && (
<Popup longitude={sat.lng} latitude={sat.lat}
onClose={() => setShowPopup(false)} closeOnClick={false}
anchor="bottom" maxWidth="200px" className="gl-popup">
<div style={{ minWidth: 180, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{
background: color, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{CAT_LABELS[sat.category]}</span>
<strong>{sat.name}</strong>
</div>
<table style={{ width: '100%', fontSize: 11 }}>
<tbody>
<tr><td style={{ color: '#888' }}>NORAD</td><td>{sat.noradId}</td></tr>
<tr><td style={{ color: '#888' }}>Lat</td><td>{sat.lat.toFixed(2)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>Lng</td><td>{sat.lng.toFixed(2)}&deg;</td></tr>
<tr><td style={{ color: '#888' }}>Alt</td><td>{Math.round(sat.altitude)} km</td></tr>
</tbody>
</table>
</div>
</Popup>
)}
</>
);
});

파일 보기

@ -0,0 +1,260 @@
import { useMemo, useState, useRef } from 'react';
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: '&copy; Esri, Maxar, Earthstar Geographics',
maxzoom: 19,
},
'esri-boundaries': {
type: 'raster' as const,
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
},
},
layers: [
{ id: 'background', type: 'background' as const, paint: { 'background-color': '#000811' } },
{ id: 'satellite', type: 'raster' as const, source: 'esri-satellite' },
{ id: 'boundaries', type: 'raster' as const, source: 'esri-boundaries', paint: { 'raster-opacity': 0.65 } },
],
};
const EVENT_COLORS: Record<GeoEvent['type'], string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
const SOURCE_COLORS: Record<string, string> = {
US: '#ef4444',
IL: '#22c55e',
IR: '#ff0000',
proxy: '#f59e0b',
};
function getEventColor(event: GeoEvent): string {
if (event.type === 'impact') return '#ff0000';
if (event.source && SOURCE_COLORS[event.source]) return SOURCE_COLORS[event.source];
return EVENT_COLORS[event.type];
}
const EVENT_RADIUS: Record<GeoEvent['type'], number> = {
airstrike: 12,
explosion: 10,
missile_launch: 8,
intercept: 7,
alert: 6,
impact: 14,
osint: 8,
};
export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, layers }: Props) {
const mapRef = useRef<MapRef>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const visibleEvents = useMemo(() => {
if (!layers.events) return [];
return events.filter(e => e.timestamp <= currentTime);
}, [events, currentTime, layers.events]);
const selectedEvent = useMemo(
() => visibleEvents.find(e => e.id === selectedEventId) || null,
[visibleEvents, selectedEventId],
);
const countryLabels = useMemo(() => countryLabelsGeoJSON(), []);
return (
<Map
ref={mapRef}
initialViewState={{
longitude: 53.0,
latitude: 32.0,
zoom: 5.5,
}}
style={{ width: '100%', height: '100%' }}
mapStyle={SATELLITE_STYLE as maplibregl.StyleSpecification}
attributionControl={false}
>
<NavigationControl position="top-right" />
{/* Korean country labels */}
<Source id="country-labels" type="geojson" data={countryLabels}>
<Layer
id="country-label-lg"
type="symbol"
filter={['==', ['get', 'rank'], 1]}
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 15,
'text-allow-overlap': false,
'text-ignore-placement': false,
}}
paint={{
'text-color': '#1a1a2e',
'text-halo-color': 'rgba(255,255,255,0.7)',
'text-halo-width': 2,
}}
/>
<Layer
id="country-label-md"
type="symbol"
filter={['==', ['get', 'rank'], 2]}
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 12,
'text-allow-overlap': false,
}}
paint={{
'text-color': '#2a2a3e',
'text-halo-color': 'rgba(255,255,255,0.6)',
'text-halo-width': 1.5,
}}
/>
<Layer
id="country-label-sm"
type="symbol"
filter={['==', ['get', 'rank'], 3]}
minzoom={4}
layout={{
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold'],
'text-size': 10,
'text-allow-overlap': false,
}}
paint={{
'text-color': '#333350',
'text-halo-color': 'rgba(255,255,255,0.5)',
'text-halo-width': 1.5,
}}
/>
</Source>
{/* Event markers */}
{visibleEvents.map(ev => (
<Marker
key={ev.id}
longitude={ev.lng}
latitude={ev.lat}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setSelectedEventId(ev.id); }}
>
<div
style={{
width: EVENT_RADIUS[ev.type],
height: EVENT_RADIUS[ev.type],
borderRadius: '50%',
background: getEventColor(ev),
border: '2px solid rgba(255,255,255,0.8)',
boxShadow: `0 0 8px ${getEventColor(ev)}`,
cursor: 'pointer',
}}
/>
</Marker>
))}
{/* Popup */}
{selectedEvent && (
<Popup
longitude={selectedEvent.lng}
latitude={selectedEvent.lat}
anchor="bottom"
onClose={() => setSelectedEventId(null)}
closeOnClick={false}
maxWidth="320px"
className="event-popup"
>
<div style={{ minWidth: 200, maxWidth: 320 }}>
{selectedEvent.source && (
<span style={{
background: getEventColor(selectedEvent), color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700, marginBottom: 4, display: 'inline-block',
}}>
{{ US: '미국', IL: '이스라엘', IR: '이란', proxy: '대리세력' }[selectedEvent.source]}
</span>
)}
{selectedEvent.type === 'impact' && (
<div style={{
background: '#ff0000', color: '#fff', padding: '3px 8px',
borderRadius: 3, fontSize: 11, fontWeight: 700, marginBottom: 6,
display: 'inline-block',
}}>
IMPACT SITE
</div>
)}
<div style={{ color: '#e0e0e0' }}><strong>{selectedEvent.label}</strong></div>
<span style={{ fontSize: 12, color: '#888' }}>
{new Date(selectedEvent.timestamp + 9 * 3600_000).toISOString().slice(0, 16).replace('T', ' ')} KST
</span>
{selectedEvent.description && (
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#ccc' }}>{selectedEvent.description}</p>
)}
{selectedEvent.imageUrl && (
<div style={{ marginTop: 8 }}>
<img
src={selectedEvent.imageUrl}
alt={selectedEvent.imageCaption || selectedEvent.label}
style={{ width: '100%', borderRadius: 4, maxHeight: 180, objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
{selectedEvent.imageCaption && (
<div style={{ fontSize: 10, color: '#aaa', marginTop: 3 }}>{selectedEvent.imageCaption}</div>
)}
</div>
)}
<div style={{ fontSize: 10, color: '#666', marginTop: 6 }}>
{selectedEvent.lat.toFixed(4)}°N, {selectedEvent.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
{/* Overlay layers */}
{layers.aircraft && <AircraftLayer aircraft={aircraft} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
<DamagedShipLayer currentTime={currentTime} />
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
{layers.airports && <AirportLayer airports={middleEastAirports} />}
</Map>
);
}

파일 보기

@ -0,0 +1,102 @@
import { useMemo } from 'react';
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 visibleData = useMemo(
() => data.filter(d => d.timestamp <= currentTime),
[data, currentTime],
);
const chartData = useMemo(
() =>
visibleData.map(d => ({
...d,
time: formatHour(d.timestamp, startTime),
})),
[visibleData, startTime],
);
return (
<div className="sensor-chart">
<h3>Sensor Data</h3>
<div className="chart-grid">
<div className="chart-item">
<h4>Seismic Activity</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<ReferenceLine x={formatHour(currentTime, startTime)} stroke="#fff" strokeDasharray="3 3" />
<Line type="monotone" dataKey="seismic" stroke="#ef4444" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>Noise Level (dB)</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>Air Pressure (hPa)</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[990, 1020]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="airPressure" stroke="#3b82f6" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>Radiation (uSv/h)</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[0, 0.3]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="radiationLevel" stroke="#22c55e" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
function formatHour(timestamp: number, startTime: number): string {
const hours = (timestamp - startTime) / 3600_000;
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h}:${m.toString().padStart(2, '0')}`;
}

파일 보기

@ -0,0 +1,407 @@
import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
import type { Ship, ShipCategory } from '../types';
import maplibregl from 'maplibre-gl';
interface Props {
ships: Ship[];
militaryOnly: boolean;
koreanOnly?: boolean;
}
// ── MarineTraffic-style vessel type colors ──
const MT_TYPE_COLORS: Record<string, string> = {
cargo: '#f0a830', // orange-yellow
tanker: '#e74c3c', // red
passenger: '#4caf50', // green
fishing: '#42a5f5', // light blue
pleasure: '#e91e8c', // pink/magenta
military: '#d32f2f', // dark red
tug_special: '#2e7d32', // dark green
other: '#5c6bc0', // indigo/blue
unknown: '#9e9e9e', // grey
};
// Map our internal ShipCategory + typecode → MT visual type
function getMTType(ship: Ship): string {
const tc = (ship.typecode || '').toUpperCase();
const cat = ship.category;
// Military first
if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military';
if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military';
// Tanker
if (cat === 'tanker') return 'tanker';
if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker';
if (tc.startsWith('A1')) return 'tanker';
// Cargo
if (cat === 'cargo') return 'cargo';
if (tc === 'CONT' || tc === 'BULK') return 'cargo';
if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo';
// Passenger
if (tc === 'PASS' || tc.startsWith('B')) return 'passenger';
// Fishing
if (tc.startsWith('C')) return 'fishing';
// Tug / Special
if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special';
// Pleasure
if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure';
if (cat === 'civilian') return 'other';
return 'unknown';
}
// Legacy navy flag colors (for popup header accent only)
const NAVY_COLORS: Record<string, string> = {
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
};
const CATEGORY_LABELS: Record<ShipCategory, string> = {
carrier: 'CARRIER', destroyer: 'DDG', warship: 'WARSHIP', submarine: 'SUB',
patrol: 'PATROL', tanker: 'TANKER', cargo: 'CARGO', civilian: 'CIV', unknown: 'N/A',
};
const MT_TYPE_LABELS: Record<string, string> = {
cargo: 'Cargo', tanker: 'Tanker', passenger: 'Passenger', fishing: 'Fishing',
pleasure: 'Yacht', military: 'Military', tug_special: 'Tug/Special', other: 'Other', unknown: 'Unknown',
};
const FLAG_LABELS: Record<string, string> = {
US: 'USN', UK: 'RN', FR: 'MN', KR: 'ROKN', IR: 'IRIN',
JP: 'JMSDF', AU: 'RAN', DE: 'DM', IN: 'IN',
};
const FLAG_EMOJI: Record<string, string> = {
US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}',
KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}',
AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}',
CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}',
MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}',
BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}',
SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}',
};
// icon-size multiplier (symbol layer, base=64px)
const SIZE_MAP: Record<ShipCategory, number> = {
carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16,
tanker: 0.16, cargo: 0.16, civilian: 0.14, unknown: 0.12,
};
const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
function isMilitary(category: ShipCategory): boolean {
return MIL_CATEGORIES.includes(category);
}
function getShipColor(ship: Ship): string {
return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown;
}
// ── Local Korean ship photos ──
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'440034000': '/ships/440034000.jpg',
'440150000': '/ships/440150000.jpg',
'440272000': '/ships/440272000.jpg',
'440274000': '/ships/440274000.jpg',
'440323000': '/ships/440323000.jpg',
'440384000': '/ships/440384000.jpg',
'440880000': '/ships/440880000.jpg',
'441046000': '/ships/441046000.jpg',
'441345000': '/ships/441345000.jpg',
'441353000': '/ships/441353000.jpg',
'441393000': '/ships/441393000.jpg',
'441423000': '/ships/441423000.jpg',
'441548000': '/ships/441548000.jpg',
'441708000': '/ships/441708000.png',
'441866000': '/ships/441866000.jpg',
};
interface VesselPhotoData { url: string; }
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
function VesselPhoto({ mmsi }: { mmsi: string }) {
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
const [photo, setPhoto] = useState<VesselPhotoData | null | undefined>(() => {
if (localUrl) return { url: localUrl };
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
});
useEffect(() => {
if (localUrl) return;
if (photo !== undefined) return;
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image();
img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setPhoto(result); };
img.onerror = () => { vesselPhotoCache.set(mmsi, null); setPhoto(null); };
img.src = imgUrl;
}, [mmsi, photo, localUrl]);
if (!photo) return null;
return (
<div style={{ marginBottom: 6 }}>
<img src={photo.url} alt="Vessel"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
);
}
function formatCoord(lat: number, lng: number): string {
const latDir = lat >= 0 ? 'N' : 'S';
const lngDir = lng >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`;
}
// Create triangle SDF image for MapLibre symbol layer
const TRIANGLE_SIZE = 64;
function ensureTriangleImage(map: maplibregl.Map) {
if (map.hasImage('ship-triangle')) return;
const s = TRIANGLE_SIZE;
const canvas = document.createElement('canvas');
canvas.width = s;
canvas.height = s;
const ctx = canvas.getContext('2d')!;
// Draw upward-pointing triangle (heading 0 = north)
ctx.beginPath();
ctx.moveTo(s / 2, 2); // top center
ctx.lineTo(s * 0.12, s - 2); // bottom left
ctx.lineTo(s / 2, s * 0.62); // inner notch
ctx.lineTo(s * 0.88, s - 2); // bottom right
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.fill();
const imgData = ctx.getImageData(0, 0, s, s);
map.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(imgData.data.buffer) }, { sdf: true });
}
// ── Main layer (WebGL symbol rendering — triangles) ──
export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
const { current: map } = useMap();
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(false);
const filtered = useMemo(() => {
let result = ships;
if (koreanOnly) result = result.filter(s => s.flag === 'KR');
if (militaryOnly) result = result.filter(s => isMilitary(s.category));
return result;
}, [ships, militaryOnly, koreanOnly]);
// Add triangle image to map
useEffect(() => {
if (!map) return;
const m = map.getMap();
const addIcon = () => {
try { ensureTriangleImage(m); } catch { /* already added */ }
setImageReady(true);
};
if (m.isStyleLoaded()) { addIcon(); }
else { m.once('load', addIcon); }
return () => { m.off('load', addIcon); };
}, [map]);
// Build GeoJSON for all ships
const shipGeoJson = useMemo(() => {
const features: GeoJSON.Feature[] = filtered.map(ship => ({
type: 'Feature' as const,
properties: {
mmsi: ship.mmsi,
color: getShipColor(ship),
size: SIZE_MAP[ship.category],
isMil: isMilitary(ship.category) ? 1 : 0,
isKorean: ship.flag === 'KR' ? 1 : 0,
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
heading: ship.heading,
},
geometry: {
type: 'Point' as const,
coordinates: [ship.lng, ship.lat],
},
}));
return { type: 'FeatureCollection' as const, features };
}, [filtered]);
// Register click and cursor handlers
useEffect(() => {
if (!map) return;
const m = map.getMap();
const layerId = 'ships-triangles';
const handleClick = (e: maplibregl.MapLayerMouseEvent) => {
if (e.features && e.features.length > 0) {
const mmsi = e.features[0].properties?.mmsi;
if (mmsi) setSelectedMmsi(mmsi);
}
};
const handleEnter = () => { m.getCanvas().style.cursor = 'pointer'; };
const handleLeave = () => { m.getCanvas().style.cursor = ''; };
m.on('click', layerId, handleClick);
m.on('mouseenter', layerId, handleEnter);
m.on('mouseleave', layerId, handleLeave);
return () => {
m.off('click', layerId, handleClick);
m.off('mouseenter', layerId, handleEnter);
m.off('mouseleave', layerId, handleLeave);
};
}, [map]);
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
// Carrier labels — only a few, so DOM markers are fine
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
if (!imageReady) return null;
return (
<>
<Source id="ships-source" type="geojson" data={shipGeoJson}>
{/* Korean ship outer ring (circle behind triangle) */}
<Layer
id="ships-korean-ring"
type="circle"
filter={['==', ['get', 'isKorean'], 1]}
paint={{
'circle-radius': ['*', ['get', 'size'], 14],
'circle-color': 'transparent',
'circle-stroke-color': '#00e5ff',
'circle-stroke-width': 1.5,
'circle-stroke-opacity': 0.6,
}}
/>
{/* Main ship triangles */}
<Layer
id="ships-triangles"
type="symbol"
layout={{
'icon-image': 'ship-triangle',
'icon-size': ['get', 'size'],
'icon-rotate': ['get', 'heading'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
}}
paint={{
'icon-color': ['get', 'color'],
'icon-opacity': 0.9,
'icon-halo-color': ['case',
['==', ['get', 'isMil'], 1], '#ffffff',
'rgba(255,255,255,0.3)',
],
'icon-halo-width': ['case',
['==', ['get', 'isMil'], 1], 1,
0.3,
],
}}
/>
</Source>
{/* Carrier labels as DOM markers (very few) */}
{carriers.map(ship => (
<Marker key={`label-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ pointerEvents: 'none' }}>
<div className="gl-marker-label" style={{ color: getShipColor(ship) }}>
{ship.name}
</div>
</div>
</Marker>
))}
{/* Popup for selected ship */}
{selectedShip && (
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} />
)}
</>
);
}
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
const isMil = isMilitary(ship.category);
const navyLabel = isMil && ship.flag && FLAG_LABELS[ship.flag] ? FLAG_LABELS[ship.flag] : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : color;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
return (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div style={{ minWidth: 280, maxWidth: 340, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: isMil ? '#1a1a2e' : '#1565c0', color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{flagEmoji && <span style={{ fontSize: 16 }}>{flagEmoji}</span>}
<strong style={{ fontSize: 13, flex: 1 }}>{ship.name}</strong>
{navyLabel && (
<span style={{
background: navyAccent, color: '#000', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{navyLabel}</span>
)}
</div>
<VesselPhoto mmsi={ship.mmsi} />
<div style={{
display: 'flex', gap: 4, marginBottom: 6,
borderBottom: '1px solid #ddd', paddingBottom: 4,
}}>
<span style={{
background: color, color: '#fff', padding: '1px 6px',
borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>{MT_TYPE_LABELS[mtType] || 'Unknown'}</span>
<span style={{
background: '#333', color: '#ccc', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{CATEGORY_LABELS[ship.category]}</span>
{ship.typeDesc && (
<span style={{ color: '#666', fontSize: 10, lineHeight: '18px' }}>{ship.typeDesc}</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px', fontSize: 11 }}>
<div>
<div><span style={{ color: '#888' }}>MMSI : </span>{ship.mmsi}</div>
{ship.callSign && <div><span style={{ color: '#888' }}>Call Sign : </span>{ship.callSign}</div>}
{ship.imo && <div><span style={{ color: '#888' }}>IMO : </span>{ship.imo}</div>}
{ship.status && <div><span style={{ color: '#888' }}>Status : </span>{ship.status}</div>}
{ship.length && <div><span style={{ color: '#888' }}>Length : </span>{ship.length}m</div>}
{ship.width && <div><span style={{ color: '#888' }}>Width : </span>{ship.width}m</div>}
{ship.draught && <div><span style={{ color: '#888' }}>Draught : </span>{ship.draught}m</div>}
</div>
<div>
<div><span style={{ color: '#888' }}>Heading : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Course : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span style={{ color: '#888' }}>Speed : </span>{ship.speed.toFixed(1)} kn</div>
<div><span style={{ color: '#888' }}>Lat : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
<div><span style={{ color: '#888' }}>Lon : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
{ship.destination && <div><span style={{ color: '#888' }}>Dest : </span>{ship.destination}</div>}
{ship.eta && <div><span style={{ color: '#888' }}>ETA : </span>{new Date(ship.eta).toLocaleString()}</div>}
</div>
</div>
<div style={{ marginTop: 6, fontSize: 9, color: '#999', textAlign: 'right' }}>
Last Update : {new Date(ship.lastSeen).toLocaleString()}
</div>
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
MarineTraffic &rarr;
</a>
</div>
</div>
</Popup>
);
});

파일 보기

@ -0,0 +1,154 @@
import { useState } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../services/submarineCable';
import type { SubmarineCable } from '../services/submarineCable';
export function SubmarineCableLayer() {
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
// Build GeoJSON for all cables
const geojson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: KOREA_SUBMARINE_CABLES.map(cable => ({
type: 'Feature' as const,
properties: {
id: cable.id,
name: cable.name,
color: cable.color,
},
geometry: {
type: 'LineString' as const,
coordinates: cable.route,
},
})),
};
return (
<>
{/* Cable lines */}
<Source id="submarine-cables" type="geojson" data={geojson}>
<Layer
id="submarine-cables-outline"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-width': 1.5,
'line-opacity': 0.25,
}}
/>
<Layer
id="submarine-cables-line"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-width': 1,
'line-opacity': 0.6,
'line-dasharray': [4, 3],
}}
/>
</Source>
{/* Landing points */}
{KOREA_LANDING_POINTS.map(pt => (
<Marker key={pt.name} longitude={pt.lng} latitude={pt.lat} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedPoint(pt); setSelectedCable(null); }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: '#00e5ff', border: '1.5px solid #fff',
boxShadow: '0 0 6px #00e5ff88',
cursor: 'pointer',
}} />
</Marker>
))}
{/* Cable name labels along route (midpoint) */}
{KOREA_SUBMARINE_CABLES.map(cable => {
const mid = cable.route[Math.floor(cable.route.length / 3)];
if (!mid) return null;
return (
<Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center"
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
<div style={{
fontSize: 7, fontFamily: 'monospace', fontWeight: 600,
color: cable.color, cursor: 'pointer',
textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000',
whiteSpace: 'nowrap', opacity: 0.8,
}}>
{cable.name}
</div>
</Marker>
);
})}
{/* Landing point popup */}
{selectedPoint && (
<Popup longitude={selectedPoint.lng} latitude={selectedPoint.lat}
onClose={() => setSelectedPoint(null)} closeOnClick={false}
anchor="bottom" maxWidth="260px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 180 }}>
<div style={{
background: '#00e5ff', color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span>📡</span> {selectedPoint.name}
</div>
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 4 }}>
: {selectedPoint.cables.length}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{selectedPoint.cables.map(cid => {
const c = KOREA_SUBMARINE_CABLES.find(cc => cc.id === cid);
if (!c) return null;
return (
<div key={cid} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: c.color, flexShrink: 0 }} />
<span style={{ color: '#ddd' }}>{c.name}</span>
</div>
);
})}
</div>
</div>
</Popup>
)}
{/* Cable info popup */}
{selectedCable && (
<Popup
longitude={selectedCable.route[0][0]}
latitude={selectedCable.route[0][1]}
onClose={() => setSelectedCable(null)} closeOnClick={false}
anchor="bottom" maxWidth="280px" className="gl-popup">
<div style={{ fontFamily: 'monospace', fontSize: 12, minWidth: 200 }}>
<div style={{
background: selectedCable.color, color: '#000',
padding: '4px 8px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
fontWeight: 700, fontSize: 13,
}}>
🔌 {selectedCable.name}
</div>
<div style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div>
<span style={{ color: '#888' }}>: </span>
<span style={{ color: '#ddd' }}>{selectedCable.landingPoints.join(' → ')}</span>
</div>
{selectedCable.rfsYear && (
<div><span style={{ color: '#888' }}>: </span>{selectedCable.rfsYear}</div>
)}
{selectedCable.length && (
<div><span style={{ color: '#888' }}> : </span>{selectedCable.length}</div>
)}
{selectedCable.owners && (
<div><span style={{ color: '#888' }}>: </span>{selectedCable.owners}</div>
)}
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,159 @@
import { useMemo, useState, useCallback } from 'react';
import type { GeoEvent } from '../types';
interface Props {
currentTime: number;
startTime: number;
endTime: number;
events: GeoEvent[];
onSeek: (time: number) => void;
onEventFlyTo?: (event: GeoEvent) => void;
}
const KST_OFFSET = 9 * 3600_000;
const TYPE_COLORS: Record<string, string> = {
airstrike: '#ef4444',
explosion: '#f97316',
missile_launch: '#eab308',
intercept: '#3b82f6',
alert: '#a855f7',
impact: '#ff0000',
osint: '#06b6d4',
};
const TYPE_LABELS_KO: Record<string, string> = {
airstrike: '공습',
explosion: '폭발',
missile_launch: '미사일 발사',
intercept: '요격',
alert: '경보',
impact: '피격',
osint: 'OSINT',
};
const SOURCE_LABELS_KO: Record<string, string> = {
US: '미국',
IL: '이스라엘',
IR: '이란',
proxy: '대리세력',
};
export function TimelineSlider({ currentTime, startTime, endTime, events, onSeek, onEventFlyTo }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const progress = ((currentTime - startTime) / (endTime - startTime)) * 100;
const eventMarkers = useMemo(() => {
return events.map(e => ({
id: e.id,
position: ((e.timestamp - startTime) / (endTime - startTime)) * 100,
type: e.type,
label: e.label,
}));
}, [events, startTime, endTime]);
const formatTime = (t: number) => {
const d = new Date(t + KST_OFFSET);
return d.toISOString().slice(0, 16).replace('T', ' ') + ' KST';
};
const formatTimeShort = (t: number) => {
const d = new Date(t + KST_OFFSET);
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
};
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
onSeek(startTime + pct * (endTime - startTime));
};
// When a marker is clicked: select it + seek to its time
const handleMarkerClick = useCallback((e: React.MouseEvent, ev: GeoEvent) => {
e.stopPropagation(); // don't trigger track seek
setSelectedId(prev => prev === ev.id ? null : ev.id);
onSeek(ev.timestamp);
}, [onSeek]);
// Find events near the selected event (within 30 min)
const selectedCluster = useMemo(() => {
if (!selectedId) return [];
const sel = events.find(e => e.id === selectedId);
if (!sel) return [];
const WINDOW = 30 * 60_000; // 30 min
return events
.filter(e => Math.abs(e.timestamp - sel.timestamp) <= WINDOW)
.sort((a, b) => a.timestamp - b.timestamp);
}, [selectedId, events]);
const handleEventCardClick = useCallback((ev: GeoEvent) => {
onSeek(ev.timestamp);
onEventFlyTo?.(ev);
}, [onSeek, onEventFlyTo]);
return (
<div className="timeline-slider">
<div className="timeline-labels">
<span>{formatTime(startTime)}</span>
<span className="timeline-current">{formatTime(currentTime)}</span>
<span>{formatTime(endTime)}</span>
</div>
<div className="timeline-track" onClick={handleTrackClick}>
<div className="timeline-progress" style={{ width: `${progress}%` }} />
<div className="timeline-playhead" style={{ left: `${progress}%` }} />
{eventMarkers.map(m => {
const ev = events.find(e => e.id === m.id)!;
const isSelected = selectedId === m.id;
const isInCluster = selectedCluster.some(c => c.id === m.id);
return (
<div
key={m.id}
className={`tl-marker ${isSelected ? 'selected' : ''} ${isInCluster && !isSelected ? 'in-cluster' : ''}`}
style={{
left: `${m.position}%`,
'--marker-color': TYPE_COLORS[m.type] || '#888',
} as React.CSSProperties}
title={m.label}
onClick={(e) => handleMarkerClick(e, ev)}
/>
);
})}
</div>
{/* Event detail strip — shown when a marker is selected */}
{selectedCluster.length > 0 && (
<div className="tl-detail-strip">
{selectedCluster.map(ev => {
const color = TYPE_COLORS[ev.type] || '#888';
const isPast = ev.timestamp <= currentTime;
const isActive = ev.id === selectedId;
const source = ev.source ? SOURCE_LABELS_KO[ev.source] : '';
const typeLabel = TYPE_LABELS_KO[ev.type] || ev.type;
return (
<button
key={ev.id}
className={`tl-event-card ${isActive ? 'active' : ''} ${isPast ? 'past' : 'future'}`}
style={{ '--card-color': color } as React.CSSProperties}
onClick={() => handleEventCardClick(ev)}
title="클릭하면 지도에서 해당 위치로 이동합니다"
>
<span className="tl-card-dot" />
<span className="tl-card-time">{formatTimeShort(ev.timestamp)}</span>
{source && (
<span className="tl-card-source" style={{ background: color }}>{source}</span>
)}
<span className="tl-card-name">{ev.label}</span>
<span className="tl-card-type">{typeLabel}</span>
<svg className="tl-card-goto" viewBox="0 0 16 16" width="10" height="10">
<path d="M8 1L14 8L8 15M14 8H1" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
</button>
);
})}
</div>
)}
</div>
);
}

112
src/data/airports.ts Normal file
파일 보기

@ -0,0 +1,112 @@
// Major airports in the Middle East / Horn of Africa region
// Reference: Flightradar24, OurAirports
export interface Airport {
iata: string; // IATA code (e.g. "IKA")
icao: string; // ICAO code (e.g. "OIIE")
name: string;
nameKo?: string; // Korean name
lat: number;
lng: number;
type: 'large' | 'medium' | 'small' | 'military';
country: string; // ISO 2-letter
city?: string;
}
export const middleEastAirports: Airport[] = [
// ── 이란 (Iran) ──
{ iata: 'IKA', icao: 'OIIE', name: 'Imam Khomeini Intl', nameKo: '이맘 호메이니 국제공항', lat: 35.4161, lng: 51.1522, type: 'large', country: 'IR', city: 'Tehran' },
{ iata: 'THR', icao: 'OIII', name: 'Mehrabad Intl', nameKo: '메흐라바드 국제공항', lat: 35.6892, lng: 51.3134, type: 'large', country: 'IR', city: 'Tehran' },
{ iata: 'MHD', icao: 'OIMM', name: 'Mashhad Intl', nameKo: '마슈하드 국제공항', lat: 36.2352, lng: 59.6410, type: 'large', country: 'IR', city: 'Mashhad' },
{ iata: 'IFN', icao: 'OIFM', name: 'Isfahan Intl', nameKo: '이스파한 국제공항', lat: 32.7508, lng: 51.8613, type: 'large', country: 'IR', city: 'Isfahan' },
{ iata: 'SYZ', icao: 'OISS', name: 'Shiraz Intl', nameKo: '시라즈 국제공항', lat: 29.5392, lng: 52.5899, type: 'large', country: 'IR', city: 'Shiraz' },
{ iata: 'TBZ', icao: 'OITT', name: 'Tabriz Intl', nameKo: '타브리즈 국제공항', lat: 38.1339, lng: 46.2350, type: 'medium', country: 'IR', city: 'Tabriz' },
{ iata: 'BND', icao: 'OIKB', name: 'Bandar Abbas Intl', nameKo: '반다르 아바스 국제공항', lat: 27.2183, lng: 56.3778, type: 'medium', country: 'IR', city: 'Bandar Abbas' },
{ iata: 'AWZ', icao: 'OIAW', name: 'Ahvaz Intl', nameKo: '아흐바즈 국제공항', lat: 31.3374, lng: 48.7620, type: 'medium', country: 'IR', city: 'Ahvaz' },
{ iata: 'KIH', icao: 'OIBK', name: 'Kish Island Intl', nameKo: '키시섬 국제공항', lat: 26.5262, lng: 53.9802, type: 'medium', country: 'IR', city: 'Kish Island' },
{ iata: 'BUZ', icao: 'OIBB', name: 'Bushehr Airport', nameKo: '부셰르 공항', lat: 28.9448, lng: 50.8346, type: 'medium', country: 'IR', city: 'Bushehr' },
{ iata: 'KER', icao: 'OIKK', name: 'Kerman Airport', nameKo: '케르만 공항', lat: 30.2744, lng: 56.9511, type: 'medium', country: 'IR', city: 'Kerman' },
{ iata: 'ZAH', icao: 'OIZH', name: 'Zahedan Intl', nameKo: '자헤단 국제공항', lat: 29.4757, lng: 60.9062, type: 'medium', country: 'IR', city: 'Zahedan' },
// ── 이란 군사기지 (Iran Military) ──
{ iata: '', icao: 'OIFH', name: 'Isfahan (Haft) AFB', nameKo: '이스파한 군사기지', lat: 32.5669, lng: 51.6916, type: 'military', country: 'IR', city: 'Isfahan' },
{ iata: '', icao: 'OIIA', name: 'Tehran Doshan Tappeh AFB', nameKo: '도샨 타페 공군기지', lat: 35.7030, lng: 51.4750, type: 'military', country: 'IR', city: 'Tehran' },
{ iata: '', icao: 'OINR', name: 'Nojeh AFB (Hamadan)', nameKo: '노제 공군기지', lat: 34.8919, lng: 48.2914, type: 'military', country: 'IR', city: 'Hamadan' },
{ iata: '', icao: 'OIBJ', name: 'Bandar Abbas (Havadarya) NAS', nameKo: '반다르 아바스 해군항공기지', lat: 27.1583, lng: 56.1725, type: 'military', country: 'IR', city: 'Bandar Abbas' },
{ iata: '', icao: 'OICC', name: 'Tabriz (Shahid Fakouri) AFB', nameKo: '타브리즈 공군기지', lat: 38.1500, lng: 46.2500, type: 'military', country: 'IR', city: 'Tabriz' },
// ── 이라크 (Iraq) ──
{ iata: 'BGW', icao: 'ORBI', name: 'Baghdad Intl', nameKo: '바그다드 국제공항', lat: 33.2625, lng: 44.2346, type: 'large', country: 'IQ', city: 'Baghdad' },
{ iata: 'BSR', icao: 'ORMM', name: 'Basra Intl', nameKo: '바스라 국제공항', lat: 30.5491, lng: 47.6621, type: 'medium', country: 'IQ', city: 'Basra' },
{ iata: 'EBL', icao: 'ORER', name: 'Erbil Intl', nameKo: '에르빌 국제공항', lat: 36.2376, lng: 43.9632, type: 'medium', country: 'IQ', city: 'Erbil' },
{ iata: '', icao: 'ORAA', name: 'Al Asad Airbase', nameKo: '알 아사드 공군기지', lat: 33.7856, lng: 42.4412, type: 'military', country: 'IQ', city: 'Anbar' },
{ iata: '', icao: 'ORBD', name: 'Balad (Al Bakr) AB', nameKo: '발라드 공군기지', lat: 33.9402, lng: 44.3615, type: 'military', country: 'IQ', city: 'Balad' },
// ── 이스라엘 (Israel) ──
{ iata: 'TLV', icao: 'LLBG', name: 'Ben Gurion Intl', nameKo: '벤 구리온 국제공항', lat: 32.0114, lng: 34.8867, type: 'large', country: 'IL', city: 'Tel Aviv' },
{ iata: '', icao: 'LLNV', name: 'Nevatim AFB', nameKo: '네바팀 공군기지', lat: 31.2083, lng: 34.9389, type: 'military', country: 'IL', city: 'Be\'er Sheva' },
{ iata: '', icao: 'LLRM', name: 'Ramon AFB', nameKo: '라몬 공군기지', lat: 30.7761, lng: 34.6667, type: 'military', country: 'IL', city: 'Negev' },
{ iata: '', icao: 'LLHA', name: 'Hatzerim AFB', nameKo: '하체림 공군기지', lat: 31.2333, lng: 34.6667, type: 'military', country: 'IL', city: 'Be\'er Sheva' },
// ── UAE ──
{ iata: 'DXB', icao: 'OMDB', name: 'Dubai Intl', nameKo: '두바이 국제공항', lat: 25.2528, lng: 55.3644, type: 'large', country: 'AE', city: 'Dubai' },
{ iata: 'AUH', icao: 'OMAA', name: 'Abu Dhabi Intl', nameKo: '아부다비 국제공항', lat: 24.4430, lng: 54.6511, type: 'large', country: 'AE', city: 'Abu Dhabi' },
{ iata: 'SHJ', icao: 'OMSJ', name: 'Sharjah Intl', nameKo: '샤르자 국제공항', lat: 25.3286, lng: 55.5172, type: 'medium', country: 'AE', city: 'Sharjah' },
{ iata: '', icao: 'OMAD', name: 'Al Dhafra AFB', nameKo: '알 다프라 공군기지', lat: 24.2483, lng: 54.5481, type: 'military', country: 'AE', city: 'Abu Dhabi' },
// ── 사우디아라비아 (Saudi Arabia) ──
{ iata: 'RUH', icao: 'OERK', name: 'King Khalid Intl', nameKo: '킹 칼리드 국제공항', lat: 24.9576, lng: 46.6988, type: 'large', country: 'SA', city: 'Riyadh' },
{ iata: 'JED', icao: 'OEJN', name: 'King Abdulaziz Intl', nameKo: '킹 압둘아지즈 국제공항', lat: 21.6796, lng: 39.1565, type: 'large', country: 'SA', city: 'Jeddah' },
{ iata: 'DMM', icao: 'OEDF', name: 'King Fahd Intl', nameKo: '킹 파흐드 국제공항', lat: 26.4712, lng: 49.7979, type: 'large', country: 'SA', city: 'Dammam' },
{ iata: '', icao: 'OEPS', name: 'Prince Sultan AB', nameKo: '프린스 술탄 공군기지', lat: 24.0625, lng: 47.5806, type: 'military', country: 'SA', city: 'Al Kharj' },
// ── 카타르 (Qatar) ──
{ iata: 'DOH', icao: 'OTHH', name: 'Hamad Intl', nameKo: '하마드 국제공항', lat: 25.2731, lng: 51.6081, type: 'large', country: 'QA', city: 'Doha' },
{ iata: '', icao: 'OTBH', name: 'Al Udeid AB', nameKo: '알 우데이드 공군기지', lat: 25.1173, lng: 51.3150, type: 'military', country: 'QA', city: 'Doha' },
// ── 바레인 (Bahrain) ──
{ iata: 'BAH', icao: 'OBBI', name: 'Bahrain Intl', nameKo: '바레인 국제공항', lat: 26.2708, lng: 50.6336, type: 'large', country: 'BH', city: 'Manama' },
{ iata: '', icao: 'OBBS', name: 'Isa AB (NSA Bahrain)', nameKo: '이사 공군기지', lat: 26.1572, lng: 50.5911, type: 'military', country: 'BH', city: 'Manama' },
// ── 쿠웨이트 (Kuwait) ──
{ iata: 'KWI', icao: 'OKBK', name: 'Kuwait Intl', nameKo: '쿠웨이트 국제공항', lat: 29.2267, lng: 47.9689, type: 'large', country: 'KW', city: 'Kuwait City' },
{ iata: '', icao: 'OKAJ', name: 'Ali Al Salem AB', nameKo: '알리 알 살렘 공군기지', lat: 29.3467, lng: 47.5211, type: 'military', country: 'KW', city: 'Kuwait' },
// ── 오만 (Oman) ──
{ iata: 'MCT', icao: 'OOMS', name: 'Muscat Intl', nameKo: '무스카트 국제공항', lat: 23.5933, lng: 58.2844, type: 'large', country: 'OM', city: 'Muscat' },
{ iata: '', icao: 'OMTH', name: 'Thumrait AB', nameKo: '튬라이트 공군기지', lat: 17.6660, lng: 54.0246, type: 'military', country: 'OM', city: 'Thumrait' },
// ── 터키 (Turkey) ──
{ iata: 'IST', icao: 'LTFM', name: 'Istanbul Airport', nameKo: '이스탄불 공항', lat: 41.2753, lng: 28.7519, type: 'large', country: 'TR', city: 'Istanbul' },
{ iata: 'ESB', icao: 'LTAC', name: 'Ankara Esenboğa', nameKo: '앙카라 에센보아 공항', lat: 40.1281, lng: 32.9951, type: 'large', country: 'TR', city: 'Ankara' },
{ iata: 'ADA', icao: 'LTAF', name: 'Adana Şakirpaşa', nameKo: '아다나 공항', lat: 36.9822, lng: 35.2804, type: 'medium', country: 'TR', city: 'Adana' },
{ iata: '', icao: 'LTAG', name: 'Incirlik AB', nameKo: '인시를릭 공군기지', lat: 37.0021, lng: 35.4259, type: 'military', country: 'TR', city: 'Adana' },
{ iata: 'DYB', icao: 'LTCC', name: 'Diyarbakır Airport', nameKo: '디야르바키르 공항', lat: 37.8940, lng: 40.2010, type: 'medium', country: 'TR', city: 'Diyarbakır' },
// ── 요르단 (Jordan) ──
{ iata: 'AMM', icao: 'OJAI', name: 'Queen Alia Intl', nameKo: '퀸 알리아 국제공항', lat: 31.7226, lng: 35.9932, type: 'large', country: 'JO', city: 'Amman' },
// ── 레바논 (Lebanon) ──
{ iata: 'BEY', icao: 'OLBA', name: 'BeirutRafic 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: 'DjiboutiAmbouli Intl', nameKo: '지부티 국제공항', lat: 11.5473, lng: 43.1595, type: 'medium', country: 'DJ', city: 'Djibouti' },
{ iata: '', icao: 'HDCL', name: 'Camp Lemonnier', nameKo: '캠프 르모니에 (미군)', lat: 11.5474, lng: 43.1556, type: 'military', country: 'DJ', city: 'Djibouti' },
// ── 예멘 (Yemen) ──
{ iata: 'ADE', icao: 'OYAA', name: 'Aden Intl', nameKo: '아덴 국제공항', lat: 12.8295, lng: 45.0288, type: 'medium', country: 'YE', city: 'Aden' },
{ iata: 'SAH', icao: 'OYSN', name: 'Sana\'a Intl', nameKo: '사나 국제공항', lat: 15.4763, lng: 44.2197, type: 'medium', country: 'YE', city: 'Sana\'a' },
// ── 소말리아 (Somalia) ──
{ iata: 'BSA', icao: 'HCMF', name: 'Bosaso Airport', nameKo: '보사소 공항', lat: 11.2753, lng: 49.1494, type: 'small', country: 'SO', city: 'Bosaso' },
{ iata: 'MGQ', icao: 'HCMM', name: 'Aden Abdulle Intl', nameKo: '모가디슈 국제공항', lat: 2.0144, lng: 45.3047, type: 'medium', country: 'SO', city: 'Mogadishu' },
];

126
src/data/countryLabels.ts Normal file
파일 보기

@ -0,0 +1,126 @@
// ═══ 한글 국가명 라벨 데이터 ═══
// 중동 + 동아시아 지역 국가명 (지도 오버레이용)
export interface CountryLabel {
name: string; // 한글 국가명
nameEn: string; // 영문 (참고용)
lat: number;
lng: number;
rank: number; // 1=대국(큰글씨), 2=중간, 3=소국(작은글씨)
}
export const countryLabels: CountryLabel[] = [
// ── 중동 · 서아시아 ──
{ name: '이란', nameEn: 'Iran', lat: 32.5, lng: 53.7, rank: 1 },
{ name: '이라크', nameEn: 'Iraq', lat: 33.2, lng: 43.7, rank: 1 },
{ name: '사우디아라비아', nameEn: 'Saudi Arabia', lat: 24.0, lng: 45.0, rank: 1 },
{ name: '튀르키예', nameEn: 'Turkey', lat: 39.0, lng: 35.2, rank: 1 },
{ name: '이집트', nameEn: 'Egypt', lat: 26.8, lng: 30.8, rank: 1 },
{ name: '시리아', nameEn: 'Syria', lat: 35.0, lng: 38.5, rank: 2 },
{ name: '요르단', nameEn: 'Jordan', lat: 31.3, lng: 36.5, rank: 2 },
{ name: '레바논', nameEn: 'Lebanon', lat: 33.9, lng: 35.9, rank: 3 },
{ name: '이스라엘', nameEn: 'Israel', lat: 31.5, lng: 34.8, rank: 3 },
{ name: '쿠웨이트', nameEn: 'Kuwait', lat: 29.3, lng: 47.5, rank: 3 },
{ name: '바레인', nameEn: 'Bahrain', lat: 26.07, lng: 50.55, rank: 3 },
{ name: '카타르', nameEn: 'Qatar', lat: 25.3, lng: 51.2, rank: 3 },
{ name: 'UAE', nameEn: 'UAE', lat: 24.0, lng: 54.0, rank: 2 },
{ name: '오만', nameEn: 'Oman', lat: 21.5, lng: 57.0, rank: 2 },
{ name: '예멘', nameEn: 'Yemen', lat: 15.6, lng: 48.5, rank: 2 },
{ name: '아프가니스탄', nameEn: 'Afghanistan', lat: 33.9, lng: 67.7, rank: 1 },
{ name: '파키스탄', nameEn: 'Pakistan', lat: 30.4, lng: 69.3, rank: 1 },
{ name: '투르크메니스탄', nameEn: 'Turkmenistan', lat: 39.0, lng: 59.6, rank: 2 },
{ name: '우즈베키스탄', nameEn: 'Uzbekistan', lat: 41.4, lng: 64.6, rank: 2 },
{ name: '아르메니아', nameEn: 'Armenia', lat: 40.1, lng: 44.5, rank: 3 },
{ name: '아제르바이잔', nameEn: 'Azerbaijan', lat: 40.4, lng: 49.9, rank: 3 },
{ name: '조지아', nameEn: 'Georgia', lat: 42.3, lng: 43.4, rank: 3 },
{ name: '수단', nameEn: 'Sudan', lat: 16.0, lng: 30.2, rank: 2 },
{ name: '에리트레아', nameEn: 'Eritrea', lat: 15.2, lng: 39.8, rank: 3 },
{ name: '에티오피아', nameEn: 'Ethiopia', lat: 9.1, lng: 40.5, rank: 1 },
{ name: '소말리아', nameEn: 'Somalia', lat: 5.2, lng: 46.2, rank: 2 },
{ name: '지부티', nameEn: 'Djibouti', lat: 11.6, lng: 43.1, rank: 3 },
{ name: '리비아', nameEn: 'Libya', lat: 27.0, lng: 17.2, rank: 2 },
{ name: '키프로스', nameEn: 'Cyprus', lat: 35.1, lng: 33.4, rank: 3 },
// ── 페르시아만 해역 라벨 ──
{ name: '페르시아만', nameEn: 'Persian Gulf', lat: 27.0, lng: 51.5, rank: 2 },
{ name: '호르무즈 해협', nameEn: 'Strait of Hormuz', lat: 26.56, lng: 56.25, rank: 3 },
{ name: '오만만', nameEn: 'Gulf of Oman', lat: 24.5, lng: 58.5, rank: 3 },
{ name: '홍해', nameEn: 'Red Sea', lat: 20.0, lng: 38.5, rank: 2 },
{ name: '아덴만', nameEn: 'Gulf of Aden', lat: 12.5, lng: 47.0, rank: 3 },
{ name: '아라비아해', nameEn: 'Arabian Sea', lat: 16.0, lng: 62.0, rank: 2 },
{ name: '카스피해', nameEn: 'Caspian Sea', lat: 41.0, lng: 51.0, rank: 2 },
{ name: '지중해', nameEn: 'Mediterranean Sea', lat: 35.0, lng: 25.0, rank: 2 },
// ── 주요 도시 (이란) ──
{ name: '테헤란', nameEn: 'Tehran', lat: 35.69, lng: 51.39, rank: 2 },
{ name: '이스파한', nameEn: 'Isfahan', lat: 32.65, lng: 51.68, rank: 3 },
{ name: '타브리즈', nameEn: 'Tabriz', lat: 38.08, lng: 46.29, rank: 3 },
{ name: '시라즈', nameEn: 'Shiraz', lat: 29.59, lng: 52.58, rank: 3 },
{ name: '마슈하드', nameEn: 'Mashhad', lat: 36.30, lng: 59.60, rank: 3 },
{ name: '반다르아바스', nameEn: 'Bandar Abbas', lat: 27.18, lng: 56.28, rank: 3 },
{ name: '부셰르', nameEn: 'Bushehr', lat: 28.97, lng: 50.84, rank: 3 },
{ name: '나탄즈', nameEn: 'Natanz', lat: 33.51, lng: 51.92, rank: 3 },
{ name: '아바즈', nameEn: 'Ahvaz', lat: 31.32, lng: 48.67, rank: 3 },
{ name: '케르만', nameEn: 'Kerman', lat: 30.28, lng: 57.08, rank: 3 },
{ name: '케슘섬', nameEn: 'Qeshm Island', lat: 26.85, lng: 55.90, rank: 3 },
{ name: '하르그섬', nameEn: 'Kharg Island', lat: 29.24, lng: 50.31, rank: 3 },
// ── 주요 도시 (중동 기타) ──
{ name: '바그다드', nameEn: 'Baghdad', lat: 33.31, lng: 44.37, rank: 2 },
{ name: '에르빌', nameEn: 'Erbil', lat: 36.19, lng: 44.01, rank: 3 },
{ name: '다마스쿠스', nameEn: 'Damascus', lat: 33.51, lng: 36.28, rank: 3 },
{ name: '베이루트', nameEn: 'Beirut', lat: 33.89, lng: 35.50, rank: 3 },
{ name: '예루살렘', nameEn: 'Jerusalem', lat: 31.77, lng: 35.23, rank: 3 },
{ name: '텔아비브', nameEn: 'Tel Aviv', lat: 32.08, lng: 34.78, rank: 3 },
{ name: '리야드', nameEn: 'Riyadh', lat: 24.71, lng: 46.67, rank: 2 },
{ name: '두바이', nameEn: 'Dubai', lat: 25.20, lng: 55.27, rank: 3 },
{ name: '아부다비', nameEn: 'Abu Dhabi', lat: 24.45, lng: 54.65, rank: 3 },
{ name: '도하', nameEn: 'Doha', lat: 25.29, lng: 51.53, rank: 3 },
{ name: '앙카라', nameEn: 'Ankara', lat: 39.93, lng: 32.85, rank: 2 },
{ name: '인저를릭', nameEn: 'Incirlik AFB', lat: 37.00, lng: 35.43, rank: 3 },
{ name: '카이로', nameEn: 'Cairo', lat: 30.04, lng: 31.24, rank: 2 },
{ name: '무스카트', nameEn: 'Muscat', lat: 23.59, lng: 58.54, rank: 3 },
{ name: '사나', nameEn: 'Sanaa', lat: 15.37, lng: 44.19, rank: 3 },
{ name: '카불', nameEn: 'Kabul', lat: 34.53, lng: 69.17, rank: 3 },
// ── 주요 군사기지/시설 ──
{ name: '알우데이드 기지', nameEn: 'Al Udeid AB', lat: 25.12, lng: 51.32, rank: 3 },
{ name: '제벨알리 항', nameEn: 'Jebel Ali Port', lat: 25.01, lng: 55.06, rank: 3 },
{ name: '디에고가르시아', nameEn: 'Diego Garcia', lat: -7.32, lng: 72.42, rank: 3 },
// ── 동아시아 ──
{ name: '대한민국', nameEn: 'South Korea', lat: 36.0, lng: 127.8, rank: 1 },
{ name: '북한', nameEn: 'North Korea', lat: 40.0, lng: 127.0, rank: 1 },
{ name: '일본', nameEn: 'Japan', lat: 36.2, lng: 138.3, rank: 1 },
{ name: '중국', nameEn: 'China', lat: 35.9, lng: 104.2, rank: 1 },
{ name: '대만', nameEn: 'Taiwan', lat: 23.7, lng: 121.0, rank: 2 },
{ name: '러시아', nameEn: 'Russia', lat: 55.0, lng: 105.0, rank: 1 },
{ name: '몽골', nameEn: 'Mongolia', lat: 46.9, lng: 103.8, rank: 1 },
{ name: '필리핀', nameEn: 'Philippines', lat: 12.9, lng: 121.8, rank: 2 },
{ name: '베트남', nameEn: 'Vietnam', lat: 14.1, lng: 108.3, rank: 2 },
// ── 동해/서해/남해 해역 ──
{ name: '동해', nameEn: 'East Sea', lat: 38.5, lng: 132.0, rank: 2 },
{ name: '서해(황해)', nameEn: 'Yellow Sea', lat: 35.5, lng: 124.0, rank: 2 },
{ name: '남해', nameEn: 'South Sea', lat: 33.0, lng: 128.0, rank: 3 },
{ name: '동중국해', nameEn: 'East China Sea', lat: 28.0, lng: 126.0, rank: 2 },
{ name: '남중국해', nameEn: 'South China Sea', lat: 15.0, lng: 115.0, rank: 2 },
{ name: '태평양', nameEn: 'Pacific Ocean', lat: 25.0, lng: 155.0, rank: 1 },
// ── 인도양/아프리카 동부 ──
{ name: '인도', nameEn: 'India', lat: 20.6, lng: 79.0, rank: 1 },
{ name: '인도양', nameEn: 'Indian Ocean', lat: 0.0, lng: 75.0, rank: 1 },
];
/** GeoJSON FeatureCollection 변환 */
export function countryLabelsGeoJSON(): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: countryLabels.map((c, i) => ({
type: 'Feature' as const,
id: i,
geometry: { type: 'Point' as const, coordinates: [c.lng, c.lat] },
properties: { name: c.name, nameEn: c.nameEn, rank: c.rank },
})),
};
}

148
src/data/damagedShips.ts Normal file
파일 보기

@ -0,0 +1,148 @@
// ═══ 피격 선박 데이터 ═══
// sampleData.ts의 해상 공격 이벤트와 연동
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
const HOUR = 3600_000;
const DAY = 24 * HOUR;
export interface DamagedShip {
id: string;
name: string;
flag: string; // 국적 코드
type: string; // VLCC, LNG, Container 등
lat: number;
lng: number;
damagedAt: number; // unix ms — 피격 시각
cause: string; // 기뢰, 드론, 대함미사일 등
damage: 'sunk' | 'severe' | 'moderate' | 'minor';
description: string;
eventId: string; // 연관 GeoEvent id
}
export const damagedShips: DamagedShip[] = [
// DAY 3 — 3월 3일
{
id: 'ds-1',
name: 'ATHENA GLORY',
flag: 'GR',
type: 'VLCC',
lat: 26.5500, lng: 56.3500,
damagedAt: T0 + 2 * DAY,
cause: '기뢰 접촉',
damage: 'severe',
description: '그리스 국적 VLCC, 호르무즈 해협 기뢰 접촉. 원유 유출.',
eventId: 'd3-sea1',
},
// DAY 6 — 3월 6일: IRGC 고속정
{
id: 'ds-2',
name: 'IRGC FAST BOAT x4',
flag: 'IR',
type: 'MILITARY',
lat: 26.4000, lng: 56.4000,
damagedAt: T0 + 2 * DAY + 3 * HOUR,
cause: '미 구축함 함포 교전',
damage: 'sunk',
description: 'IRGC 고속정 4척, 미 구축함 교전 중 격침.',
eventId: 'd3-sea2',
},
// DAY 11 — 3월 11일: 호르무즈 기뢰 자폭
{
id: 'ds-3',
name: 'IRGC MINESWEEPER',
flag: 'IR',
type: 'MILITARY',
lat: 26.5667, lng: 56.2500,
damagedAt: T0 + 10 * DAY + 4 * HOUR,
cause: '자체 기뢰 폭발',
damage: 'sunk',
description: 'IRGC 소해정 1척, 자체 배치 기뢰 폭발로 침몰.',
eventId: 'd11-ir1',
},
// DAY 12 — 3월 12일
{
id: 'ds-4',
name: 'SHOWA MARU',
flag: 'JP',
type: 'VLCC',
lat: 26.3500, lng: 56.5000,
damagedAt: T0 + 11 * DAY,
cause: '기뢰 접촉',
damage: 'severe',
description: '일본 국적 VLCC, 호르무즈 해협 기뢰 접촉. 선체 파공, 원유 유출.',
eventId: 'd12-sea1',
},
{
id: 'ds-5',
name: 'SK INNOVATION',
flag: 'KR',
type: 'LNG',
lat: 26.2000, lng: 56.6000,
damagedAt: T0 + 11 * DAY + 2 * HOUR,
cause: 'IRGC 자폭드론',
damage: 'minor',
description: '한국행 LNG 운반선, 드론 2대 피격. 경미 손상.',
eventId: 'd12-sea2',
},
{
id: 'ds-6',
name: 'ATHENS EXPRESS',
flag: 'GR',
type: 'CONTAINER',
lat: 25.8000, lng: 56.8000,
damagedAt: T0 + 11 * DAY + 3 * HOUR,
cause: 'IRGC 누르 대함미사일',
damage: 'moderate',
description: '그리스 컨테이너선, 대함미사일 피격. 화재, 승조원 부상.',
eventId: 'd12-sea3',
},
{
id: 'ds-7',
name: 'IRGC FAST BOAT x5',
flag: 'IR',
type: 'MILITARY',
lat: 26.6000, lng: 56.1000,
damagedAt: T0 + 11 * DAY + 5 * HOUR,
cause: '미 구축함 함포/CIWS',
damage: 'sunk',
description: 'IRGC 고속정 5척, USS 마이클 머피 교전 중 격침.',
eventId: 'd12-sea4',
},
// DAY 12 후반 — 3월 12일 오후
{
id: 'ds-8',
name: 'IRGC MINE LAYER',
flag: 'IR',
type: 'MILITARY',
lat: 26.4500, lng: 56.3500,
damagedAt: T0 + 11 * DAY + 14 * HOUR,
cause: '자체 기뢰 폭발',
damage: 'sunk',
description: 'IRGC 기뢰부설정, 자체 배치 기뢰 접촉 폭발로 침몰. 승조원 12명 사망 추정.',
eventId: 'd12-sea5',
},
{
id: 'ds-9',
name: 'PACIFIC PIONEER',
flag: 'PA',
type: 'BULK',
lat: 26.3000, lng: 56.7000,
damagedAt: T0 + 11 * DAY + 16 * HOUR,
cause: '부유기뢰 접촉',
damage: 'moderate',
description: '파나마 국적 벌크선, 오만만 북부 부유기뢰 접촉. 선수 파공, 느린 침수. 오만 해군 구조.',
eventId: 'd12-sea6',
},
{
id: 'ds-10',
name: 'IRGC FAST BOAT x20+',
flag: 'IR',
type: 'MILITARY',
lat: 26.9800, lng: 56.0800,
damagedAt: T0 + 11 * DAY + 14 * HOUR,
cause: '미 F/A-18F 공습',
damage: 'sunk',
description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.',
eventId: 'd12-us5',
},
];

105
src/data/iranBorder.ts Normal file
파일 보기

@ -0,0 +1,105 @@
// Simplified Iran border polygon (GeoJSON)
// ~60 points tracing the approximate boundary
export const iranBorderGeoJSON: GeoJSON.Feature = {
type: 'Feature',
properties: { name: 'Iran' },
geometry: {
type: 'Polygon',
coordinates: [[
// Northwest — Turkey/Armenia/Azerbaijan border
[44.0, 39.4],
[44.8, 39.7],
[45.5, 39.0],
[46.0, 38.9],
[47.0, 39.2],
[48.0, 38.8],
[48.5, 38.5],
[48.9, 38.4],
// Caspian Sea coast (south shore)
[49.0, 38.4],
[49.5, 37.5],
[50.0, 37.4],
[50.5, 37.0],
[51.0, 36.8],
[51.5, 36.8],
[52.0, 36.9],
[53.0, 36.9],
[53.9, 37.1],
[54.7, 37.3],
[55.4, 37.2],
[56.0, 37.4],
[57.0, 37.4],
[57.4, 37.6],
// Northeast — Turkmenistan border
[58.0, 37.6],
[58.8, 37.6],
[59.3, 37.5],
[60.0, 36.7],
[60.5, 36.5],
[61.0, 36.6],
[61.2, 36.6],
// East — Afghanistan border
[61.2, 35.6],
[61.2, 34.7],
[61.0, 34.0],
[60.5, 33.7],
[60.5, 33.1],
[60.8, 32.2],
[60.8, 31.5],
// Southeast — Pakistan border
[61.7, 31.4],
[61.8, 30.8],
[61.4, 29.8],
[60.9, 29.4],
[60.6, 28.5],
[61.0, 27.2],
[62.0, 26.4],
[63.2, 25.2],
// South coast — Gulf of Oman / Persian Gulf
[61.6, 25.2],
[60.0, 25.3],
[58.5, 25.6],
[57.8, 25.7],
[57.3, 26.0],
[56.4, 26.2],
[56.1, 26.0],
[55.5, 26.0],
[54.8, 26.5],
[54.3, 26.5],
[53.5, 26.6],
[52.5, 27.2],
[51.5, 27.9],
[50.8, 28.3],
[50.5, 28.8],
[50.2, 29.1],
[50.0, 29.3],
[49.5, 29.6],
[49.0, 29.8],
[48.6, 29.9],
// Southwest — Iraq border (Shatt al-Arab and west)
[48.4, 30.4],
[48.0, 30.5],
[47.7, 30.9],
[47.6, 31.4],
[47.1, 31.6],
[46.5, 32.0],
[46.1, 32.2],
[45.6, 32.9],
[45.4, 33.4],
[45.5, 33.9],
[45.6, 34.2],
[45.4, 34.5],
[45.2, 35.0],
[45.1, 35.4],
[45.4, 35.8],
[45.1, 36.0],
[44.8, 36.4],
[44.5, 37.0],
[44.3, 37.5],
[44.2, 38.0],
[44.0, 38.4],
[44.0, 39.0],
[44.0, 39.4], // close polygon
]],
},
};

397
src/data/oilFacilities.ts Normal file
파일 보기

@ -0,0 +1,397 @@
import type { OilFacility } from '../types';
// T0 = 이란 보복 공격 기준시각
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
const HOUR = 3600_000;
// 이란 주요 석유·가스 시설 데이터
// 출처: NIOC, EIA, IEA 공개 데이터 기반
export const iranOilFacilities: OilFacility[] = [
// ═══ 주요 정유시설 (Refineries) ═══
{
id: 'ref-abadan',
name: 'Abadan Refinery',
nameKo: '아바단 정유소',
lat: 30.3358, lng: 48.2870,
type: 'refinery',
capacityBpd: 400_000,
operator: 'NIOC',
description: '이란 최대·최고(最古) 정유시설. 1912년 건설. 일 40만 배럴 처리.',
planned: true,
plannedLabel: 'D+17 B-2 정밀폭격 예정 — 이란 최대 정유능력 무력화 목표',
},
{
id: 'ref-isfahan',
name: 'Isfahan Refinery',
nameKo: '이스파한 정유소',
lat: 32.6100, lng: 51.7300,
type: 'refinery',
capacityBpd: 375_000,
operator: 'NIOC',
description: '이란 2위 정유소. 일 37.5만 배럴 처리. 중부 이란 핵심 시설.',
planned: true,
plannedLabel: 'D+18 F-35 편대 공격 예정 — 중부 정유능력 차단',
},
{
id: 'ref-bandarabbas',
name: 'Bandar Abbas Refinery (Persian Gulf Star)',
nameKo: '반다르아바스 정유소 (페르시안걸프스타르)',
lat: 27.1700, lng: 56.2200,
type: 'refinery',
capacityBpd: 360_000,
operator: 'PGPIC',
description: '2017년 완공 최신 정유소. 가스 응축액 처리. 일 36만 배럴.',
damaged: true,
damagedAt: T0 - 3 * HOUR, // IL airstrike il5 at 09:01 UTC
},
{
id: 'ref-tehran',
name: 'Tehran Refinery',
nameKo: '테헤란 정유소',
lat: 35.5700, lng: 51.4100,
type: 'refinery',
capacityBpd: 250_000,
operator: 'NIOC',
description: '수도 에너지 공급 핵심. 일 25만 배럴.',
planned: true,
plannedLabel: 'D+19 테헤란 에너지 고립 작전 — 수도 연료공급 차단',
},
{
id: 'ref-tabriz',
name: 'Tabriz Refinery',
nameKo: '타브리즈 정유소',
lat: 38.0100, lng: 46.2700,
type: 'refinery',
capacityBpd: 110_000,
operator: 'NIOC',
description: '북서부 이란 주요 정유소. 일 11만 배럴.',
damaged: true,
damagedAt: T0 - 6.5 * HOUR, // US airstrike us12 at 05:31 UTC
},
{
id: 'ref-arak',
name: 'Arak Refinery (Imam Khomeini)',
nameKo: '아라크 정유소 (이맘 호메이니)',
lat: 34.0700, lng: 49.7100,
type: 'refinery',
capacityBpd: 250_000,
operator: 'NIOC',
description: '중부 이란 전략적 위치. 일 25만 배럴.',
planned: true,
plannedLabel: 'D+18 F-15E 공격 예정 — 중부 연료보급 거점 파괴',
},
{
id: 'ref-shiraz',
name: 'Shiraz Refinery',
nameKo: '시라즈 정유소',
lat: 29.5500, lng: 52.4800,
type: 'refinery',
capacityBpd: 60_000,
operator: 'NIOC',
description: '남부 이란 정유소. 일 6만 배럴.',
},
{
id: 'ref-lavan',
name: 'Lavan Refinery',
nameKo: '라반 정유소',
lat: 26.8100, lng: 53.3500,
type: 'refinery',
capacityBpd: 60_000,
operator: 'NIOC',
description: '라반섬 해상 정유소. 일 6만 배럴. 페르시아만 원유 수출.',
},
// ═══ 주요 유전 (Oil Fields) ═══
{
id: 'oil-ahwaz',
name: 'Ahwaz-Asmari Oil Field',
nameKo: '아흐바즈-아스마리 유전',
lat: 31.3200, lng: 48.6700,
type: 'oilfield',
capacityBpd: 750_000,
reservesBbl: 25.5,
operator: 'NIOC',
description: '이란 최대 유전. 확인매장량 255억 배럴. 후제스탄 주.',
},
{
id: 'oil-marunfield',
name: 'Marun Oil Field',
nameKo: '마룬 유전',
lat: 31.6500, lng: 49.2000,
type: 'oilfield',
capacityBpd: 520_000,
reservesBbl: 16.0,
operator: 'NIOC',
description: '이란 2위 유전. 확인매장량 160억 배럴.',
},
{
id: 'oil-gachsaran',
name: 'Gachsaran Oil Field',
nameKo: '가치사란 유전',
lat: 30.3600, lng: 50.8000,
type: 'oilfield',
capacityBpd: 560_000,
reservesBbl: 15.0,
operator: 'NIOC',
description: '이란 3위 유전. 매장량 150억 배럴. 자그로스 산맥 서남.',
},
{
id: 'oil-agha-jari',
name: 'Aghajari Oil Field',
nameKo: '아가자리 유전',
lat: 30.7500, lng: 49.8300,
type: 'oilfield',
capacityBpd: 200_000,
reservesBbl: 14.0,
operator: 'NIOC',
description: '역사적 대형 유전. 매장량 140억 배럴.',
},
{
id: 'oil-karangoil',
name: 'Karanj Oil Field',
nameKo: '카란즈 유전',
lat: 31.9500, lng: 49.0500,
type: 'oilfield',
capacityBpd: 180_000,
reservesBbl: 5.0,
operator: 'NIOC',
description: '후제스탄 주 주요 유전. 매장량 50억 배럴.',
},
{
id: 'oil-yadavaran',
name: 'Yadavaran Oil Field',
nameKo: '야다바란 유전',
lat: 31.0200, lng: 47.8500,
type: 'oilfield',
capacityBpd: 300_000,
reservesBbl: 17.0,
operator: 'NIOC / Sinopec',
description: '이라크 국경 인근 초대형 유전. 매장량 170억 배럴. 중국 합작.',
},
{
id: 'oil-azadegan',
name: 'Azadegan Oil Field',
nameKo: '아자데간 유전',
lat: 31.5000, lng: 47.6000,
type: 'oilfield',
capacityBpd: 220_000,
reservesBbl: 33.0,
operator: 'NIOC',
description: '이란 최대 미개발 유전. 매장량 330억 배럴.',
},
// ═══ 가스전 (Gas Fields) ═══
{
id: 'gas-southpars',
name: 'South Pars Gas Field',
nameKo: '사우스파르스 가스전',
lat: 27.0000, lng: 52.0000,
type: 'gasfield',
capacityMcfd: 20_000,
reservesTcf: 500,
operator: 'Pars Oil & Gas Co.',
description: '세계 최대 가스전 (카타르 노스돔과 공유). 매장량 500조 입방피트. 이란 가스 수출 핵심.',
},
{
id: 'gas-northpars',
name: 'North Pars Gas Field',
nameKo: '노스파르스 가스전',
lat: 27.5000, lng: 52.5000,
type: 'gasfield',
capacityMcfd: 2_500,
reservesTcf: 50,
operator: 'NIOC',
description: '페르시아만 해상 가스전. 매장량 50조 입방피트.',
},
{
id: 'gas-kish',
name: 'Kish Gas Field',
nameKo: '키시 가스전',
lat: 26.5500, lng: 53.9800,
type: 'gasfield',
capacityMcfd: 3_000,
reservesTcf: 58,
operator: 'NIOC',
description: '키시섬 인근 해상 가스전. 매장량 58조 입방피트.',
},
// ═══ 수출 터미널 (Export Terminals) ═══
{
id: 'term-kharg',
name: 'Kharg Island Terminal',
nameKo: '하르그섬 수출터미널',
lat: 29.2300, lng: 50.3200,
type: 'terminal',
capacityBpd: 5_000_000,
operator: 'NIOC',
description: '이란 원유 수출의 90% 처리. 일 500만 배럴 수출 능력. 전략적 최핵심 시설.',
planned: true,
plannedLabel: 'D+16 최우선 타격 예정 — 이란 원유 수출 90% 차단 목표. B-2·F-35 합동 공격.',
},
{
id: 'term-lavan',
name: 'Lavan Island Terminal',
nameKo: '라반섬 수출터미널',
lat: 26.7900, lng: 53.3600,
type: 'terminal',
capacityBpd: 200_000,
operator: 'NIOC',
description: '라반섬 원유 수출터미널. 일 20만 배럴.',
},
{
id: 'term-jask',
name: 'Jask Oil Terminal',
nameKo: '자스크 수출터미널',
lat: 25.6400, lng: 57.7700,
type: 'terminal',
capacityBpd: 1_000_000,
operator: 'NIOC',
description: '호르무즈 해협 우회 수출터미널. 2021년 개장. 일 100만 배럴.',
planned: true,
plannedLabel: 'D+17 우회 수출로 차단 작전 — 오만만 경유 원유수출 봉쇄',
},
// ═══ 석유화학단지 (Petrochemical) ═══
{
id: 'petro-assaluyeh',
name: 'Assaluyeh Petrochemical Complex',
nameKo: '아살루예 석유화학단지',
lat: 27.4800, lng: 52.6100,
type: 'petrochemical',
operator: 'NPC',
description: '사우스파르스 육상 가스처리 허브. 세계 최대급 석유화학 단지.',
planned: true,
plannedLabel: 'D+20 가스수출 차단 작전 — 사우스파르스 육상 처리시설 타격',
},
{
id: 'petro-mahshahr',
name: 'Mahshahr Petrochemical Zone',
nameKo: '마흐샤르 석유화학단지',
lat: 30.4600, lng: 49.1700,
type: 'petrochemical',
operator: 'NPC',
description: '반다르 이맘 호메이니 인근 대규모 석유화학 단지.',
},
// ═══ 담수화 시설 (Desalination Plants) — 호르무즈 해협 인근 ═══
{
id: 'desal-jebel-ali',
name: 'Jebel Ali Desalination Plant',
nameKo: '제벨알리 담수화시설',
lat: 25.0547, lng: 55.0272,
type: 'desalination',
capacityMgd: 636,
operator: 'DEWA',
description: '세계 최대 담수화시설. 일 636만 갤런. 두바이 수돗물 98% 공급.',
},
{
id: 'desal-taweelah',
name: 'Taweelah Desalination Plant',
nameKo: '타위라 담수화시설',
lat: 24.6953, lng: 54.7428,
type: 'desalination',
capacityMgd: 200,
operator: 'EWEC / ACWA Power',
description: '세계 최대 RO 담수화시설. 일 2억 갤런. 아부다비 핵심 수자원.',
},
{
id: 'desal-fujairah',
name: 'Fujairah Desalination Plant',
nameKo: '푸자이라 담수화시설',
lat: 25.1288, lng: 56.3264,
type: 'desalination',
capacityMgd: 130,
operator: 'FEWA',
description: '호르무즈 해협 동측. 일 1.3억 갤런. 동부 에미리트 수자원.',
},
{
id: 'desal-sohar',
name: 'Sohar Desalination Plant',
nameKo: '소하르 담수화시설',
lat: 24.3476, lng: 56.7492,
type: 'desalination',
capacityMgd: 63,
operator: 'Sohar Power / Suez',
description: '오만 북부 산업지대 수자원. 일 6,300만 갤런.',
},
{
id: 'desal-barka',
name: 'Barka Desalination Plant',
nameKo: '바르카 담수화시설',
lat: 23.6850, lng: 57.8900,
type: 'desalination',
capacityMgd: 42,
operator: 'Oman Power & Water',
description: '오만 수도 무스카트 인근. 일 4,200만 갤런.',
},
{
id: 'desal-ghubrah',
name: 'Al Ghubrah Desalination Plant',
nameKo: '알구브라 담수화시설',
lat: 23.6000, lng: 58.4200,
type: 'desalination',
capacityMgd: 68,
operator: 'PAEW',
description: '무스카트 시내 위치. 오만 최대 담수화시설. 일 6,800만 갤런.',
},
{
id: 'desal-ras-al-khair',
name: 'Ras Al Khair Desalination Plant',
nameKo: '라스 알 카이르 담수화시설',
lat: 27.1500, lng: 49.2300,
type: 'desalination',
capacityMgd: 228,
operator: 'SWCC',
description: '세계 최대 하이브리드 담수화시설. 사우디 동부 해안. 일 2.28억 갤런.',
},
{
id: 'desal-jubail',
name: 'Jubail Desalination Plant',
nameKo: '주바일 담수화시설',
lat: 26.9598, lng: 49.5687,
type: 'desalination',
capacityMgd: 400,
operator: 'SWCC',
description: '사우디 동부 주바일 산업도시. 일 4억 갤런. 리야드까지 송수.',
},
{
id: 'desal-hidd',
name: 'Al Hidd Desalination Plant',
nameKo: '알 히드 담수화시설',
lat: 26.1500, lng: 50.6600,
type: 'desalination',
capacityMgd: 90,
operator: 'EWA Bahrain',
description: '바레인 주요 수자원. 일 9,000만 갤런. 국가 물 수요 80% 담당.',
},
{
id: 'desal-ras-laffan',
name: 'Ras Laffan Desalination Plant',
nameKo: '라스 라판 담수화시설',
lat: 25.9140, lng: 51.5260,
type: 'desalination',
capacityMgd: 63,
operator: 'Kahramaa',
description: '카타르 북부 LNG 허브 인접. 일 6,300만 갤런.',
},
{
id: 'desal-azzour',
name: 'Az-Zour Desalination Plant',
nameKo: '아즈주르 담수화시설',
lat: 28.7200, lng: 48.3700,
type: 'desalination',
capacityMgd: 107,
operator: 'MEW Kuwait',
description: '쿠웨이트 남부. 일 1.07억 갤런. 쿠웨이트시 수자원.',
},
{
id: 'desal-bandarabbas',
name: 'Bandar Abbas Desalination',
nameKo: '반다르아바스 담수화시설',
lat: 27.1800, lng: 56.2700,
type: 'desalination',
capacityMgd: 18,
operator: 'ABFA Iran',
description: '이란 호르무즈간 주. 일 1,800만 갤런. 이란 최대 해수담수화.',
},
];

1495
src/data/sampleData.ts Normal file

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

9
src/env.d.ts vendored Normal file
파일 보기

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SPG_API_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

36
src/hooks/useMonitor.ts Normal file
파일 보기

@ -0,0 +1,36 @@
import { useState, useCallback, useRef, useEffect } from 'react';
const TICK_INTERVAL = 1000; // update every 1 second in live mode
export interface MonitorState {
currentTime: number; // always Date.now()
historyMinutes: number; // how far back to show (default 60)
}
export function useMonitor() {
const [state, setState] = useState<MonitorState>({
currentTime: Date.now(),
historyMinutes: 60,
});
const intervalRef = useRef<number | null>(null);
// Start ticking immediately
useEffect(() => {
intervalRef.current = window.setInterval(() => {
setState(prev => ({ ...prev, currentTime: Date.now() }));
}, TICK_INTERVAL);
return () => {
if (intervalRef.current !== null) clearInterval(intervalRef.current);
};
}, []);
const setHistoryMinutes = useCallback((minutes: number) => {
setState(prev => ({ ...prev, historyMinutes: minutes }));
}, []);
const startTime = state.currentTime - state.historyMinutes * 60_000;
const endTime = state.currentTime;
return { state, startTime, endTime, setHistoryMinutes };
}

85
src/hooks/useReplay.ts Normal file
파일 보기

@ -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<ReplayState>({
isPlaying: false,
currentTime: REPLAY_START,
startTime: REPLAY_START,
endTime: REPLAY_END,
speed: 1,
});
const intervalRef = useRef<number | null>(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 };
}

29
src/index.css Normal file
파일 보기

@ -0,0 +1,29 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0a1a;
--bg-secondary: #111127;
--bg-card: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #888;
--accent: #3b82f6;
--danger: #ef4444;
--warning: #eab308;
}
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;
}

10
src/main.tsx Normal file
파일 보기

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

파일 보기

@ -0,0 +1,310 @@
import type { Aircraft, AircraftCategory } from '../types';
// Airplanes.live API - specializes in military aircraft tracking
const ADSBX_BASE = 'https://api.airplanes.live/v2';
// Known military type codes
const MILITARY_TYPES: Record<string, AircraftCategory> = {
'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<Aircraft[]> {
try {
// Airplanes.live military endpoint - Middle East area
const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url);
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<Aircraft[]> {
try {
const url = `${ADSBX_BASE}/mil`;
const res = await fetch(url);
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<string, { ac: Aircraft[]; ts: number }>();
let krInitialDone = false;
let krQueryIdx = 0;
let krInitPromise: Promise<void> | null = null;
async function doKrInitialLoad(): Promise<void> {
console.log('Airplanes.live Korea: initial load...');
for (let i = 0; i < KR_QUERIES.length; i++) {
try {
if (i > 0) await delay(800);
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<Aircraft[]> {
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<string>();
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<string, { ac: Aircraft[]; ts: number }>();
const CACHE_TTL = 60_000; // 60s per region cache
let initialLoadDone = false;
let queryIndex = 0;
let initialLoadPromise: Promise<void> | null = null;
function delay(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`;
const res = await fetch(url);
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<void> {
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(800);
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<Aircraft[]> {
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<string>();
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<Aircraft[]> {
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<Aircraft[]> {
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 [];
}
}

38
src/services/airports.ts Normal file
파일 보기

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

58
src/services/api.ts Normal file
파일 보기

@ -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<ApiConfig>): Promise<GeoEvent[]> { // 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<ApiConfig>): Promise<SensorLog[]> { // 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<ApiConfig> = {},
) {
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;
}
},
};
}

43
src/services/cctv.ts Normal file
파일 보기

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

321
src/services/celestrak.ts Normal file
파일 보기

@ -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<Satellite[]> {
// Return cache if fresh
if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) {
return satCache.sats;
}
const allSats: Satellite[] = [];
const seenIds = new Set<number>();
// Fetch TLE groups from CelesTrak sequentially (avoid hammering)
for (const { group, category } of CELESTRAK_GROUPS) {
try {
const url = `https://celestrak.org/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<Satellite[]> {
if (satCacheKorea && Date.now() - satCacheKorea.ts < SAT_CACHE_TTL) {
return satCacheKorea.sats;
}
const allSats: Satellite[] = [];
const seenIds = new Set<number>();
for (const { group, category } of CELESTRAK_GROUPS) {
try {
const url = `https://celestrak.org/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<number, ReturnType<typeof satellite.twoline2satrec>>();
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<number, { time: number; track: [number, number][] }>();
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);
}

파일 보기

@ -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<CoastGuardType, string> = {
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 },
];

135
src/services/infra.ts Normal file
파일 보기

@ -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<PowerFacility[]> {
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' },
];
}

122
src/services/koreaEez.ts Normal file
파일 보기

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

605
src/services/navWarning.ts Normal file
파일 보기

@ -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<NavWarningLevel, string> = {
danger: '위험',
caution: '주의',
info: '정보',
};
export { LEVEL_LABEL as NW_LEVEL_LABEL };
const ORG_LABEL: Record<TrainingOrg, string> = {
'해군': '해군 훈련구역',
'해병대': '해병대 훈련구역',
'공군': '공군 훈련구역',
'육군': '육군 훈련구역',
'해경': '해양경찰청 훈련구역',
'국과연': '국방과학연구소 훈련구역',
};
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: '육군',
},
];

586
src/services/opensky.ts Normal file
파일 보기

@ -0,0 +1,586 @@
import type { Aircraft, AircraftCategory } from '../types';
// OpenSky Network API - free tier, no auth needed for basic queries
const OPENSKY_BASE = 'https://opensky-network.org/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<string, AircraftCategory> = {
'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,
};
});
}
export async function fetchAircraftOpenSky(): Promise<Aircraft[]> {
try {
const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`OpenSky ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
} 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<Aircraft[]> {
try {
const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`OpenSky Korea ${res.status}`);
const data = await res.json();
return parseOpenSkyResponse(data);
} 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 },
];
}

756
src/services/osint.ts Normal file
파일 보기

@ -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<OsintItem[]> {
const query = encodeURIComponent(keywords);
const url = `/api/gdelt/api/v2/doc/doc?query=${query}&mode=ArtList&maxrecords=30&format=json&sort=DateDesc&timespan=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<string, string>, 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<OsintItem[]> {
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<OsintItem[]> {
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<OsintItem[]> {
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) });
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<OsintItem[]> {
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<string>();
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
}

61
src/services/piracy.ts Normal file
파일 보기

@ -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<PiracyZone['level'], string> = {
critical: '#ef4444',
high: '#f97316',
moderate: '#eab308',
};
export const PIRACY_LEVEL_LABEL: Record<PiracyZone['level'], string> = {
critical: '최고위험',
high: '고위험',
moderate: '주의',
};

451
src/services/propagation.ts Normal file
파일 보기

@ -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<string, WP[]> = {
// ── 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<string, WP[]> = {
// 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;
});
}

761
src/services/ships.ts Normal file
파일 보기

@ -0,0 +1,761 @@
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,
};
// How far back to look for vessel positions (seconds)
const SINCE_SECONDS = 3600; // last 1 hour
// MMSI country prefix → flag code (MID = Maritime Identification Digits)
const MMSI_FLAG_MAP: Record<string, string> = {
'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<string, { flag: string; category: ShipCategory }> = {
'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|FORD|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<string, ShipCategory> = {
'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<string, unknown>): Promise<SPGAISTarget[]> {
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<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) {
console.warn('VITE_SPG_USERNAME / VITE_SPG_PASSWORD not set, using sample data');
return [];
}
try {
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
sinceSeconds: SINCE_SECONDS,
minLat: ME_BOUNDS.minLat,
maxLat: ME_BOUNDS.maxLat,
minLong: ME_BOUNDS.minLng,
maxLong: ME_BOUNDS.maxLng,
});
return targets
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0)
.map(parseAISTarget);
} catch (err) {
console.warn('S&P AIS API (GetTargetsInAreaEnhanced) failed:', err);
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<Ship[]> {
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<Ship[]> {
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<Ship[]> {
// 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 — separate data pipeline
// ═══════════════════════════════════════
// S&P AIS API for Korea region (Vladivostok → South China Sea)
async function fetchShipsFromSPGKorea(): Promise<Ship[]> {
if (!SPG_USERNAME || !SPG_PASSWORD) return [];
try {
const targets = await callSPGAPI('GetTargetsInAreaEnhanced', {
sinceSeconds: SINCE_SECONDS,
minLat: KR_BOUNDS.minLat,
maxLat: KR_BOUNDS.maxLat,
minLong: KR_BOUNDS.minLng,
maxLong: KR_BOUNDS.maxLng,
});
return targets
.filter(t => t.Lat != null && t.Lon != null && t.Lat !== 0 && t.Lon !== 0)
.map(parseAISTarget);
} catch (err) {
console.warn('S&P AIS API (Korea region) failed:', err);
return [];
}
}
export async function fetchShipsKorea(): Promise<Ship[]> {
const sample = getSampleShipsKorea();
const real = await fetchShipsFromSPGKorea();
if (real.length > 0) {
console.log(`S&P AIS API: ${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 },
];
}

파일 보기

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

146
src/types.ts Normal file
파일 보기

@ -0,0 +1,146 @@
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
}
// 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';

28
tsconfig.app.json Normal file
파일 보기

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

7
tsconfig.json Normal file
파일 보기

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
파일 보기

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

83
vite.config.ts Normal file
파일 보기

@ -0,0 +1,83 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [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,
},
},
},
})