From 98f3b6a59c5fc5b6c4efd4e2b4097a95d02843ac Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:09:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4=EC=84=A0?= =?UTF-8?q?=EA=B0=90=EC=8B=9C=20=ED=83=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=EC=84=A0=EB=B0=95=ED=95=84=ED=84=B0?= =?UTF-8?q?=C2=B7=EC=88=98=EC=97=AD=EB=B6=84=EB=A5=98=C2=B7=ED=8C=A8?= =?UTF-8?q?=EB=84=903=EC=84=B9=EC=85=98=C2=B7=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cnFishing ON 시 CN 어선 + 어구 패턴 선박만 표시 (useKoreaFilters 통합) - cnFishing ON 시 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시 (FishingZoneLayer) - FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거) - 어구 그룹 수역 내/외 분류 (classifyFishingZone 기반) - 수역 내: 붉은색 폴리곤(#dc2626), '조업구역내 어구' 섹션 - 수역 외: 오렌지 폴리곤(#f97316), '비허가 어구' 섹션 - 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구) - 폴리곤 클릭·zoom 시 해당 어구 행 자동 스크롤 - 백엔드 vessel-analysis 조회 윈도우 1h → 2h 확대 --- .../analysis/VesselAnalysisService.java | 2 +- frontend/src/App.tsx | 1 + .../components/korea/FleetClusterLayer.tsx | 198 ++++++++++++------ frontend/src/components/korea/KoreaMap.tsx | 8 +- frontend/src/hooks/useKoreaFilters.ts | 11 +- 5 files changed, 152 insertions(+), 68 deletions(-) diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 775065e..0dfb546 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -34,7 +34,7 @@ public class VesselAnalysisService { } } - Instant since = Instant.now().minus(1, ChronoUnit.HOURS); + Instant since = Instant.now().minus(2, ChronoUnit.HOURS); // mmsi별 최신 analyzed_at 1건만 유지 Map latest = new LinkedHashMap<>(); for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d94524a..df06edf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -238,6 +238,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { koreaData.visibleShips, currentTime, vesselAnalysis.analysisMap, + koreaLayers.cnFishing, ); const toggleLayer = useCallback((key: keyof LayerVisibility) => { diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 7c61c13..76f3b10 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -5,6 +5,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; +import { classifyFishingZone } from '../../utils/fishingAnalysis'; export interface SelectedGearGroupData { parent: Ship | null; @@ -20,8 +21,8 @@ export interface SelectedFleetData { interface Props { ships: Ship[]; - analysisMap: Map; - clusters: Map; + analysisMap?: Map; + clusters?: Map; onShipSelect?: (mmsi: string) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; @@ -98,10 +99,18 @@ interface ClusterLineFeature { type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; -export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { +const EMPTY_ANALYSIS = new globalThis.Map(); +const EMPTY_CLUSTERS = new globalThis.Map(); + +export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, clusters: clustersProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { + const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; + const clusters = clustersProp ?? EMPTY_CLUSTERS; const [companies, setCompanies] = useState>(new Map()); - const [expanded, setExpanded] = useState(true); const [expandedFleet, setExpandedFleet] = useState(null); + const [sectionExpanded, setSectionExpanded] = useState>({ + fleet: true, inZone: true, outZone: true, + }); + const toggleSection = (key: string) => setSectionExpanded(prev => ({ ...prev, [key]: !prev[key] })); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); @@ -185,7 +194,10 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, const d = dataRef.current; setSelectedGearGroup(prev => prev === name ? null : name); setExpandedGearGroup(name); - setExpanded(true); + setSectionExpanded(prev => ({ ...prev, inZone: true, outZone: true })); + requestAnimationFrame(() => { + document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); const entry = d.gearGroupMap.get(name); if (!entry) return; const all: Ship[] = [...entry.gears]; @@ -338,8 +350,28 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, }); }, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); - // 비허가 어구 클러스터 GeoJSON + // 어구 그룹을 수역 내/외로 분류 + const { inZoneGearGroups, outZoneGearGroups } = useMemo(() => { + const inZone: { name: string; parent: Ship | null; gears: Ship[]; zone: string }[] = []; + const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = []; + for (const [name, { parent, gears }] of gearGroupMap) { + const anchor = parent ?? gears[0]; + if (!anchor) { outZone.push({ name, parent, gears }); continue; } + const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng); + if (zoneInfo.zone !== 'OUTSIDE') { + inZone.push({ name, parent, gears, zone: zoneInfo.name }); + } else { + outZone.push({ name, parent, gears }); + } + } + inZone.sort((a, b) => b.gears.length - a.gears.length); + outZone.sort((a, b) => b.gears.length - a.gears.length); + return { inZoneGearGroups: inZone, outZoneGearGroups: outZone }; + }, [gearGroupMap]); + + // 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지) const gearClusterGeoJson = useMemo((): GeoJSON => { + const inZoneNames = new Set(inZoneGearGroups.map(g => g.name)); const features: GeoJSON.Feature[] = []; for (const [parentName, { parent, gears }] of gearGroupMap) { const points: [number, number][] = gears.map(g => [g.lng, g.lat]); @@ -350,23 +382,19 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, padded.push(padded[0]); features.push({ type: 'Feature', - properties: { name: parentName, gearCount: gears.length }, + properties: { name: parentName, gearCount: gears.length, inZone: inZoneNames.has(parentName) ? 1 : 0 }, geometry: { type: 'Polygon', coordinates: [padded] }, }); } return { type: 'FeatureCollection', features }; - }, [gearGroupMap]); - - // 어구 그룹 목록 (어구 수 내림차순) - const gearGroupList = useMemo(() => { - return Array.from(gearGroupMap.entries()) - .map(([name, { parent, gears }]) => ({ name, parent, gears })) - .sort((a, b) => b.gears.length - a.gears.length); - }, [gearGroupMap]); + }, [gearGroupMap, inZoneGearGroups]); const handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); setExpandedGearGroup(parentName); + requestAnimationFrame(() => { + document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); const entry = gearGroupMap.get(parentName); if (!entry) return; const all: Ship[] = [...entry.gears]; @@ -486,7 +514,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', - borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', + borderBottom: 'none', cursor: 'default', userSelect: 'none', flexShrink: 0, @@ -586,16 +614,16 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, id="gear-cluster-fill-layer" type="fill" paint={{ - 'fill-color': 'rgba(249, 115, 22, 0.08)', + 'fill-color': ['case', ['==', ['get', 'inZone'], 1], 'rgba(220, 38, 38, 0.12)', 'rgba(249, 115, 22, 0.08)'], }} /> @@ -664,27 +692,24 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, {/* 선단 목록 패널 */}
-
- - 선단 현황 ({fleetList.length}개) - - -
- - {expanded && ( -
- {fleetList.length === 0 ? ( -
- 선단 데이터 없음 -
- ) : ( - fleetList.map(({ id, mmsiList }) => { +
+ {/* ── 선단 현황 섹션 ── */} +
toggleSection('fleet')}> + + 선단 현황 ({fleetList.length}개) + + +
+ {sectionExpanded.fleet && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList }) => { const company = companies.get(id); const companyName = company?.nameCn ?? `선단 #${id}`; const color = clusterColor(id); @@ -838,26 +863,76 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, }) )} - {/* 비허가 어구 그룹 섹션 */} - {gearGroupList.length > 0 && ( - <> -
-
- 비허가 어구 그룹 ({gearGroupList.length}개) +
+ )} + + {/* ── 조업구역내 어구 그룹 섹션 ── */} + {inZoneGearGroups.length > 0 && ( + <> +
toggleSection('inZone')}> + + 조업구역내 어구 ({inZoneGearGroups.length}개) + + +
+ {sectionExpanded.inZone && ( +
+ {inZoneGearGroups.map(({ name, parent, gears, zone }) => { + const isOpen = expandedGearGroup === name; + const accentColor = '#dc2626'; + return ( +
+
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} + > + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} + + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name} + {zone} + ({gears.length}) + +
+ {isOpen && ( +
+ {parent &&
모선: {parent.name || parent.mmsi}
} +
어구 목록:
+ {gears.map(g => ( +
+ {g.name || g.mmsi} + +
+ ))} +
+ )} +
+ ); + })}
- {gearGroupList.map(({ name, parent, gears }) => { + )} + + )} + + {/* ── 비허가 어구 그룹 섹션 ── */} + {outZoneGearGroups.length > 0 && ( + <> +
toggleSection('outZone')}> + + 비허가 어구 ({outZoneGearGroups.length}개) + + +
+ {sectionExpanded.outZone && ( +
+ {outZoneGearGroups.map(({ name, parent, gears }) => { const isOpen = expandedGearGroup === name; return ( -
+
); })} - - )} -
- )} +
+ )} + + )} +
); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 2c40b73..c0b36f0 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -607,14 +607,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.cables && } {layers.cctv && } {/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */} - {koreaFilters.illegalFishing && } + {(koreaFilters.illegalFishing || layers.cnFishing) && } {layers.cnFishing && } {/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */} - {layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && ( + {layers.cnFishing && ( , + cnFishingOn = false, ): UseKoreaFiltersResult { const [filters, setFilters] = useState({ illegalFishing: false, @@ -69,7 +70,8 @@ export function useKoreaFilters( filters.darkVessel || filters.cableWatch || filters.dokdoWatch || - filters.ferryWatch; + filters.ferryWatch || + cnFishingOn; // 불법환적 의심 선박 탐지 const transshipSuspects = useMemo(() => { @@ -326,9 +328,14 @@ export function useKoreaFilters( if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true; if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true; + if (cnFishingOn) { + const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing'; + const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || ''); + if (isCnFishing || isGearPattern) return true; + } return false; }); - }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]); return { filters,