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:
부모
18b827ced0
커밋
0da477c53c
@ -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;
|
||||
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"
|
||||
style={{
|
||||
background: `${color}22`, border: `1px solid ${color}88`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<span className="text-[13px]">{FILTER_ICON[k]}</span>
|
||||
{t(FILTER_I18N_KEY[k])}
|
||||
<span className="ml-0.5 text-white/80">{count}척</span>
|
||||
<>
|
||||
<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 = 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 cursor-pointer select-none ${isOpen ? '' : 'animate-pulse'}`}
|
||||
style={{
|
||||
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>
|
||||
{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>
|
||||
<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,
|
||||
};
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user