feat: 중국어선감시 탭 기능 강화 — 선박필터·수역분류·패널3섹션·백엔드윈도우

- cnFishing ON 시 CN 어선 + 어구 패턴 선박만 표시 (useKoreaFilters 통합)
- cnFishing ON 시 조업수역 Ⅰ~Ⅳ 폴리곤 동시 표시 (FishingZoneLayer)
- FleetClusterLayer 마운트 조건 완화 (clusters 의존 제거)
- 어구 그룹 수역 내/외 분류 (classifyFishingZone 기반)
  - 수역 내: 붉은색 폴리곤(#dc2626), '조업구역내 어구' 섹션
  - 수역 외: 오렌지 폴리곤(#f97316), '비허가 어구' 섹션
- 패널 3섹션 독립 접기/펴기 (선단 현황 / 조업구역내 어구 / 비허가 어구)
- 폴리곤 클릭·zoom 시 해당 어구 행 자동 스크롤
- 백엔드 vessel-analysis 조회 윈도우 1h → 2h 확대
This commit is contained in:
htlee 2026-03-23 09:09:34 +09:00
부모 83c0281710
커밋 98f3b6a59c
5개의 변경된 파일152개의 추가작업 그리고 68개의 파일을 삭제

파일 보기

@ -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)) {

파일 보기

@ -238,6 +238,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap,
koreaLayers.cnFishing,
);
const toggleLayer = useCallback((key: keyof LayerVisibility) => {

파일 보기

@ -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,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);
@ -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>
)}
{/* ── 조업구역내 어구 그룹 섹션 ── */}
{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',
@ -967,10 +1042,11 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
</>
);

파일 보기

@ -607,14 +607,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}

파일 보기

@ -43,6 +43,7 @@ export function useKoreaFilters(
visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>,
cnFishingOn = false,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
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,