Merge remote-tracking branch 'origin/develop' into refactor/phase1-app-decompose
# Conflicts: # docs/RELEASE-NOTES.md # frontend/src/App.tsx # frontend/src/components/korea/FleetClusterLayer.tsx
This commit is contained in:
커밋
4ee977101b
@ -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)) {
|
||||
|
||||
@ -4,6 +4,24 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-23.2]
|
||||
|
||||
### 추가
|
||||
- 중국어선감시 탭: CN 어선 + 어구 패턴 선박 필터링
|
||||
- 중국어선감시 탭: 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시
|
||||
- 어구 그룹 수역 내/외 분류 (조업구역내 붉은색, 비허가 오렌지)
|
||||
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
|
||||
- 폴리곤 클릭·zoom 시 어구 행 자동 스크롤
|
||||
- localStorage 기반 레이어/필터 상태 영속화 (13개 항목)
|
||||
- AI 분석 닫힘 시 위험도 마커 off
|
||||
|
||||
### 변경
|
||||
- AI 분석 패널 위치 조정 (줌 버튼 간격 확보)
|
||||
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h
|
||||
|
||||
### 수정
|
||||
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
|
||||
|
||||
## [2026-03-23]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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,7 +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 { convexHull, padPolygon, clusterColor } from '../../utils/geometry';
|
||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
|
||||
export interface SelectedGearGroupData {
|
||||
parent: Ship | null;
|
||||
@ -21,14 +21,67 @@ 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;
|
||||
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
||||
}
|
||||
|
||||
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
|
||||
function cross(o: [number, number], a: [number, number], b: [number, number]): number {
|
||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
}
|
||||
|
||||
// Graham scan 기반 볼록 껍질 (반시계 방향)
|
||||
function convexHull(points: [number, number][]): [number, number][] {
|
||||
const n = points.length;
|
||||
if (n < 2) return points.slice();
|
||||
const sorted = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const lower: [number, number][] = [];
|
||||
for (const p of sorted) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
const p = sorted[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(p);
|
||||
}
|
||||
// lower + upper (첫/끝 중복 제거)
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
// 중심에서 각 꼭짓점 방향으로 padding 확장
|
||||
function padPolygon(hull: [number, number][], padding: number): [number, number][] {
|
||||
if (hull.length === 0) return hull;
|
||||
const cx = hull.reduce((s, p) => s + p[0], 0) / hull.length;
|
||||
const cy = hull.reduce((s, p) => s + p[1], 0) / hull.length;
|
||||
return hull.map(([x, y]) => {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len === 0) return [x + padding, y + padding] as [number, number];
|
||||
const scale = (len + padding) / len;
|
||||
return [cx + dx * scale, cy + dy * scale] as [number, number];
|
||||
});
|
||||
}
|
||||
|
||||
// cluster_id 해시 → HSL 색상
|
||||
function clusterColor(id: number): string {
|
||||
const h = (id * 137) % 360;
|
||||
return `hsl(${h}, 80%, 55%)`;
|
||||
}
|
||||
|
||||
// HSL 문자열 → hex 근사 (MapLibre는 hsl() 지원하므로 직접 사용 가능)
|
||||
// GeoJSON feature에 color 속성으로 주입
|
||||
interface ClusterPolygonFeature {
|
||||
type: 'Feature';
|
||||
@ -46,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);
|
||||
@ -133,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];
|
||||
@ -286,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]);
|
||||
@ -298,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];
|
||||
@ -434,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,
|
||||
@ -534,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],
|
||||
}}
|
||||
/>
|
||||
@ -612,27 +692,24 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
|
||||
{/* 선단 목록 패널 */}
|
||||
<div style={panelStyle}>
|
||||
<div style={headerStyle}>
|
||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
||||
선단 현황 ({fleetList.length}개)
|
||||
</span>
|
||||
<button
|
||||
style={toggleButtonStyle}
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
|
||||
>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{fleetList.length === 0 ? (
|
||||
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
||||
선단 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
fleetList.map(({ id, mmsiList }) => {
|
||||
<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} aria-label="선단 현황 접기/펴기">
|
||||
{sectionExpanded.fleet ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{sectionExpanded.fleet && (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{fleetList.length === 0 ? (
|
||||
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
||||
선단 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
fleetList.map(({ id, mmsiList }) => {
|
||||
const company = companies.get(id);
|
||||
const companyName = company?.nameCn ?? `선단 #${id}`;
|
||||
const color = clusterColor(id);
|
||||
@ -786,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>
|
||||
)}
|
||||
|
||||
{/* ── 조업구역내 어구 그룹 섹션 ── */}
|
||||
{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>
|
||||
{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;
|
||||
return (
|
||||
<div key={name}>
|
||||
<div key={name} id={`gear-row-${name}`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@ -915,10 +1042,11 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -35,6 +35,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 {
|
||||
@ -151,6 +152,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
}, []);
|
||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||
@ -616,14 +618,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}
|
||||
@ -647,7 +649,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
zoneLabelsLayer,
|
||||
...selectedGearLayers,
|
||||
...selectedFleetLayers,
|
||||
...analysisDeckLayers,
|
||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||
].filter(Boolean)} />
|
||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||
{staticPickInfo && (
|
||||
@ -743,6 +745,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,
|
||||
|
||||
68
frontend/src/hooks/useLocalStorage.ts
Normal file
68
frontend/src/hooks/useLocalStorage.ts
Normal file
@ -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];
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user