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건만 유지 // mmsi별 최신 analyzed_at 1건만 유지
Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>(); Map<String, VesselAnalysisResult> latest = new LinkedHashMap<>();
for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) { for (VesselAnalysisResult r : repository.findByAnalyzedAtAfter(since)) {

파일 보기

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useLocalStorage, useLocalStorageSet } from './hooks/useLocalStorage';
import { ReplayMap } from './components/iran/ReplayMap'; import { ReplayMap } from './components/iran/ReplayMap';
import type { FlyToTarget } from './components/iran/ReplayMap'; import type { FlyToTarget } from './components/iran/ReplayMap';
import { GlobeMap } from './components/iran/GlobeMap'; import { GlobeMap } from './components/iran/GlobeMap';
@ -68,9 +69,9 @@ interface AuthenticatedAppProps {
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [appMode, setAppMode] = useState<AppMode>('live'); const [appMode, setAppMode] = useState<AppMode>('live');
const [mapMode, setMapMode] = useState<'flat' | 'globe' | 'satellite'>('satellite'); const [mapMode, setMapMode] = useLocalStorage<'flat' | 'globe' | 'satellite'>('mapMode', 'satellite');
const [dashboardTab, setDashboardTab] = useState<'iran' | 'korea'>('iran'); const [dashboardTab, setDashboardTab] = useLocalStorage<'iran' | 'korea'>('dashboardTab', 'iran');
const [layers, setLayers] = useState<LayerVisibility>({ const [layers, setLayers] = useLocalStorage<LayerVisibility>('iranLayers', {
events: true, events: true,
aircraft: true, aircraft: true,
satellites: true, satellites: true,
@ -94,7 +95,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}); });
// Korea tab layer visibility (lifted from KoreaMap) // Korea tab layer visibility (lifted from KoreaMap)
const [koreaLayers, setKoreaLayers] = useState<Record<string, boolean>>({ const [koreaLayers, setKoreaLayers] = useLocalStorage<Record<string, boolean>>('koreaLayers', {
ships: true, ships: true,
aircraft: true, aircraft: true,
satellites: true, satellites: true,
@ -134,11 +135,11 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const toggleKoreaLayer = useCallback((key: string) => { const toggleKoreaLayer = useCallback((key: string) => {
setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] })); setKoreaLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []); }, [setKoreaLayers]);
// Category filter state (shared across tabs) // Category filter state (shared across tabs)
const [hiddenAcCategories, setHiddenAcCategories] = useState<Set<string>>(new Set()); const [hiddenAcCategories, setHiddenAcCategories] = useLocalStorageSet('hiddenAcCategories', new Set());
const [hiddenShipCategories, setHiddenShipCategories] = useState<Set<string>>(new Set()); const [hiddenShipCategories, setHiddenShipCategories] = useLocalStorageSet('hiddenShipCategories', new Set());
const toggleAcCategory = useCallback((cat: string) => { const toggleAcCategory = useCallback((cat: string) => {
setHiddenAcCategories(prev => { setHiddenAcCategories(prev => {
@ -146,7 +147,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next; return next;
}); });
}, []); }, [setHiddenAcCategories]);
const toggleShipCategory = useCallback((cat: string) => { const toggleShipCategory = useCallback((cat: string) => {
setHiddenShipCategories(prev => { setHiddenShipCategories(prev => {
@ -154,27 +155,27 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
if (next.has(cat)) { next.delete(cat); } else { next.add(cat); } if (next.has(cat)) { next.delete(cat); } else { next.add(cat); }
return next; return next;
}); });
}, []); }, [setHiddenShipCategories]);
// Nationality filter state (Korea tab) // 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) => { const toggleNationality = useCallback((nat: string) => {
setHiddenNationalities(prev => { setHiddenNationalities(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next; return next;
}); });
}, []); }, [setHiddenNationalities]);
// Fishing vessel nationality filter state // 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) => { const toggleFishingNat = useCallback((nat: string) => {
setHiddenFishingNats(prev => { setHiddenFishingNats(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
return next; return next;
}); });
}, []); }, [setHiddenFishingNats]);
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null); const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
@ -238,11 +239,12 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
koreaData.visibleShips, koreaData.visibleShips,
currentTime, currentTime,
vesselAnalysis.analysisMap, vesselAnalysis.analysisMap,
koreaLayers.cnFishing,
); );
const toggleLayer = useCallback((key: keyof LayerVisibility) => { const toggleLayer = useCallback((key: keyof LayerVisibility) => {
setLayers(prev => ({ ...prev, [key]: !prev[key] })); setLayers(prev => ({ ...prev, [key]: !prev[key] }));
}, []); }, [setLayers]);
// Handle event card click from timeline: fly to location on map // Handle event card click from timeline: fly to location on map
const handleEventFlyTo = useCallback((event: GeoEvent) => { const handleEventFlyTo = useCallback((event: GeoEvent) => {

파일 보기

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
// Aircraft category colors (matches AircraftLayer military fixed colors) // Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = { const AC_CAT_COLORS: Record<string, string> = {
@ -172,7 +173,7 @@ export function LayerPanel({
onFishingNatToggle, onFishingNatToggle,
}: LayerPanelProps) { }: LayerPanelProps) {
const { t } = useTranslation(['common', 'ships']); 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 [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((key: string) => { const toggleExpand = useCallback((key: string) => {
@ -181,7 +182,7 @@ export function LayerPanel({
if (next.has(key)) { next.delete(key); } else { next.add(key); } if (next.has(key)) { next.delete(key); } else { next.add(key); }
return next; return next;
}); });
}, []); }, [setExpanded]);
const toggleLegend = useCallback((key: string) => { const toggleLegend = useCallback((key: string) => {
setLegendOpen(prev => { 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 { VesselAnalysisDto, RiskLevel, Ship } from '../../types';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis'; import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { fetchVesselTrack } from '../../services/vesselTrack'; import { fetchVesselTrack } from '../../services/vesselTrack';
interface Props { interface Props {
@ -12,6 +13,7 @@ interface Props {
allShips?: Ship[]; allShips?: Ship[];
onShipSelect?: (mmsi: string) => void; onShipSelect?: (mmsi: string) => void;
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
onExpandedChange?: (expanded: boolean) => void;
} }
interface VesselListItem { interface VesselListItem {
@ -71,8 +73,16 @@ const LEGEND_LINES = [
'스푸핑: 순간이동+SOG급변+BD09 종합', '스푸핑: 순간이동+SOG급변+BD09 종합',
]; ];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) { export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad, onExpandedChange }: Props) {
const [expanded, setExpanded] = useState(true); 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 [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null); const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [showLegend, setShowLegend] = useState(false); const [showLegend, setShowLegend] = useState(false);
@ -124,8 +134,8 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
const panelStyle: React.CSSProperties = { const panelStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
top: 60, top: 10,
right: 10, right: 50,
zIndex: 10, zIndex: 10,
minWidth: 200, minWidth: 200,
maxWidth: 280, maxWidth: 280,
@ -231,7 +241,7 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
</button> </button>
<button <button
style={toggleButtonStyle} style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)} onClick={toggleExpanded}
aria-label={expanded ? '패널 접기' : '패널 펼치기'} aria-label={expanded ? '패널 접기' : '패널 펼치기'}
> >
{expanded ? '▲' : '▼'} {expanded ? '▲' : '▼'}

파일 보기

@ -5,6 +5,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl';
import type { Ship, VesselAnalysisDto } from '../../types'; import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import { fetchFleetCompanies } from '../../services/vesselAnalysis';
import type { FleetCompany } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
export interface SelectedGearGroupData { export interface SelectedGearGroupData {
parent: Ship | null; parent: Ship | null;
@ -20,8 +21,8 @@ export interface SelectedFleetData {
interface Props { interface Props {
ships: Ship[]; ships: Ship[];
analysisMap: Map<string, VesselAnalysisDto>; analysisMap?: Map<string, VesselAnalysisDto>;
clusters: Map<number, string[]>; clusters?: Map<number, string[]>;
onShipSelect?: (mmsi: string) => void; onShipSelect?: (mmsi: string) => void;
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
@ -98,10 +99,18 @@ interface ClusterLineFeature {
type ClusterFeature = ClusterPolygonFeature | 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 [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null); 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 [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null); const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = 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; const d = dataRef.current;
setSelectedGearGroup(prev => prev === name ? null : name); setSelectedGearGroup(prev => prev === name ? null : name);
setExpandedGearGroup(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); const entry = d.gearGroupMap.get(name);
if (!entry) return; if (!entry) return;
const all: Ship[] = [...entry.gears]; const all: Ship[] = [...entry.gears];
@ -338,8 +350,28 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
}); });
}, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); }, [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 gearClusterGeoJson = useMemo((): GeoJSON => {
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
const features: GeoJSON.Feature[] = []; const features: GeoJSON.Feature[] = [];
for (const [parentName, { parent, gears }] of gearGroupMap) { for (const [parentName, { parent, gears }] of gearGroupMap) {
const points: [number, number][] = gears.map(g => [g.lng, g.lat]); 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]); padded.push(padded[0]);
features.push({ features.push({
type: 'Feature', 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] }, geometry: { type: 'Polygon', coordinates: [padded] },
}); });
} }
return { type: 'FeatureCollection', features }; return { type: 'FeatureCollection', features };
}, [gearGroupMap]); }, [gearGroupMap, inZoneGearGroups]);
// 어구 그룹 목록 (어구 수 내림차순)
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]);
const handleGearGroupZoom = useCallback((parentName: string) => { const handleGearGroupZoom = useCallback((parentName: string) => {
setSelectedGearGroup(prev => prev === parentName ? null : parentName); setSelectedGearGroup(prev => prev === parentName ? null : parentName);
setExpandedGearGroup(parentName); setExpandedGearGroup(parentName);
requestAnimationFrame(() => {
document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
const entry = gearGroupMap.get(parentName); const entry = gearGroupMap.get(parentName);
if (!entry) return; if (!entry) return;
const all: Ship[] = [...entry.gears]; const all: Ship[] = [...entry.gears];
@ -486,7 +514,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '6px 10px', padding: '6px 10px',
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none', borderBottom: 'none',
cursor: 'default', cursor: 'default',
userSelect: 'none', userSelect: 'none',
flexShrink: 0, flexShrink: 0,
@ -586,16 +614,16 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
id="gear-cluster-fill-layer" id="gear-cluster-fill-layer"
type="fill" type="fill"
paint={{ 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 <Layer
id="gear-cluster-line-layer" id="gear-cluster-line-layer"
type="line" type="line"
paint={{ paint={{
'line-color': '#f97316', 'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
'line-opacity': 0.7, 'line-opacity': 0.7,
'line-width': 1.5, 'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
'line-dasharray': [4, 2], 'line-dasharray': [4, 2],
}} }}
/> />
@ -664,27 +692,24 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
{/* 선단 목록 패널 */} {/* 선단 목록 패널 */}
<div style={panelStyle}> <div style={panelStyle}>
<div style={headerStyle}> <div style={{ maxHeight: 500, overflowY: 'auto' }}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}> {/* ── 선단 현황 섹션 ── */}
({fleetList.length}) <div style={headerStyle} onClick={() => toggleSection('fleet')}>
</span> <span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
<button ({fleetList.length})
style={toggleButtonStyle} </span>
onClick={() => setExpanded(prev => !prev)} <button style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
aria-label={expanded ? '패널 접기' : '패널 펼치기'} {sectionExpanded.fleet ? '▲' : '▼'}
> </button>
{expanded ? '▲' : '▼'} </div>
</button> {sectionExpanded.fleet && (
</div> <div style={{ padding: '4px 0' }}>
{fleetList.length === 0 ? (
{expanded && ( <div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
{fleetList.length === 0 ? ( </div>
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}> ) : (
fleetList.map(({ id, mmsiList }) => {
</div>
) : (
fleetList.map(({ id, mmsiList }) => {
const company = companies.get(id); const company = companies.get(id);
const companyName = company?.nameCn ?? `선단 #${id}`; const companyName = company?.nameCn ?? `선단 #${id}`;
const color = clusterColor(id); const color = clusterColor(id);
@ -838,26 +863,76 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
}) })
)} )}
{/* 비허가 어구 그룹 섹션 */} </div>
{gearGroupList.length > 0 && ( )}
<>
<div style={{ {/* ── 조업구역내 어구 그룹 섹션 ── */}
borderTop: '1px solid rgba(249,115,22,0.25)', {inZoneGearGroups.length > 0 && (
margin: '6px 10px', <>
}} /> <div style={{ ...headerStyle, borderTop: '1px solid rgba(220,38,38,0.35)' }} onClick={() => toggleSection('inZone')}>
<div style={{ <span style={{ fontWeight: 700, color: '#dc2626', letterSpacing: 0.3 }}>
padding: '2px 10px 4px', ({inZoneGearGroups.length})
fontSize: 10, </span>
color: '#f97316', <button style={toggleButtonStyle} aria-label="조업구역내 어구 접기/펴기">
fontWeight: 700, {sectionExpanded.inZone ? '▲' : '▼'}
letterSpacing: 0.3, </button>
}}> </div>
({gearGroupList.length}) {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> </div>
{gearGroupList.map(({ name, parent, gears }) => { )}
</>
)}
{/* ── 비허가 어구 그룹 섹션 ── */}
{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; const isOpen = expandedGearGroup === name;
return ( return (
<div key={name}> <div key={name} id={`gear-row-${name}`}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -967,10 +1042,11 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
</div> </div>
); );
})} })}
</> </div>
)} )}
</div> </>
)} )}
</div>
</div> </div>
</> </>
); );

파일 보기

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

파일 보기

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