feat: 중국어선감시 탭 강화 + localStorage 상태 영속화 (#152)

This commit is contained in:
htlee 2026-03-23 09:31:38 +09:00
부모 852817d7ff
커밋 cdc4cb57b1
8개의 변경된 파일260개의 추가작업 그리고 92개의 파일을 삭제

파일 보기

@ -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<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {

파일 보기

@ -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<AppMode>('live');
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite');
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran');
const [layers, setLayers] = useState<LayerVisibility>({
const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
const [layers, setLayers] = useLocalStorage<LayerVisibility>('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<Record<string, boolean>>({
const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('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<Set<string>>(new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(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<Set<string>>(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<Set<string>>(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<FlyToTarget | null>(null);
@ -238,11 +239,12 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap,
koreaLayers.cnFishing,
);
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) => {

파일 보기

@ -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<string, string> = {
@ -172,7 +173,7 @@ export function LayerPanel({
onFishingNatToggle,
}: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']);
const [expanded, setExpanded] = useState<Set<string>>(new Set(['ships']));
const [expanded, setExpanded] = useLocalStorageSet('layerPanelExpanded', new Set(['ships']));
const [legendOpen, setLegendOpen] = useState<Set<string>>(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 => {

파일 보기

@ -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<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(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,
</button>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
onClick={toggleExpanded}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}

파일 보기

@ -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<string, VesselAnalysisDto>;
clusters: Map<number, string[]>;
analysisMap?: Map<string, VesselAnalysisDto>;
clusters?: Map<number, string[]>;
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<string, VesselAnalysisDto>();
const EMPTY_CLUSTERS = new globalThis.Map<number, string[]>();
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<Map<number, FleetCompany>>(new Map());
const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
fleet: true, inZone: true, outZone: true,
});
const toggleSection = (key: string) => setSectionExpanded(prev => ({ ...prev, [key]: !prev[key] }));
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(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)'],
}}
/>
<Layer
id="gear-cluster-line-layer"
type="line"
paint={{
'line-color': '#f97316',
'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
'line-opacity': 0.7,
'line-width': 1.5,
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
'line-dasharray': [4, 2],
}}
/>
@ -664,21 +692,18 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<div style={headerStyle}>
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
{/* ── 선단 현황 섹션 ── */}
<div style={headerStyle} onClick={() => toggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
</span>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}
<button style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
{sectionExpanded.fleet ? '▲' : '▼'}
</button>
</div>
{expanded && (
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
{sectionExpanded.fleet && (
<div style={{ padding: '4px 0' }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
@ -838,26 +863,76 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
})
)}
{/* 비허가 어구 그룹 섹션 */}
{gearGroupList.length > 0 && (
<>
<div style={{
borderTop: '1px solid rgba(249,115,22,0.25)',
margin: '6px 10px',
}} />
<div style={{
padding: '2px 10px 4px',
fontSize: 10,
color: '#f97316',
fontWeight: 700,
letterSpacing: 0.3,
}}>
({gearGroupList.length})
</div>
{gearGroupList.map(({ name, parent, gears }) => {
)}
{/* ── 조업구역내 어구 그룹 섹션 ── */}
{inZoneGearGroups.length > 0 && (
<>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(220,38,38,0.35)' }} onClick={() => toggleSection('inZone')}>
<span style={{ fontWeight: 700, color: '#dc2626', letterSpacing: 0.3 }}>
({inZoneGearGroups.length})
</span>
<button style={toggleButtonStyle} aria-label="조업구역내 어구 접기/펴기">
{sectionExpanded.inZone ? '▲' : '▼'}
</button>
</div>
{sectionExpanded.inZone && (
<div style={{ padding: '4px 0' }}>
{inZoneGearGroups.map(({ name, parent, gears, zone }) => {
const isOpen = expandedGearGroup === name;
const accentColor = '#dc2626';
return (
<div key={name} id={`gear-row-${name}`}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 10px', cursor: 'pointer', borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent', transition: 'background-color 0.1s' }}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }}
>
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'}</span>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: accentColor, flexShrink: 0 }} />
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name}${zone}`}>{name}</span>
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zone}</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gears.length})</span>
<button type="button" onClick={e => { e.stopPropagation(); handleGearGroupZoom(name); }} style={{ background: 'none', border: `1px solid rgba(220,38,38,0.5)`, borderRadius: 3, color: accentColor, fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }} title="이 어구 그룹으로 지도 이동">zoom</button>
</div>
{isOpen && (
<div style={{ paddingLeft: 24, paddingRight: 10, paddingBottom: 4, fontSize: 9, color: '#94a3b8', borderLeft: `2px solid rgba(220,38,38,0.25)`, marginLeft: 10 }}>
{parent && <div style={{ color: '#fbbf24', marginBottom: 2 }}>: {parent.name || parent.mmsi}</div>}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div>
{gears.map(g => (
<div key={g.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 1 }}>
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.name || g.mmsi}</span>
<button type="button" onClick={() => onShipSelect?.(g.mmsi)} style={{ background: 'none', border: 'none', color: accentColor, fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} title="어구 위치로 이동"></button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
{/* ── 비허가 어구 그룹 섹션 ── */}
{outZoneGearGroups.length > 0 && (
<>
<div style={{ ...headerStyle, borderTop: '1px solid rgba(249,115,22,0.25)' }} onClick={() => toggleSection('outZone')}>
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
({outZoneGearGroups.length})
</span>
<button style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
{sectionExpanded.outZone ? '▲' : '▼'}
</button>
</div>
{sectionExpanded.outZone && (
<div style={{ padding: '4px 0' }}>
{outZoneGearGroups.map(({ name, parent, gears }) => {
const isOpen = expandedGearGroup === name;
return (
<div key={name}>
<div key={name} id={`gear-row-${name}`}>
<div
style={{
display: 'flex',
@ -967,10 +1042,11 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
</div>
);
})}
</div>
)}
</>
)}
</div>
)}
</div>
</>
);

파일 보기

@ -34,6 +34,7 @@ import type { Ship, Aircraft, SatellitePosition } from '../../types';
import type { OsintItem } from '../../services/osint';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import 'maplibre-gl/dist/maplibre-gl.css';
export interface KoreaFiltersState {
@ -142,6 +143,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
@ -607,14 +609,14 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{layers.cables && <SubmarineCableLayer />}
{layers.cctv && <CctvLayer />}
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
{koreaFilters.illegalFishing && <FishingZoneLayer />}
{(koreaFilters.illegalFishing || layers.cnFishing) && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
{layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && (
{layers.cnFishing && (
<FleetClusterLayer
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
clusters={vesselAnalysis.clusters}
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
@ -638,7 +640,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...analysisDeckLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (() => {
@ -928,6 +930,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
allShips={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad}
onExpandedChange={setAnalysisPanelOpen}
/>
)}
</Map>

파일 보기

@ -1,4 +1,5 @@
import { useState, useMemo, useRef } from 'react';
import { useLocalStorage } from './useLocalStorage';
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import { classifyFishingZone } from '../utils/fishingAnalysis';
@ -43,8 +44,9 @@ export function useKoreaFilters(
visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>,
cnFishingOn = false,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
illegalFishing: false,
illegalTransship: false,
darkVessel: false,
@ -69,7 +71,8 @@ export function useKoreaFilters(
filters.darkVessel ||
filters.cableWatch ||
filters.dokdoWatch ||
filters.ferryWatch;
filters.ferryWatch ||
cnFishingOn;
// 불법환적 의심 선박 탐지
const transshipSuspects = useMemo(() => {
@ -326,9 +329,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,

파일 보기

@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
const PREFIX = 'kcg.';
/**
* localStorage useState JSON / .
* Record defaults와 .
*/
export function useLocalStorage<T>(key: string, defaults: T): [T, (v: T | ((prev: T) => T)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<T>(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw === null) return defaults;
const parsed = JSON.parse(raw) as T;
// Record 타입이면 defaults에 있는 키가 저장값에 없을 때 머지
if (defaults !== null && typeof defaults === 'object' && !Array.isArray(defaults)) {
return { ...defaults, ...parsed };
}
return parsed;
} catch {
return defaults;
}
});
const setValue = useCallback((updater: T | ((prev: T) => T)) => {
setValueRaw(prev => {
const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater;
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch { /* quota exceeded — 무시 */ }
return next;
});
}, [storageKey]);
return [value, setValue];
}
/**
* Set<string> localStorage Array로 .
*/
export function useLocalStorageSet(key: string, defaults: Set<string>): [Set<string>, (v: Set<string> | ((prev: Set<string>) => Set<string>)) => void] {
const storageKey = PREFIX + key;
const [value, setValueRaw] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw === null) return defaults;
const arr = JSON.parse(raw);
return Array.isArray(arr) ? new Set(arr) : defaults;
} catch {
return defaults;
}
});
const setValue = useCallback((updater: Set<string> | ((prev: Set<string>) => Set<string>)) => {
setValueRaw(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater;
try {
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
} catch { /* quota exceeded */ }
return next;
});
}, [storageKey]);
return [value, setValue];
}