kcg-monitoring/frontend/src/components/korea/KoreaDashboard.tsx
htlee 1bf70f46ac feat: 분석 용어/색상 통일 + 경량 분석 + 항적 미니맵
- AI분석/현장분석/보고서 위험도 용어 통일 (HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL)
- 공통 riskMapping.ts: ALERT_COLOR/EMOJI/LEVELS, RISK_TO_ALERT, STATS_KEY_MAP
- deck.gl 오버레이 색상 현장분석 팔레트로 통일
- Python 경량 분석: 파이프라인 미통과 412* 선박에 위치 기반 간이 AnalysisResult 생성
- 현장분석 fallback 제거: classifyStateFallback/classifyFishingZone → Python 결과 전용
- 보고서 위험 평가: Python riskCounts 실데이터 기반으로 전면 교체
- 현장분석 우측 패널: 항적 미니맵 (72시간, fetchVesselTrack API)
- 현장분석 좌측 패널: 위험도 점수 기준 섹션 추가
2026-03-25 12:39:22 +09:00

451 lines
20 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
import { KoreaMap } from './KoreaMap';
import { FieldAnalysisModal } from './FieldAnalysisModal';
import { ReportModal } from './ReportModal';
import { OpsGuideModal } from './OpsGuideModal';
import type { OpsRoute } from './OpsGuideModal';
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
import { EventLog } from '../common/EventLog';
import { LiveControls } from '../common/LiveControls';
import { ReplayControls } from '../common/ReplayControls';
import { TimelineSlider } from '../common/TimelineSlider';
import { useKoreaData } from '../../hooks/useKoreaData';
import { useVesselAnalysis } from '../../hooks/useVesselAnalysis';
import { useGroupPolygons } from '../../hooks/useGroupPolygons';
import { useKoreaFilters } from '../../hooks/useKoreaFilters';
import { useSharedFilters } from '../../hooks/useSharedFilters';
import { EAST_ASIA_PORTS } from '../../data/ports';
import { KOREAN_AIRPORTS } from '../../services/airports';
import { MILITARY_BASES } from '../../data/militaryBases';
import { GOV_BUILDINGS } from '../../data/govBuildings';
import { KOREA_WIND_FARMS } from '../../data/windFarms';
import { NK_LAUNCH_SITES } from '../../data/nkLaunchSites';
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
import { COAST_GUARD_FACILITIES } from '../../services/coastGuard';
import { NAV_WARNINGS } from '../../services/navWarning';
import { PIRACY_ZONES } from '../../services/piracy';
import { KOREA_SUBMARINE_CABLES } from '../../services/submarineCable';
import { HAZARD_FACILITIES } from '../../data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../../data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../../data/jpFacilities';
type DashboardTab = 'iran' | 'korea';
interface ReplayState {
isPlaying: boolean;
speed: number;
startTime: number;
endTime: number;
currentTime: number;
}
interface ReplayControls {
state: ReplayState;
play: () => void;
pause: () => void;
reset: () => void;
setSpeed: (s: number) => void;
setRange: (s: number, e: number) => void;
seek: (t: number) => void;
}
interface MonitorState {
currentTime: number;
historyMinutes: number;
}
interface MonitorControls {
state: MonitorState;
setHistoryMinutes: (m: number) => void;
}
export interface KoreaDashboardProps {
currentTime: number;
isLive: boolean;
refreshKey: number;
replay: ReplayControls;
monitor: MonitorControls;
timeZone: 'KST' | 'UTC';
onTimeZoneChange: (tz: 'KST' | 'UTC') => void;
}
export const KoreaDashboard = ({
currentTime,
isLive,
refreshKey,
replay,
monitor,
timeZone,
onTimeZoneChange,
}: KoreaDashboardProps) => {
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
const [showReport, setShowReport] = useState(false);
const [showOpsGuide, setShowOpsGuide] = useState(false);
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
const { t } = useTranslation();
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
useSharedFilters();
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('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,
windFarm: true,
ports: true,
militaryBases: true,
govBuildings: true,
nkLaunch: true,
nkMissile: true,
cnFishing: false,
militaryOnly: false,
overseasChina: false,
overseasJapan: false,
cnPower: false,
cnMilitary: false,
jpPower: false,
jpMilitary: false,
hazardPetrochemical: false,
hazardLng: false,
hazardOilTank: false,
hazardPort: false,
energyNuclear: false,
energyThermal: false,
industryShipyard: false,
industryWastewater: false,
industryHeavy: false,
});
const toggleKoreaLayer = useCallback((key: string) => {
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, [setKoreaLayers]);
const batchToggleKoreaLayer = useCallback((keys: string[], value: boolean) => {
setKoreaLayers(prev => {
const next = { ...prev };
for (const k of keys) next[k] = value;
return next;
});
}, [setKoreaLayers]);
const [hiddenNationalities, setHiddenNationalities] = useLocalStorageSet('hiddenNationalities', new Set());
const toggleNationality = useCallback((nat: string) => {
setHiddenNationalities(prev => {
const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next;
});
}, [setHiddenNationalities]);
const [hiddenFishingNats, setHiddenFishingNats] = useLocalStorageSet('hiddenFishingNats', new Set());
const toggleFishingNat = useCallback((nat: string) => {
setHiddenFishingNats(prev => {
const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next;
});
}, [setHiddenFishingNats]);
const koreaData = useKoreaData({
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
hiddenNationalities,
refreshKey,
});
const vesselAnalysis = useVesselAnalysis(true);
const groupPolygons = useGroupPolygons(true);
const largestGearGroup = useMemo(() => {
const gears = groupPolygons.allGroups.filter(g => g.groupType !== 'FLEET');
if (gears.length === 0) return undefined;
const max = gears.reduce((a, b) => a.memberCount > b.memberCount ? a : b);
return { name: max.groupLabel, count: max.memberCount };
}, [groupPolygons.allGroups]);
const koreaFiltersResult = useKoreaFilters(
koreaData.ships,
koreaData.visibleShips,
vesselAnalysis.analysisMap,
);
const handleTabChange = useCallback((_tab: DashboardTab) => {
// Tab switching is managed by parent (App.tsx); no-op here
}, []);
const layerTree = useMemo((): LayerTreeNode[] => [
{ key: 'ships', label: t('layers.ships'), color: '#fb923c', count: koreaData.ships.length, specialRenderer: 'shipCategories' },
{ key: 'nationality', label: '국적 분류', color: '#8b5cf6', count: koreaData.ships.length, specialRenderer: 'nationalityCategories' },
{
key: 'aviation', label: '항공망', color: '#22d3ee',
children: [
{ key: 'aircraft', label: t('layers.aircraft'), color: '#22d3ee', count: koreaData.aircraft.length, specialRenderer: 'aircraftCategories' },
{ key: 'satellites', label: t('layers.satellites'), color: '#ef4444', count: koreaData.satPositions.length },
],
},
{
key: 'maritime-safety', label: '해양안전', color: '#3b82f6',
children: [
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1 },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length },
{ key: 'nkMissile', label: '미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length },
{ key: 'nkLaunch', label: '발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length },
],
},
{
key: 'govt-infra', label: '국가기관망', color: '#f59e0b',
children: [
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length },
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length },
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length },
],
},
{
key: 'energy', label: '에너지/발전시설', color: '#a855f7',
children: [
{ key: 'infra', label: t('layers.infra'), color: '#ffc107' },
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length },
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length },
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length },
],
},
{
key: 'hazard', label: '위험시설', color: '#ef4444',
children: [
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length },
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length },
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length },
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length },
],
},
{
key: 'industry', label: '산업공정/제조시설', color: '#0ea5e9',
children: [
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length },
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length },
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length },
],
},
{
key: 'overseas', label: '해외시설', color: '#f97316',
children: [
{
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
children: [
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
],
},
{
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
children: [
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
],
},
],
},
], [koreaData, t]);
// 헤더 슬롯 Portal — 한국 필터 버튼 + 카운트
const headerSlot = document.getElementById('dashboard-header-slot');
const countsSlot = document.getElementById('dashboard-counts-slot');
return (
<>
{headerSlot && createPortal(
<div className="mode-toggle">
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
<span className="text-[11px]">🚫🐟</span>{t('filters.illegalFishing')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)} title={t('filters.illegalTransship')}>
<span className="text-[11px]"></span>{t('filters.illegalTransship')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.darkVessel ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('darkVessel', !koreaFiltersResult.filters.darkVessel)} title={t('filters.darkVessel')}>
<span className="text-[11px]">👻</span>{t('filters.darkVessel')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.cableWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('cableWatch', !koreaFiltersResult.filters.cableWatch)} title={t('filters.cableWatch')}>
<span className="text-[11px]">🔌</span>{t('filters.cableWatch')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.dokdoWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('dokdoWatch', !koreaFiltersResult.filters.dokdoWatch)} title={t('filters.dokdoWatch')}>
<span className="text-[11px]">🏝</span>{t('filters.dokdoWatch')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.ferryWatch ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)} title={t('filters.ferryWatch')}>
<span className="text-[11px]">🚢</span>{t('filters.ferryWatch')}
</button>
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.cnFishing ? 'active live' : ''}`}
onClick={() => koreaFiltersResult.setFilter('cnFishing', !koreaFiltersResult.filters.cnFishing)} title="중국어선감시">
<span className="text-[11px]">🎣</span>
</button>
<button type="button" className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
<span className="text-[11px]">📊</span>
</button>
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
<span className="text-[11px]"></span>
</button>
</div>,
headerSlot,
)}
{countsSlot && createPortal(
<div className="header-counts">
<span className="count-item ac-count">{koreaData.aircraft.length} AC</span>
<span className="count-item mil-count">{koreaData.militaryCount} MIL</span>
<span className="count-item ship-count">{koreaData.ships.length} SHIP</span>
<span className="count-item sat-count">{koreaData.satPositions.length} SAT</span>
</div>,
countsSlot,
)}
<main className="app-main">
<div className="map-panel">
{showFieldAnalysis && (
<FieldAnalysisModal
ships={koreaData.ships}
vesselAnalysis={vesselAnalysis}
onClose={() => setShowFieldAnalysis(false)}
onShowReport={() => setShowReport(v => !v)}
/>
)}
{showReport && (
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} analysisMap={vesselAnalysis.analysisMap} />
)}
{showOpsGuide && (
<OpsGuideModal
ships={koreaData.ships}
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
onFlyTo={(lat, lng, zoom) => setExternalFlyTo({ lat, lng, zoom })}
onRouteSelect={setOpsRoute}
/>
)}
<KoreaMap
ships={koreaFiltersResult.filteredShips}
allShips={koreaData.ships}
aircraft={koreaData.visibleAircraft}
satellites={koreaData.satPositions}
layers={koreaLayers}
osintFeed={koreaData.osintFeed}
currentTime={currentTime}
koreaFilters={koreaFiltersResult.filters}
transshipSuspects={koreaFiltersResult.transshipSuspects}
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
groupPolygons={groupPolygons}
hiddenShipCategories={hiddenShipCategories}
hiddenNationalities={hiddenNationalities}
externalFlyTo={externalFlyTo}
onExternalFlyToDone={() => setExternalFlyTo(null)}
opsRoute={opsRoute}
/>
<div className="map-overlay-left">
<LayerPanel
layers={koreaLayers}
onToggle={toggleKoreaLayer}
onBatchToggle={batchToggleKoreaLayer}
tree={layerTree}
aircraftByCategory={koreaData.aircraftByCategory}
aircraftTotal={koreaData.aircraft.length}
shipsByMtCategory={koreaData.shipsByCategory}
shipTotal={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
onShipCategoryToggle={toggleShipCategory}
shipsByNationality={koreaData.shipsByNationality}
hiddenNationalities={hiddenNationalities}
onNationalityToggle={toggleNationality}
fishingByNationality={koreaData.fishingByNationality}
hiddenFishingNats={hiddenFishingNats}
onFishingNatToggle={toggleFishingNat}
/>
</div>
</div>
<aside className="side-panel">
<EventLog
events={[]}
currentTime={currentTime}
totalShipCount={koreaData.ships.length}
koreanShips={koreaData.koreaKoreanShips}
koreanShipsByCategory={koreaData.shipsByCategory}
chineseShips={koreaData.koreaChineseShips}
osintFeed={koreaData.osintFeed}
isLive={isLive}
dashboardTab="korea"
onTabChange={handleTabChange}
ships={koreaData.ships}
/>
</aside>
</main>
<footer className="app-footer">
{isLive ? (
<LiveControls
currentTime={monitor.state.currentTime}
historyMinutes={monitor.state.historyMinutes}
onHistoryChange={monitor.setHistoryMinutes}
aircraftCount={koreaData.aircraft.length}
shipCount={koreaData.ships.length}
satelliteCount={koreaData.satPositions.length}
timeZone={timeZone}
onTimeZoneChange={onTimeZoneChange}
/>
) : (
<>
<ReplayControls
isPlaying={replay.state.isPlaying}
speed={replay.state.speed}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
onPlay={replay.play}
onPause={replay.pause}
onReset={replay.reset}
onSpeedChange={replay.setSpeed}
onRangeChange={replay.setRange}
/>
<TimelineSlider
currentTime={replay.state.currentTime}
startTime={replay.state.startTime}
endTime={replay.state.endTime}
events={[]}
onSeek={replay.seek}
onEventFlyTo={() => undefined}
/>
</>
)}
</footer>
</>
);
};