feat: 중국어선감시 KoreaFilters 통합 + 필터 배지 클릭 선박목록/CSV 다운로드

- cnFishing을 KoreaFilters 인터페이스에 통합 (koreaLayers → koreaFilters)
  → 다른 필터 탭과 동일한 선박 비활성화/상단 배지/카운트 동작
- 상단 필터 배지 클릭 → 대상 선박 목록 패널 (MMSI/이름/국적/유형/속도)
  → 선박 클릭 시 flyTo, 200척까지 표시
- CSV 다운로드: BOM 포함 UTF-8, 필터별 파일명 (e.g. cnFishing_2026-03-23.csv)
- cnFishingSuspects Set 추가 (useKoreaFilters 반환값)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-23 12:55:52 +09:00
부모 18b827ced0
커밋 0da477c53c
3개의 변경된 파일130개의 추가작업 그리고 49개의 파일을 삭제

파일 보기

@ -159,7 +159,6 @@ export const KoreaDashboard = ({
koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap,
koreaLayers.cnFishing,
);
const handleTabChange = useCallback((_tab: DashboardTab) => {
@ -198,8 +197,8 @@ export const KoreaDashboard = ({
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 ${koreaLayers.cnFishing ? 'active live' : ''}`}
onClick={() => toggleKoreaLayer('cnFishing')} title="중국어선감시">
<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' : ''}`}
@ -239,6 +238,7 @@ export const KoreaDashboard = ({
transshipSuspects={koreaFiltersResult.transshipSuspects}
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
hiddenShipCategories={hiddenShipCategories}

파일 보기

@ -45,6 +45,7 @@ export interface KoreaFiltersState {
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
cnFishing: boolean;
}
interface Props {
@ -59,6 +60,7 @@ interface Props {
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
cnFishingSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
hiddenShipCategories?: Set<string>;
@ -115,6 +117,7 @@ const FILTER_ICON: Record<string, string> = {
cableWatch: '\u{1F50C}',
dokdoWatch: '\u{1F3DD}\uFE0F',
ferryWatch: '\u{1F6A2}',
cnFishing: '\u{1F3A3}',
};
const FILTER_COLOR: Record<string, string> = {
@ -124,6 +127,7 @@ const FILTER_COLOR: Record<string, string> = {
cableWatch: '#00e5ff',
dokdoWatch: '#22c55e',
ferryWatch: '#2196f3',
cnFishing: '#f59e0b',
};
const FILTER_I18N_KEY: Record<string, string> = {
@ -133,6 +137,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
cableWatch: 'filters.cableWatchMonitor',
dokdoWatch: 'filters.dokdoWatchMonitor',
ferryWatch: 'filters.ferryWatchMonitor',
cnFishing: 'filters.cnFishingMonitor',
};
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) {
@ -156,6 +161,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
@ -189,7 +195,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
);
}, []);
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch;
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing;
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
const zoomScale = useMemo(() => {
@ -488,7 +494,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
: koreaFilters.darkVessel ? 'darkVessel'
: layers.cnFishing ? 'cnFishing'
: koreaFilters.cnFishing ? 'cnFishing'
: null;
const analysisDeckLayers = useAnalysisDeckLayers(
@ -638,10 +644,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
{(koreaFilters.illegalFishing || layers.cnFishing) && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{(koreaFilters.illegalFishing || koreaFilters.cnFishing) && <FishingZoneLayer />}
{koreaFilters.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
{layers.cnFishing && (
{koreaFilters.cnFishing && (
<FleetClusterLayer
ships={allShips ?? ships}
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
@ -681,42 +687,106 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{/* Filter Status Banner — 필터별 개별 탐지 카운트 */}
{(() => {
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
if (active.length === 0) return null;
const filterCount: Record<string, number> = {
illegalFishing: (allShips ?? ships).filter(s => {
if (s.mtCategory !== 'fishing' || s.flag === 'KR') return false;
return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE';
}).length,
illegalTransship: transshipSuspects.size,
darkVessel: ships.filter(s => {
const dto = vesselAnalysis?.analysisMap.get(s.mmsi);
return dto?.algorithms.darkVessel.isDark || (s.lastSeen && currentTime - s.lastSeen > 3600000);
}).length,
cableWatch: cableWatchSuspects.size,
dokdoWatch: dokdoWatchSuspects.size,
ferryWatch: (allShips ?? ships).filter(s => s.mtCategory === 'passenger').length,
if (active.length === 0) { if (activeBadgeFilter) setActiveBadgeFilter(null); return null; }
const gearPattern = /^.+?_\d+_\d+_?$/;
const all = allShips ?? ships;
const getShipsForFilter = (k: string): Ship[] => {
switch (k) {
case 'illegalFishing': return all.filter(s => s.mtCategory === 'fishing' && s.flag !== 'KR' && classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE');
case 'illegalTransship': return all.filter(s => transshipSuspects.has(s.mmsi));
case 'darkVessel': return all.filter(s => { const dto = vesselAnalysis?.analysisMap.get(s.mmsi); return !!(dto?.algorithms.darkVessel.isDark) || (s.lastSeen != null && currentTime - s.lastSeen > 3600000); });
case 'cableWatch': return all.filter(s => cableWatchSuspects.has(s.mmsi));
case 'dokdoWatch': return all.filter(s => dokdoWatchSuspects.has(s.mmsi));
case 'ferryWatch': return all.filter(s => s.mtCategory === 'passenger');
case 'cnFishing': return all.filter(s => (s.flag === 'CN' && s.mtCategory === 'fishing') || gearPattern.test(s.name || ''));
default: return [];
}
};
const downloadCsv = (k: string) => {
const data = getShipsForFilter(k);
const bom = '\uFEFF';
const header = 'MMSI,Name,Flag,Category,Lat,Lng,Speed,Heading';
const rows = data.map(s => `${s.mmsi},"${(s.name || '').replace(/"/g, '""')}",${s.flag || ''},${s.mtCategory || ''},${s.lat.toFixed(4)},${s.lng.toFixed(4)},${s.speed},${s.heading}`);
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${k}_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const badgeShips = activeBadgeFilter ? getShipsForFilter(activeBadgeFilter) : [];
return (
<>
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
{active.map(k => {
const color = FILTER_COLOR[k];
const count = filterCount[k] ?? 0;
const count = getShipsForFilter(k).length;
const isOpen = activeBadgeFilter === k;
return (
<div
key={k}
className="rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 animate-pulse"
className={`rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 cursor-pointer select-none ${isOpen ? '' : 'animate-pulse'}`}
style={{
background: `${color}22`, border: `1px solid ${color}88`,
background: isOpen ? `${color}44` : `${color}22`,
border: `1px solid ${isOpen ? color : color + '88'}`,
color,
}}
onClick={() => setActiveBadgeFilter(prev => prev === k ? null : k)}
>
<span className="text-[13px]">{FILTER_ICON[k]}</span>
{t(FILTER_I18N_KEY[k])}
{FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k}
<span className="ml-0.5 text-white/80">{count}</span>
</div>
);
})}
</div>
{activeBadgeFilter && badgeShips.length > 0 && (
<div
className="absolute top-12 left-1/2 -translate-x-1/2 z-30 rounded-lg px-3 py-2 font-mono text-[11px] w-[440px] max-h-[320px] bg-kcg-overlay backdrop-blur-lg shadow-lg"
style={{ borderWidth: 1, borderStyle: 'solid', borderColor: (FILTER_COLOR[activeBadgeFilter] ?? '#888') + '88' }}
>
<div className="flex justify-between items-center mb-2">
<span className="font-bold text-white">
{FILTER_ICON[activeBadgeFilter]} {FILTER_I18N_KEY[activeBadgeFilter] ? t(FILTER_I18N_KEY[activeBadgeFilter]) : activeBadgeFilter} {badgeShips.length}
</span>
<div className="flex gap-1.5">
<button type="button" onClick={() => downloadCsv(activeBadgeFilter)} className="text-[10px] px-2 py-0.5 rounded bg-white/10 hover:bg-white/20 text-white transition-colors">CSV</button>
<button type="button" onClick={() => setActiveBadgeFilter(null)} className="text-white/60 hover:text-white transition-colors"></button>
</div>
</div>
<div className="overflow-y-auto max-h-[260px]">
<table className="w-full text-[10px]">
<thead className="sticky top-0 bg-kcg-overlay">
<tr className="text-white/50 border-b border-white/10">
<th className="text-left py-1 px-1">MMSI</th>
<th className="text-left py-1 px-1">Name</th>
<th className="text-center py-1 px-1">Flag</th>
<th className="text-left py-1 px-1">Type</th>
<th className="text-right py-1 px-1">Speed</th>
</tr>
</thead>
<tbody>
{badgeShips.slice(0, 200).map(s => (
<tr
key={s.mmsi}
className="hover:bg-white/5 cursor-pointer border-b border-white/5"
onClick={() => setFlyToTarget({ lng: s.lng, lat: s.lat, zoom: 12 })}
>
<td className="py-0.5 px-1 text-white/60">{s.mmsi}</td>
<td className="py-0.5 px-1 text-white/90 truncate max-w-[140px]">{s.name || '-'}</td>
<td className="py-0.5 px-1 text-center text-white/60">{s.flag || '??'}</td>
<td className="py-0.5 px-1 text-white/50">{s.mtCategory || '-'}</td>
<td className="py-0.5 px-1 text-right text-white/50">{s.speed?.toFixed(1)}kn</td>
</tr>
))}
</tbody>
</table>
{badgeShips.length > 200 && <div className="text-center text-white/40 text-[10px] py-1">... {badgeShips.length - 200}</div>}
</div>
</div>
)}
</>
);
})()}

파일 보기

@ -12,6 +12,7 @@ interface KoreaFilters {
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
cnFishing: boolean;
}
interface DokdoAlert {
@ -28,6 +29,7 @@ interface UseKoreaFiltersResult {
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
cnFishingSuspects: Set<string>;
dokdoAlerts: DokdoAlert[];
anyFilterOn: boolean;
}
@ -43,7 +45,6 @@ export function useKoreaFilters(
visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>,
cnFishingOn = false,
): UseKoreaFiltersResult {
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
illegalFishing: false,
@ -52,6 +53,7 @@ export function useKoreaFilters(
cableWatch: false,
dokdoWatch: false,
ferryWatch: false,
cnFishing: false,
});
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
@ -70,7 +72,7 @@ export function useKoreaFilters(
filters.cableWatch ||
filters.dokdoWatch ||
filters.ferryWatch ||
cnFishingOn;
filters.cnFishing;
// 불법환적 의심 선박 탐지 (Python 분석 결과 소비)
const transshipSuspects = useMemo(() => {
@ -250,6 +252,18 @@ export function useKoreaFilters(
return result;
}, [koreaShips, filters.dokdoWatch, currentTime]);
// 중국어선 의심 선박 Set
const cnFishingSuspects = useMemo(() => {
if (!filters.cnFishing) return new Set<string>();
const result = new Set<string>();
for (const s of koreaShips) {
const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing';
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
if (isCnFishing || isGearPattern) result.add(s.mmsi);
}
return result;
}, [filters.cnFishing, koreaShips]);
// 필터링된 선박 목록
const filteredShips = useMemo(() => {
if (!anyFilterOn) return visibleShips;
@ -272,14 +286,10 @@ export function useKoreaFilters(
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
if (filters.ferryWatch && s.mtCategory === 'passenger') return true;
if (cnFishingOn) {
const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing';
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
if (isCnFishing || isGearPattern) return true;
}
if (filters.cnFishing && cnFishingSuspects.has(s.mmsi)) return true;
return false;
});
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]);
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingSuspects]);
return {
filters,
@ -288,6 +298,7 @@ export function useKoreaFilters(
transshipSuspects,
cableWatchSuspects: cableWatchSet,
dokdoWatchSuspects: dokdoWatchSet,
cnFishingSuspects,
dokdoAlerts,
anyFilterOn,
};