React 19 + TypeScript + Vite + MapLibre 기반 해양 모니터링 대시보드. 선박 AIS, 항공기, CCTV, 위성, 해양 인프라 등 다중 레이어 지원. ESLint React Compiler 규칙 조정 및 lint 에러 수정 포함.
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
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 [];
|
|
}
|
|
}
|