From 83c0281710e96c48af18636226ddc3bb34a40164 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 08:24:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3848b4e..43f9459 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,7 +4,26 @@ ## [Unreleased] -## [2026-03-20.3] +## [2026-03-23] + +### 추가 +- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설 +- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드) +- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴) +- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보) +- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기 + +### 변경 +- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함) +- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x) + +### 수정 +- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정 +- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소) +- deck.gl 레이어 호버 시 pointer 커서 표시 +- prediction 증분 수집 버그 수정 (vessel_store.py) + +## [2026-03-20] ### 변경 - deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL) @@ -12,22 +31,14 @@ ### 추가 - NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조 - -### 수정 -- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 - -## [2026-03-20.2] - -### 추가 -- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고 +- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고 - AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계 - 불법어선/다크베셀/중국어선감시 Python 분석 연동 - Backend vessel-analysis REST API + DB 테이블 복원 +- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 -## [2026-03-20] - -### 추가 -- 특정어업수역 Ⅰ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon) +### 수정 +- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선 ## [2026-03-19] -- 2.45.2 From 98f3b6a59c5fc5b6c4efd4e2b4097a95d02843ac Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:09:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EC=84=A0=EA=B0=90=EC=8B=9C=20=ED=83=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20=E2=80=94=20=EC=84=A0=EB=B0=95=ED=95=84?= =?UTF-8?q?=ED=84=B0=C2=B7=EC=88=98=EC=97=AD=EB=B6=84=EB=A5=98=C2=B7?= =?UTF-8?q?=ED=8C=A8=EB=84=903=EC=84=B9=EC=85=98=C2=B7=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=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, -- 2.45.2 From 6305fd3c2665b0942d9229bb3a05e61aeb45b815 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 09:22:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20localStorage=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4/=ED=95=84=ED=84=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=98=81=EC=86=8D=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLocalStorage / useLocalStorageSet 훅 추가 - 영속 대상: - dashboardTab, mapMode, iranLayers, koreaLayers - koreaFilters, hiddenAcCategories, hiddenShipCategories - hiddenNationalities, hiddenFishingNats - layerPanelExpanded, analysisPanelExpanded, analysisPanelOpen - Record 타입은 새 키 추가 시 defaults와 자동 머지 - AI 분석 패널 위치: top:10, right:50 (줌 버튼 간격) - AI 분석 닫힘 시 위험도 마커 off, 열림 시 on --- frontend/src/App.tsx | 29 ++++---- frontend/src/components/common/LayerPanel.tsx | 5 +- .../components/korea/AnalysisStatsPanel.tsx | 22 ++++-- frontend/src/components/korea/KoreaMap.tsx | 5 +- frontend/src/hooks/useKoreaFilters.ts | 3 +- frontend/src/hooks/useLocalStorage.ts | 68 +++++++++++++++++++ 6 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/useLocalStorage.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df06edf..bdd8948 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage'; import { ReplayMap } from './components/iran/ReplayMap'; import type { FlyToTarget } from './components/iran/ReplayMap'; import { GlobeMap } from './components/iran/GlobeMap'; @@ -68,9 +69,9 @@ interface AuthenticatedAppProps { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const [appMode, setAppMode] = useState('live'); - const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); - const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); - const [layers, setLayers] = useState({ + const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite'); + const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran'); + const [layers, setLayers] = useLocalStorage('iranLayers', { events: true, aircraft: true, satellites: true, @@ -94,7 +95,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }); // Korea tab layer visibility (lifted from KoreaMap) - const [koreaLayers, setKoreaLayers] = useState>({ + const [koreaLayers, setKoreaLayers] = useLocalStorage>('koreaLayers', { ships: true, aircraft: true, satellites: true, @@ -134,11 +135,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const toggleKoreaLayer = useCallback((key: string) => { setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); + }, [setKoreaLayers]); // Category filter state (shared across tabs) - const [hiddenAcCategories, setHiddenAcCategories] = useState>(new Set()); - const [hiddenShipCategories, setHiddenShipCategories] = useState>(new Set()); + const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set()); + const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set()); const toggleAcCategory = useCallback((cat: string) => { setHiddenAcCategories(prev => { @@ -146,7 +147,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); - }, []); + }, [setHiddenAcCategories]); const toggleShipCategory = useCallback((cat: string) => { setHiddenShipCategories(prev => { @@ -154,27 +155,27 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } return next; }); - }, []); + }, [setHiddenShipCategories]); // Nationality filter state (Korea tab) - const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); + 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]); // Fishing vessel nationality filter state - const [hiddenFishingNats, setHiddenFishingNats] = useState>(new Set()); + 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 [flyToTarget, setFlyToTarget] = useState(null); @@ -243,7 +244,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { const toggleLayer = useCallback((key: keyof LayerVisibility) => { setLayers(prev => ({ ...prev, [key]: !prev[key] })); - }, []); + }, [setLayers]); // Handle event card click from timeline: fly to location on map const handleEventFlyTo = useCallback((event: GeoEvent) => { diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index b41844e..0f4c3bf 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useLocalStorageSet } from '../../hooks/useLocalStorage'; // Aircraft category colors (matches AircraftLayer military fixed colors) const AC_CAT_COLORS: Record = { @@ -172,7 +173,7 @@ export function LayerPanel({ onFishingNatToggle, }: LayerPanelProps) { const { t } = useTranslation(['common', 'ships']); - const [expanded, setExpanded] = useState>(new Set(['ships'])); + const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships'])); const [legendOpen, setLegendOpen] = useState>(new Set()); const toggleExpand = useCallback((key: string) => { @@ -181,7 +182,7 @@ export function LayerPanel({ if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); - }, []); + }, [setExpanded]); const toggleLegend = useCallback((key: string) => { setLegendOpen(prev => { diff --git a/frontend/src/components/korea/AnalysisStatsPanel.tsx b/frontend/src/components/korea/AnalysisStatsPanel.tsx index dd8b27f..836d8bc 100644 --- a/frontend/src/components/korea/AnalysisStatsPanel.tsx +++ b/frontend/src/components/korea/AnalysisStatsPanel.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import type { VesselAnalysisDto, RiskLevel, Ship } from '../../types'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; import { fetchVesselTrack } from '../../services/vesselTrack'; interface Props { @@ -12,6 +13,7 @@ interface Props { allShips?: Ship[]; onShipSelect?: (mmsi: string) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; + onExpandedChange?: (expanded: boolean) => void; } interface VesselListItem { @@ -71,8 +73,16 @@ const LEGEND_LINES = [ '스푸핑: 순간이동+SOG급변+BD09 종합', ]; -export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { - const [expanded, setExpanded] = useState(true); +export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) { + const [expanded, setExpanded] = useLocalStorage('analysisPanelExpanded', false); + const toggleExpanded = () => { + const next = !expanded; + setExpanded(next); + onExpandedChange?.(next); + }; + // 마운트 시 저장된 상태를 부모에 동기화 + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { onExpandedChange?.(expanded); }, []); const [selectedLevel, setSelectedLevel] = useState(null); const [selectedMmsi, setSelectedMmsi] = useState(null); const [showLegend, setShowLegend] = useState(false); @@ -124,8 +134,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, const panelStyle: React.CSSProperties = { position: 'absolute', - top: 60, - right: 10, + top: 10, + right: 50, zIndex: 10, minWidth: 200, maxWidth: 280, @@ -231,7 +241,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,