- GeoEvent.type에 'sea_attack' 추가 + SEA ATK 배지 (#0ea5e9) - damagedShips → GeoEvent 변환, mergedEvents에 합류 - 더미↔API 토글 UI (ReplayControls 배속 우측) - useIranData: dataSource 분기 (dummy=sampleData, api=Backend DB) - API 모드: events/aircraft/osint 시점 범위 조회 (3월1일~오늘) - 중복 방지: API 모드에서 damageEvents 프론트 병합 건너뜀 - fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수
898 lines
46 KiB
TypeScript
898 lines
46 KiB
TypeScript
import { useMemo, useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { GeoEvent, Ship } from '../../types';
|
|
import type { OsintItem } from '../../services/osint';
|
|
import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../services/disasterNews';
|
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
|
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
|
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
|
import { AiChatPanel } from '../korea/AiChatPanel';
|
|
|
|
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[];
|
|
highlightKoreanShips?: boolean;
|
|
onToggleHighlightKorean?: () => void;
|
|
onShipHover?: (mmsi: string | null) => void;
|
|
onShipClick?: (mmsi: string) => void;
|
|
}
|
|
|
|
// ═══ 속보 / 트럼프 발언 + 유가·에너지 뉴스 ═══
|
|
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 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',
|
|
osint: 'OSINT',
|
|
sea_attack: 'SEA ATK',
|
|
};
|
|
|
|
const TYPE_COLORS: Record<GeoEvent['type'], string> = {
|
|
airstrike: 'var(--kcg-event-airstrike)',
|
|
explosion: 'var(--kcg-event-explosion)',
|
|
missile_launch: 'var(--kcg-event-missile)',
|
|
intercept: 'var(--kcg-event-intercept)',
|
|
alert: 'var(--kcg-event-alert)',
|
|
impact: 'var(--kcg-event-impact)',
|
|
osint: 'var(--kcg-event-osint)',
|
|
sea_attack: '#0ea5e9',
|
|
};
|
|
|
|
// MarineTraffic-style ship type classification
|
|
// getMarineTrafficCategory → getMarineTrafficCategory (utils/marineTraffic.ts)로 통합
|
|
|
|
// MarineTraffic-style category colors (labels come from i18n)
|
|
const MT_CATEGORY_COLORS: Record<string, string> = {
|
|
cargo: 'var(--kcg-ship-cargo)',
|
|
tanker: 'var(--kcg-ship-tanker)',
|
|
passenger: 'var(--kcg-ship-passenger)',
|
|
high_speed: 'var(--kcg-ship-highspeed)',
|
|
tug_special: 'var(--kcg-ship-tug)',
|
|
fishing: 'var(--kcg-ship-fishing)',
|
|
pleasure: 'var(--kcg-ship-pleasure)',
|
|
military: 'var(--kcg-ship-military)',
|
|
other: 'var(--kcg-ship-other)',
|
|
unspecified: 'var(--kcg-ship-unknown)',
|
|
};
|
|
|
|
const NEWS_CATEGORY_ICONS: Record<BreakingNews['category'], string> = {
|
|
trump: '\u{1F1FA}\u{1F1F8}',
|
|
oil: '\u{1F6E2}\u{FE0F}',
|
|
diplomacy: '\u{1F310}',
|
|
economy: '\u{1F4CA}',
|
|
};
|
|
|
|
// OSINT category icons (labels come from i18n)
|
|
const OSINT_CAT_ICONS: Record<string, string> = {
|
|
military: '\u{1F3AF}',
|
|
oil: '\u{1F6E2}',
|
|
diplomacy: '\u{1F310}',
|
|
shipping: '\u{1F6A2}',
|
|
nuclear: '\u{2622}',
|
|
maritime_accident: '\u{1F6A8}',
|
|
fishing: '\u{1F41F}',
|
|
maritime_traffic: '\u{1F6A2}',
|
|
general: '\u{1F4F0}',
|
|
};
|
|
|
|
// OSINT category colors
|
|
const OSINT_CAT_COLORS: Record<string, string> = {
|
|
military: '#ef4444',
|
|
oil: '#f59e0b',
|
|
diplomacy: '#8b5cf6',
|
|
shipping: '#06b6d4',
|
|
nuclear: '#f97316',
|
|
maritime_accident: '#ef4444',
|
|
fishing: '#22c55e',
|
|
maritime_traffic: '#3b82f6',
|
|
general: '#6b7280',
|
|
};
|
|
|
|
// NEWS category colors
|
|
const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
|
|
trump: '#ef4444',
|
|
oil: '#f59e0b',
|
|
diplomacy: '#8b5cf6',
|
|
economy: '#3b82f6',
|
|
};
|
|
|
|
const EMPTY_OSINT: OsintItem[] = [];
|
|
const EMPTY_SHIPS: Ship[] = [];
|
|
|
|
function useTimeAgo() {
|
|
const { t } = useTranslation('common');
|
|
return (ts: number): string => {
|
|
const diff = Date.now() - ts;
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return t('time.justNow');
|
|
if (mins < 60) return t('time.minutesAgo', { count: mins });
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return t('time.hoursAgo', { count: hours });
|
|
const days = Math.floor(hours / 24);
|
|
return t('time.daysAgo', { count: days });
|
|
};
|
|
}
|
|
|
|
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
|
|
const { t } = useTranslation(['common', 'events', 'ships']);
|
|
const timeAgo = useTimeAgo();
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships', 'cn-fishing']));
|
|
const toggleCollapse = useCallback((key: string) => {
|
|
setCollapsed(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
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) — reserved for ship status panel
|
|
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],
|
|
);
|
|
void iranMilitaryShips;
|
|
|
|
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">{t('events:news.breakingTitle')}</span>
|
|
</div>
|
|
<div className="breaking-news-list">
|
|
{visibleNews.map(n => {
|
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
|
return (
|
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
|
<div className="breaking-news-top">
|
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
|
</span>
|
|
<span className="breaking-news-time">
|
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
|
</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">{'\u{1F1F0}\u{1F1F7}'}</span>
|
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
|
<span className="area-ship-total text-kcg-danger">{koreanShips.length}{t('common:units.vessels')}</span>
|
|
</div>
|
|
<div className="iran-mil-list">
|
|
{koreanShips.slice(0, 30).map(s => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
const mtColor = MT_CATEGORY_COLORS[cat] || '#888';
|
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
|
return (
|
|
<div
|
|
key={s.mmsi}
|
|
className="iran-mil-item iran-mil-item-interactive"
|
|
onMouseEnter={() => onShipHover?.(s.mmsi)}
|
|
onMouseLeave={() => onShipHover?.(null)}
|
|
onClick={() => onShipClick?.(s.mmsi)}
|
|
>
|
|
<span className="iran-mil-flag">{'\u{1F1F0}\u{1F1F7}'}</span>
|
|
<span className="iran-mil-name">{s.name}</span>
|
|
<span className="iran-mil-cat" style={{ color: mtColor, background: `${mtColor}22` }}>
|
|
{mtLabel}
|
|
</span>
|
|
{s.speed != null && s.speed > 0.5 ? (
|
|
<span className="ml-auto text-[9px] text-kcg-success">{s.speed.toFixed(1)}kn</span>
|
|
) : (
|
|
<span className="ml-auto text-[9px] text-kcg-danger">{t('ships:status.anchored')}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* OSINT Live Feed (live mode) */}
|
|
{isLive && osintFeed.length > 0 && (
|
|
<>
|
|
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('osint-live')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('osint-live') ? '▶' : '▼'}</span>
|
|
<span className="osint-live-dot" />
|
|
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
|
<span className="osint-count">{osintFeed.length}</span>
|
|
</div>
|
|
{!collapsed.has('osint-live') && (
|
|
<div className="osint-list">
|
|
{osintFeed.map(item => {
|
|
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
|
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
|
return (
|
|
<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: catColor }}>
|
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
|
</span>
|
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
|
</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">{t('events:osint.liveTitle')}</span>
|
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Event Log (replay mode) */}
|
|
{!isLive && (
|
|
<>
|
|
<h3>{t('events:log.title')}</h3>
|
|
<div className="event-list">
|
|
{visibleEvents.length === 0 && (
|
|
<div className="event-empty">{t('events:log.noEvents')}</div>
|
|
)}
|
|
{visibleEvents.map(e => {
|
|
const isNew = currentTime - e.timestamp < 86_400_000;
|
|
return (
|
|
<div key={e.id} className="event-item" style={isNew ? { borderLeft: '3px solid var(--kcg-event-impact)' } : 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 className="inline-block rounded-sm bg-[var(--kcg-event-impact)] px-1 mr-1 text-[9px] font-bold text-white">{t('events:log.new')}</span>
|
|
)}
|
|
{e.label}
|
|
</div>
|
|
<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: 'var(--kcg-accent)' }}>
|
|
<div className="breaking-news-header">
|
|
<span className="breaking-flash bg-kcg-accent">{t('events:news.breaking')}</span>
|
|
<span className="breaking-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:news.koreaTitle')}</span>
|
|
</div>
|
|
<div className="breaking-news-list">
|
|
{visibleNewsKR.map(n => {
|
|
const catColor = NEWS_CATEGORY_COLORS[n.category];
|
|
const catIcon = NEWS_CATEGORY_ICONS[n.category];
|
|
const isRecent = currentTime - n.timestamp < 2 * HOUR_MS;
|
|
return (
|
|
<div key={n.id} className={`breaking-news-item${isRecent ? ' breaking-new' : ''}`}>
|
|
<div className="breaking-news-top">
|
|
<span className="breaking-cat-tag" style={{ background: catColor }}>
|
|
{catIcon} {t(`events:news.categoryLabel.${n.category}`)}
|
|
</span>
|
|
<span className="breaking-news-time">
|
|
{new Date(n.timestamp + 9 * 3600_000).toISOString().slice(5, 16).replace('T', ' ')}
|
|
</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" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('kr-ships')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('kr-ships') ? '▶' : '▼'}</span>
|
|
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
|
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
|
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
|
{onToggleHighlightKorean && (dashboardTab as string) === 'iran' && (
|
|
<button
|
|
type="button"
|
|
className={`korean-highlight-toggle ${highlightKoreanShips ? 'active' : ''}`}
|
|
onClick={onToggleHighlightKorean}
|
|
title={highlightKoreanShips ? '지도 강조 끄기' : '지도에서 강조 표시'}
|
|
>
|
|
{highlightKoreanShips ? 'ON' : 'OFF'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!collapsed.has('kr-ships') && koreanShips.length > 0 && (() => {
|
|
const groups: Record<string, Ship[]> = {};
|
|
for (const s of koreanShips) {
|
|
const cat = getMarineTrafficCategory(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 className="flex flex-col gap-0.5 py-1">
|
|
{sorted.map(cat => {
|
|
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
|
const list = groups[cat];
|
|
const moving = list.filter(s => s.speed > 0.5).length;
|
|
const anchored = list.length - moving;
|
|
return (
|
|
<div key={cat} className="font-mono" style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px',
|
|
borderLeft: `3px solid ${mtColor}`,
|
|
}}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
|
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
|
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
|
</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
|
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 중국 선박 현황 */}
|
|
<div className="iran-ship-summary">
|
|
<div className="area-ship-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('cn-ships')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('cn-ships') ? '▶' : '▼'}</span>
|
|
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
|
|
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
|
|
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
|
|
</div>
|
|
{!collapsed.has('cn-ships') && chineseShips.length > 0 && (() => {
|
|
const groups: Record<string, Ship[]> = {};
|
|
for (const s of chineseShips) {
|
|
const cat = getMarineTrafficCategory(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 className="flex flex-col gap-0.5 py-1">
|
|
{fishingCount > 0 && (
|
|
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
|
|
<span className="text-sm">{'\u{1F6A8}'}</span>
|
|
<span className="text-[11px] font-bold font-mono text-kcg-danger">
|
|
{t('ships:shipStatus.chineseFishingAlert', { count: fishingCount })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{sorted.map(cat => {
|
|
const mtColor = MT_CATEGORY_COLORS[cat] || MT_CATEGORY_COLORS.unspecified;
|
|
const mtLabel = t(`ships:mtType.${cat}`, { defaultValue: t('ships:mtType.unspecified') });
|
|
const list = groups[cat];
|
|
const moving = list.filter(s => s.speed > 0.5).length;
|
|
const anchored = list.length - moving;
|
|
return (
|
|
<div key={cat} className="font-mono" style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px',
|
|
borderLeft: `3px solid ${mtColor}`,
|
|
}}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
|
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
|
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
|
</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
|
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 중국 어선 조업 분석 (GC-KCG-2026-001 기반) */}
|
|
{chineseShips.length > 0 && (() => {
|
|
const stats = aggregateFishingStats(chineseShips);
|
|
if (stats.total === 0) return null;
|
|
const gearOrder: FishingGearType[] = ['trawl_pair', 'trawl_single', 'gillnet', 'stow_net', 'purse_seine', 'carrier', 'unknown'];
|
|
|
|
return (
|
|
<div className="iran-ship-summary">
|
|
<div className="area-ship-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('cn-fishing')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('cn-fishing') ? '▶' : '▼'}</span>
|
|
<span className="area-ship-icon">🎣</span>
|
|
<span className="area-ship-title">중국어선 조업분석 ({stats.total}척)</span>
|
|
<span className="area-ship-total" style={{ color: '#ef4444' }}>
|
|
{stats.operating}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>척 조업중</span>
|
|
</span>
|
|
</div>
|
|
{/* 위험 경보 */}
|
|
{!collapsed.has('cn-fishing') && (stats.critical > 0 || stats.high > 0) && (
|
|
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
|
|
<span className="text-sm">🚨</span>
|
|
<span className="text-[10px] font-bold font-mono text-kcg-danger">
|
|
{stats.critical > 0 && `CRITICAL ${stats.critical}척`}
|
|
{stats.critical > 0 && stats.high > 0 && ' · '}
|
|
{stats.high > 0 && `HIGH ${stats.high}척`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* 조업/비조업 요약 + 어구별 분류 */}
|
|
{!collapsed.has('cn-fishing') && (
|
|
<>
|
|
<div className="font-mono" style={{ display: 'flex', gap: 8, padding: '4px 8px', fontSize: 10 }}>
|
|
<span style={{ color: '#ef4444' }}>🔴 조업 {stats.operating}</span>
|
|
<span style={{ color: '#22c55e' }}>🟢 비조업 {stats.idle}</span>
|
|
<span style={{ color: 'var(--kcg-muted)', marginLeft: 'auto' }}>총 {stats.total}척</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 py-1">
|
|
{gearOrder.map(gear => {
|
|
const count = stats.byGear[gear];
|
|
if (!count) return null;
|
|
const info = GEAR_LABELS[gear];
|
|
return (
|
|
<div key={gear} className="font-mono" style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px',
|
|
borderLeft: `3px solid ${info.color}`,
|
|
}}>
|
|
<span style={{ fontSize: 10 }}>{info.icon}</span>
|
|
<span style={{ fontSize: 10, fontWeight: 700, minWidth: 100, color: info.color }}>{info.ko}</span>
|
|
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
|
{count}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>척</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 재난/안전뉴스 */}
|
|
{isLive && (() => {
|
|
const disasterItems = getDisasterNews();
|
|
return (
|
|
<>
|
|
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('disaster-news')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('disaster-news') ? '▶' : '▼'}</span>
|
|
<span style={{ fontSize: 11 }}>🚨</span>
|
|
<span className="osint-title">재난/안전뉴스</span>
|
|
<span className="osint-count">{disasterItems.length}</span>
|
|
<a
|
|
href="https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--kcg-dim)', textDecoration: 'none' }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
안전코리아 ↗
|
|
</a>
|
|
</div>
|
|
{!collapsed.has('disaster-news') && (
|
|
<div className="osint-list" style={{ maxHeight: 310, overflowY: 'auto' }}>
|
|
{disasterItems.map(item => {
|
|
const icon = getDisasterCatIcon(item.category);
|
|
const color = getDisasterCatColor(item.category);
|
|
const isRecent = Date.now() - item.timestamp < 3600_000 * 6;
|
|
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: color }}>
|
|
{icon} {item.category === 'sea' ? '해양' : item.category === 'chemical' ? '화학' : item.category === 'fire' ? '화재' : item.category === 'earthquake' ? '지진' : item.category === 'flood' ? '홍수' : item.category === 'typhoon' ? '태풍' : item.category === 'safety' ? '안전' : '재난'}
|
|
</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>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
|
|
{osintFeed.length > 0 && (
|
|
<>
|
|
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('osint-korea')}>
|
|
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('osint-korea') ? '▶' : '▼'}</span>
|
|
<span className="osint-live-dot" />
|
|
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
|
<span className="osint-count">{(() => {
|
|
const filtered = osintFeed.filter(i => i.category !== 'general' && i.category !== 'oil');
|
|
const seen = new Set<string>();
|
|
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>
|
|
{!collapsed.has('osint-korea') && (
|
|
<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 catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
|
const catIcon = OSINT_CAT_ICONS[item.category] || OSINT_CAT_ICONS.general;
|
|
const isRecent = Date.now() - item.timestamp < 3600_000;
|
|
return (
|
|
<a
|
|
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: catColor }}>
|
|
{catIcon} {t(`events:osint.categoryLabel.${item.category}`, { defaultValue: t('events:osint.categoryLabel.general') })}
|
|
</span>
|
|
{item.language === 'ko' && <span className="osint-lang-tag">KR</span>}
|
|
<span className="osint-time">{timeAgo(item.timestamp)}</span>
|
|
</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">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
|
<span className="osint-loading">{t('events:osint.loading')}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* AI 해양분석 챗 — 한국 탭 전용 */}
|
|
{dashboardTab === 'korea' && (
|
|
<AiChatPanel
|
|
ships={ships}
|
|
koreanShipCount={koreanShips.length}
|
|
chineseShipCount={chineseShips.length}
|
|
totalShipCount={ships.length}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|