feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선 #211

병합
htlee feature/deckgl-ship-migration 에서 develop 로 5 commits 를 머지했습니다 2026-03-31 15:59:07 +09:00
22개의 변경된 파일2399개의 추가작업 그리고 633개의 파일을 삭제

파일 보기

@ -14,6 +14,7 @@ public class GroupPolygonDto {
private String groupType; private String groupType;
private String groupKey; private String groupKey;
private String groupLabel; private String groupLabel;
private int subClusterId;
private String snapshotTime; private String snapshotTime;
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON) private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
private double centerLat; private double centerLat;

파일 보기

@ -28,7 +28,7 @@ public class GroupPolygonService {
private volatile long lastCacheTime = 0; private volatile long lastCacheTime = 0;
private static final String LATEST_GROUPS_SQL = """ private static final String LATEST_GROUPS_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color
@ -38,7 +38,7 @@ public class GroupPolygonService {
"""; """;
private static final String GROUP_DETAIL_SQL = """ private static final String GROUP_DETAIL_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color
@ -49,7 +49,7 @@ public class GroupPolygonService {
"""; """;
private static final String GROUP_HISTORY_SQL = """ private static final String GROUP_HISTORY_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color
@ -59,21 +59,27 @@ public class GroupPolygonService {
"""; """;
private static final String GROUP_CORRELATIONS_SQL = """ private static final String GROUP_CORRELATIONS_SQL = """
SELECT s.target_mmsi, s.target_type, s.target_name, WITH best_scores AS (
s.current_score, s.streak_count, s.observation_count, SELECT DISTINCT ON (m.id, s.target_mmsi)
s.freeze_state, s.shadow_bonus_total, s.target_mmsi, s.target_type, s.target_name,
r.proximity_ratio, r.visit_score, r.heading_coherence, s.current_score, s.streak_count, s.observation_count,
m.id AS model_id, m.name AS model_name, m.is_default s.freeze_state, s.shadow_bonus_total,
FROM kcg.gear_correlation_scores s m.id AS model_id, m.name AS model_name, m.is_default
JOIN kcg.correlation_param_models m ON s.model_id = m.id FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
ORDER BY m.id, s.target_mmsi, s.current_score DESC
)
SELECT bs.*,
r.proximity_ratio, r.visit_score, r.heading_coherence
FROM best_scores bs
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT proximity_ratio, visit_score, heading_coherence SELECT proximity_ratio, visit_score, heading_coherence
FROM kcg.gear_correlation_raw_metrics FROM kcg.gear_correlation_raw_metrics
WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi WHERE group_key = ? AND target_mmsi = bs.target_mmsi
ORDER BY observed_at DESC LIMIT 1 ORDER BY observed_at DESC LIMIT 1
) r ON TRUE ) r ON TRUE
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE ORDER BY bs.model_id, bs.current_score DESC
ORDER BY m.is_default DESC, s.current_score DESC
"""; """;
private static final String GEAR_STATS_SQL = """ private static final String GEAR_STATS_SQL = """
@ -121,7 +127,7 @@ public class GroupPolygonService {
row.put("modelName", rs.getString("model_name")); row.put("modelName", rs.getString("model_name"));
row.put("isDefault", rs.getBoolean("is_default")); row.put("isDefault", rs.getBoolean("is_default"));
return row; return row;
}, groupKey, minScore); }, groupKey, minScore, groupKey);
} catch (Exception e) { } catch (Exception e) {
log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage()); log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage());
return List.of(); return List.of();
@ -162,6 +168,7 @@ public class GroupPolygonService {
/** /**
* 특정 그룹의 시간별 히스토리. * 특정 그룹의 시간별 히스토리.
* sub_cluster_id 포함하여 raw 반환 프론트에서 서브클러스터별 독립 center trail 구성.
*/ */
public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) { public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) {
return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours)); return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours));
@ -192,6 +199,7 @@ public class GroupPolygonService {
.groupType(rs.getString("group_type")) .groupType(rs.getString("group_type"))
.groupKey(rs.getString("group_key")) .groupKey(rs.getString("group_key"))
.groupLabel(rs.getString("group_label")) .groupLabel(rs.getString("group_label"))
.subClusterId(rs.getInt("sub_cluster_id"))
.snapshotTime(rs.getString("snapshot_time")) .snapshotTime(rs.getString("snapshot_time"))
.polygon(polygonObj) .polygon(polygonObj)
.centerLat(rs.getDouble("center_lat")) .centerLat(rs.getDouble("center_lat"))

파일 보기

@ -4,6 +4,33 @@
## [Unreleased] ## [Unreleased]
### 추가
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
- 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원)
- 선박 호버 툴팁 (이름, MMSI, 위치, 속도, 수신시각)
- 리플레이 집중 모드 — 주변 라이브 정보 숨김 토글
- 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시)
- 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정)
- 서브클러스터별 독립 center trail (PathLayer 색상 구분)
- 리플레이 전체 구간 멤버 목록 (allHistoryMembers)
### 변경
- 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비)
- 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
- 모델 패널/재생 컨트롤러 레이아웃: 좌측 패널~우측 패널 사이 중앙 배치
### 수정
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
- 한국 국적 선박(440/441) 어구 오탐 제외
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
- 리플레이 종료/탭 off 시 deck.gl 레이어 + gearReplayStore 완전 초기화
- 어구 폴리곤 호버 하이라이트 추가
### 기타
- DB 마이그레이션: sub_cluster_id 컬럼 추가 + '#N' 데이터 변환 + UNIQUE 제약 변경
## [2026-03-31] ## [2026-03-31]
### 추가 ### 추가

1
frontend/.gitignore vendored Normal file
파일 보기

@ -0,0 +1 @@
.claude/worktrees/

파일 보기

@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState, useMemo, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GearCorrelationItem } from '../../services/vesselAnalysis'; import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import type { MemberInfo } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
@ -44,80 +46,126 @@ const CorrelationPanel = ({
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null); const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
const activeModelTip = pinnedModelTip ?? hoveredModelTip; const activeModelTip = pinnedModelTip ?? hoveredModelTip;
// Compute identity data from groupPolygons // Card expand state
const allGroups = groupPolygons const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: []; // Card ref map for tooltip positioning
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const identityVessels = group?.members.filter(m => m.isParent) ?? []; const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => {
const identityGear = group?.members.filter(m => !m.isParent) ?? []; if (el) cardRefs.current.set(model, el);
else cardRefs.current.delete(model);
}, []);
const toggleCardExpand = (model: string) => {
setExpandedCards(prev => {
const next = new Set(prev);
if (next.has(model)) next.delete(model); else next.add(model);
return next;
});
};
// Identity 목록: 리플레이 활성 시 전체 구간 멤버, 아닐 때 현재 스냅샷 멤버
const allHistoryMembers = useGearReplayStore(s => s.allHistoryMembers);
const { identityVessels, identityGear } = useMemo(() => {
if (historyActive && allHistoryMembers.length > 0) {
return {
identityVessels: allHistoryMembers.filter(m => m.isParent),
identityGear: allHistoryMembers.filter(m => !m.isParent),
};
}
if (!groupPolygons || !selectedGearGroup) return { identityVessels: [] as MemberInfo[], identityGear: [] as MemberInfo[] };
const allGear = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const matches = allGear.filter(g => g.groupKey === selectedGearGroup);
const seen = new Set<string>();
const members: MemberInfo[] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } }
return {
identityVessels: members.filter(m => m.isParent),
identityGear: members.filter(m => !m.isParent),
};
}, [historyActive, allHistoryMembers, groupPolygons, selectedGearGroup]);
// Suppress unused MODEL_ORDER warning — used for ordering checks // Suppress unused MODEL_ORDER warning — used for ordering checks
void _MODEL_ORDER; void _MODEL_ORDER;
// Common card styles // Common card styles
const CARD_WIDTH = 180;
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: 'rgba(12,24,37,0.95)', background: 'rgba(12,24,37,0.95)',
borderRadius: 6, borderRadius: 6,
minWidth: 160, width: CARD_WIDTH,
maxWidth: 200, minWidth: CARD_WIDTH,
flexShrink: 0, flexShrink: 0,
border: '1px solid rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.08)',
position: 'relative', position: 'relative',
}; };
const cardScrollStyle: React.CSSProperties = { const CARD_COLLAPSED_H = 200;
padding: '6px 8px', const CARD_EXPANDED_H = 500;
maxHeight: 200,
overflowY: 'auto', const cardFooterStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 8px 6px',
cursor: 'pointer', userSelect: 'none',
borderTop: '1px solid rgba(255,255,255,0.06)',
}; };
// Model title tooltip hover/click handlers const getCardBodyStyle = (model: string): React.CSSProperties => ({
padding: '6px 8px 4px',
maxHeight: expandedCards.has(model) ? CARD_EXPANDED_H : CARD_COLLAPSED_H,
overflowY: 'auto',
transition: 'max-height 0.2s ease',
});
// Model title tooltip: hover → show, right-click → pin
const handleTipHover = (model: string) => { const handleTipHover = (model: string) => {
if (!pinnedModelTip) setHoveredModelTip(model); if (!pinnedModelTip) setHoveredModelTip(model);
}; };
const handleTipLeave = () => { const handleTipLeave = () => {
if (!pinnedModelTip) setHoveredModelTip(null); if (!pinnedModelTip) setHoveredModelTip(null);
}; };
const handleTipClick = (model: string) => { const handleTipContextMenu = (e: React.MouseEvent, model: string) => {
e.preventDefault();
setPinnedModelTip(prev => prev === model ? null : model); setPinnedModelTip(prev => prev === model ? null : model);
setHoveredModelTip(null); setHoveredModelTip(null);
}; };
const renderModelTip = (model: string, color: string) => { // 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음)
if (activeModelTip !== model) return null; const renderFloatingTip = () => {
const desc = MODEL_DESC[model]; if (!activeModelTip) return null;
const desc = MODEL_DESC[activeModelTip];
if (!desc) return null; if (!desc) return null;
const el = cardRefs.current.get(activeModelTip);
if (!el) return null;
const rect = el.getBoundingClientRect();
const color = MODEL_COLORS[activeModelTip] ?? '#94a3b8';
return ( return (
<div style={{ <div style={{
position: 'absolute', position: 'fixed',
bottom: '100%', left: rect.left,
left: 0, top: rect.top - 4,
marginBottom: 4, transform: 'translateY(-100%)',
padding: '6px 10px', padding: '6px 10px',
background: 'rgba(15,23,42,0.97)', background: 'rgba(15,23,42,0.97)',
border: `1px solid ${color}66`, border: `1px solid ${color}66`,
borderRadius: 5, borderRadius: 5,
fontSize: 9, fontSize: 9,
color: '#e2e8f0', color: '#e2e8f0',
zIndex: 30, zIndex: 50,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
boxShadow: '0 2px 12px rgba(0,0,0,0.6)', boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
pointerEvents: pinnedModelTip === model ? 'auto' : 'none', pointerEvents: pinnedModelTip ? 'auto' : 'none',
fontFamily: FONT_MONO,
}}> }}>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div> <div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
{desc.details.map((line, i) => ( {desc.details.map((line, i) => (
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div> <div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
))} ))}
{pinnedModelTip === model && ( {pinnedModelTip && (
<div style={{ <div style={{
color: '#64748b', color: '#64748b', fontSize: 8, marginTop: 4,
fontSize: 8, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 3,
marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: 3,
}}> }}>
</div> </div>
)} )}
</div> </div>
@ -142,7 +190,7 @@ const CorrelationPanel = ({
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName; const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
return ( return (
<div <div
key={c.targetMmsi} key={`${modelName}-${c.targetMmsi}`}
style={{ style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3, fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer', padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
@ -172,11 +220,11 @@ const CorrelationPanel = ({
}; };
// Member row renderer (identity model — no score, independent hover) // Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string) => { const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity'; const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
return ( return (
<div <div
key={m.mmsi} key={`${keyPrefix}-${m.mmsi}`}
style={{ style={{
fontSize: 9, fontSize: 9,
marginBottom: 1, marginBottom: 1,
@ -203,27 +251,25 @@ const CorrelationPanel = ({
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: historyActive ? 120 : 20, bottom: historyActive ? 120 : 20,
left: 'calc(50% - 210px)', left: 'calc(50% + 100px)',
transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
maxWidth: 1320,
display: 'flex', display: 'flex',
gap: 6, gap: 6,
alignItems: 'flex-start', alignItems: 'flex-end',
zIndex: 21, zIndex: 21,
fontFamily: FONT_MONO, fontFamily: FONT_MONO,
fontSize: 10, fontSize: 10,
color: '#e2e8f0', color: '#e2e8f0',
pointerEvents: 'auto', pointerEvents: 'auto',
maxWidth: 'calc(100vw - 40px)',
overflowX: 'auto',
overflowY: 'visible',
}}> }}>
{/* 고정: 토글 패널 */} {/* 고정: 토글 패널 (스크롤 밖) */}
<div style={{ <div style={{
background: 'rgba(12,24,37,0.95)', background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(249,115,22,0.3)', border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8, borderRadius: 8,
padding: '8px 10px', padding: '8px 10px',
position: 'sticky',
left: 0,
minWidth: 165, minWidth: 165,
flexShrink: 0, flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
@ -268,20 +314,16 @@ const CorrelationPanel = ({
})} })}
</div> </div>
{/* 스크롤 영역: 모델 카드들 */}
<div style={{
display: 'flex', gap: 6, alignItems: 'flex-end',
overflowX: 'auto', overflowY: 'visible', flex: 1, minWidth: 0,
}}>
{/* 이름 기반 카드 (체크 시) */} {/* 이름 기반 카드 (체크 시) */}
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && ( {enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
<div style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)' }}> <div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
{renderModelTip('identity', '#f97316')} <div style={getCardBodyStyle('identity')}>
<div style={cardScrollStyle}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
onMouseEnter={() => handleTipHover('identity')}
onMouseLeave={handleTipLeave}
onClick={() => handleTipClick('identity')}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316' }}> </span>
</div>
{identityVessels.length > 0 && ( {identityVessels.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div> <div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div>
@ -291,13 +333,21 @@ const CorrelationPanel = ({
{identityGear.length > 0 && ( {identityGear.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div> <div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div>
{identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))} {identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
{identityGear.length > 12 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{identityGear.length - 12} </div>
)}
</> </>
)} )}
</div> </div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand('identity')}
onMouseEnter={() => handleTipHover('identity')}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, 'identity')}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316', flex: 1 }}> </span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has('identity') ? '▾' : '▴'}</span>
</div>
</div> </div>
)} )}
@ -309,40 +359,37 @@ const CorrelationPanel = ({
const gears = items.filter(c => c.targetType !== 'VESSEL'); const gears = items.filter(c => c.targetType !== 'VESSEL');
if (vessels.length === 0 && gears.length === 0) return null; if (vessels.length === 0 && gears.length === 0) return null;
return ( return (
<div key={m.name} style={{ ...cardStyle, borderColor: `${color}40` }}> <div key={m.name} ref={(el) => setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}>
{renderModelTip(m.name, color)} <div style={getCardBodyStyle(m.name)}>
<div style={cardScrollStyle}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
onMouseEnter={() => handleTipHover(m.name)}
onMouseLeave={handleTipLeave}
onClick={() => handleTipClick(m.name)}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color }}>{m.name}{m.isDefault ? '*' : ''}</span>
</div>
{vessels.length > 0 && ( {vessels.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div> <div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div>
{vessels.slice(0, 10).map(c => renderRow(c, color, m.name))} {vessels.map(c => renderRow(c, color, m.name))}
{vessels.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{vessels.length - 10} </div>
)}
</> </>
)} )}
{gears.length > 0 && ( {gears.length > 0 && (
<> <>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({gears.length})</div> <div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({gears.length})</div>
{gears.slice(0, 10).map(c => renderRow(c, color, m.name))} {gears.map(c => renderRow(c, color, m.name))}
{gears.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{gears.length - 10} </div>
)}
</> </>
)} )}
</div> </div>
<div
style={cardFooterStyle}
onClick={() => toggleCardExpand(m.name)}
onMouseEnter={() => handleTipHover(m.name)}
onMouseLeave={handleTipLeave}
onContextMenu={(e) => handleTipContextMenu(e, m.name)}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ fontSize: 9, fontWeight: 700, color, flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has(m.name) ? '▾' : '▴'}</span>
</div>
</div> </div>
); );
})} })}
</div>{/* 스크롤 영역 끝 */}
{renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
</div> </div>
); );
}; };

파일 보기

@ -1,11 +1,82 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useMap } from 'react-map-gl/maplibre'; import type { Layer as DeckLayer } from '@deck.gl/core';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import type { Ship, VesselAnalysisDto } from '../../types'; import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis'; import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis';
import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useFleetClusterDeckLayers } from '../../hooks/useFleetClusterDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import type { PickedPolygonFeature } from '../../hooks/useFleetClusterDeckLayers';
import { useFontScale } from '../../hooks/useFontScale';
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
/** 서브클러스터 center 시계열 (독립 추적용) */
export interface SubClusterCenter {
subClusterId: number;
path: [number, number][]; // [lon, lat] 시간순
timestamps: number[]; // epoch ms
}
/**
* :
* 1. frames: 시간별 ( )
* 2. subClusterCenters: 서브클러스터별 center (PathLayer용)
*/
function splitAndMergeHistory(history: GroupPolygonDto[]) {
// 시간순 정렬 (오래된 것 먼저)
const sorted = [...history].sort((a, b) =>
new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(),
);
// 1. 서브클러스터별 center 궤적 수집
const centerMap = new Map<number, { path: [number, number][]; timestamps: number[] }>();
for (const h of sorted) {
const sid = h.subClusterId ?? 0;
const entry = centerMap.get(sid) ?? { path: [], timestamps: [] };
entry.path.push([h.centerLon, h.centerLat]);
entry.timestamps.push(new Date(h.snapshotTime).getTime());
centerMap.set(sid, entry);
}
const subClusterCenters: SubClusterCenter[] = [...centerMap.entries()].map(
([subClusterId, data]) => ({ subClusterId, ...data }),
);
// 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환)
const byTime = new Map<string, GroupPolygonDto[]>();
for (const h of sorted) {
const list = byTime.get(h.snapshotTime) ?? [];
list.push(h);
byTime.set(h.snapshotTime, list);
}
const frames: GroupPolygonDto[] = [];
for (const [, items] of byTime) {
if (items.length === 1) {
frames.push(items[0]);
continue;
}
const seen = new Set<string>();
const allMembers: GroupPolygonDto['members'] = [];
for (const item of items) {
for (const m of item.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
}
}
const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
frames.push({
...biggest,
subClusterId: 0,
members: allMembers,
memberCount: allMembers.length,
// 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center)
centerLat: biggest.centerLat,
centerLon: biggest.centerLon,
});
}
return { frames: fillGapFrames(frames), subClusterCenters };
}
// ── 분리된 모듈 ── // ── 분리된 모듈 ──
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
@ -29,10 +100,13 @@ interface Props {
onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void; onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void;
onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void; onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void;
groupPolygons?: UseGroupPolygonsResult; groupPolygons?: UseGroupPolygonsResult;
zoomScale?: number;
onDeckLayersChange?: (layers: DeckLayer[]) => void;
} }
export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) { export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons, zoomScale = 1, onDeckLayersChange }: Props) {
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
const { fontScale } = useFontScale();
// ── 선단/어구 패널 상태 ── // ── 선단/어구 패널 상태 ──
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map()); const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
@ -40,6 +114,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const [activeSection, setActiveSection] = useState<string | null>('fleet'); const [activeSection, setActiveSection] = useState<string | null>('fleet');
const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key); const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null); const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [hoveredGearName, setHoveredGearName] = useState<string | 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);
@ -60,9 +135,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
// ── 맵 + ref ── // ── 맵 + ref ──
const { current: mapRef } = useMap();
const registeredRef = useRef(false);
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
// ── 초기 로드 ── // ── 초기 로드 ──
useEffect(() => { useEffect(() => {
@ -78,9 +150,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
]); ]);
// 2. 데이터 전처리 // 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center
const sorted = history.reverse(); const { frames: filled, subClusterCenters } = splitAndMergeHistory(history);
const filled = fillGapFrames(sorted);
const corrData = corrRes.items; const corrData = corrRes.items;
const corrTracks = trackRes.vessels; const corrTracks = trackRes.vessels;
@ -105,12 +176,36 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 // 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작
const store = useGearReplayStore.getState(); const store = useGearReplayStore.getState();
store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels);
// 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
const seen = new Set<string>();
const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = [];
for (const f of history) {
for (const m of f.members) {
if (!seen.has(m.mmsi)) {
seen.add(m.mmsi);
allHistoryMembers.push({ mmsi: m.mmsi, name: m.name, isParent: m.isParent });
}
}
}
useGearReplayStore.setState({ subClusterCenters, allHistoryMembers });
store.play(); store.play();
}; };
const closeHistory = useCallback(() => { const closeHistory = useCallback(() => {
useGearReplayStore.getState().reset(); useGearReplayStore.getState().reset();
setSelectedGearGroup(null); setSelectedGearGroup(null);
setCorrelationData([]);
setCorrelationTracks([]);
setEnabledVessels(new Set());
}, []);
// ── cnFishing 탭 off (unmount) 시 재생 상태 + deck layers 전체 초기화 ──
useEffect(() => {
return () => {
useGearReplayStore.getState().reset();
onDeckLayersChange?.([]);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ── // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ──
@ -140,141 +235,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [historyActive, closeHistory]); }, [historyActive, closeHistory]);
// ── 맵 이벤트 등록 ── // 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover)
useEffect(() => {
const map = mapRef?.getMap();
if (!map || registeredRef.current) return;
const fleetLayers = ['fleet-cluster-fill-layer'];
const gearLayers = ['gear-cluster-fill-layer'];
const allLayers = [...fleetLayers, ...gearLayers];
const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; };
const onFleetEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const cid = feat.properties?.clusterId as number | undefined;
if (cid != null) {
setHoveredFleetId(cid);
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid });
}
};
const onFleetLeave = () => {
setCursor('');
setHoveredFleetId(null);
setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev);
};
const handleFleetSelect = (cid: number) => {
const d = dataRef.current;
setExpandedFleet(prev => prev === cid ? null : cid);
setActiveSection('fleet');
const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
if (!group || group.members.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
loadHistory(String(cid));
};
const handleGearGroupZoomFromMap = (name: string) => {
const d = dataRef.current;
setSelectedGearGroup(prev => prev === name ? null : name);
setExpandedGearGroup(name);
const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name);
setActiveSection(isInZone ? 'inZone' : 'outZone');
requestAnimationFrame(() => {
setTimeout(() => {
document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
});
const allGroups = d.groupPolygons ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] : [];
const group = allGroups.find(g => g.groupKey === name);
if (!group || group.members.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
loadHistory(name);
};
const onPolygonClick = (e: MapLayerMouseEvent) => {
const features = map.queryRenderedFeatures(e.point, { layers: allLayers });
if (features.length === 0) return;
const seen = new Set<string>();
const candidates: PickerCandidate[] = [];
for (const f of features) {
const cid = f.properties?.clusterId as number | undefined;
const gearName = f.properties?.name as string | undefined;
if (cid != null) {
const key = `fleet-${cid}`;
if (seen.has(key)) continue;
seen.add(key);
const d = dataRef.current;
const g = d.groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === cid);
candidates.push({ name: g?.groupLabel ?? `선단 #${cid}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: cid });
} else if (gearName) {
if (seen.has(gearName)) continue;
seen.add(gearName);
candidates.push({ name: gearName, count: f.properties?.gearCount ?? 0, inZone: f.properties?.inZone === 1, isFleet: false });
}
}
if (candidates.length === 1) {
const c = candidates[0];
if (c.isFleet && c.clusterId != null) handleFleetSelect(c.clusterId);
else handleGearGroupZoomFromMap(c.name);
} else if (candidates.length > 1) {
setGearPickerPopup({ lng: e.lngLat.lng, lat: e.lngLat.lat, candidates });
}
};
const onGearEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const name = feat.properties?.name as string | undefined;
if (name) setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name });
};
const onGearLeave = () => {
setCursor('');
setHoverTooltip(prev => prev?.type === 'gear' ? null : prev);
};
const register = () => {
const ready = allLayers.every(id => map.getLayer(id));
if (!ready) return;
registeredRef.current = true;
for (const id of fleetLayers) {
map.on('mouseenter', id, onFleetEnter);
map.on('mouseleave', id, onFleetLeave);
map.on('click', id, onPolygonClick);
}
for (const id of gearLayers) {
map.on('mouseenter', id, onGearEnter);
map.on('mouseleave', id, onGearLeave);
map.on('click', id, onPolygonClick);
}
};
register();
if (!registeredRef.current) {
const interval = setInterval(() => {
register();
if (registeredRef.current) clearInterval(interval);
}, 500);
return () => clearInterval(interval);
}
}, [mapRef]);
// ── ships map ── // ── ships map ──
const shipMap = useMemo(() => { const shipMap = useMemo(() => {
@ -283,7 +244,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return m; return m;
}, [ships]); }, [ships]);
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
// ── 부모 콜백 동기화: 어구 그룹 선택 ── // ── 부모 콜백 동기화: 어구 그룹 선택 ──
useEffect(() => { useEffect(() => {
@ -293,11 +254,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return; return;
} }
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const matches = allGroups.filter(g => g.groupKey === selectedGearGroup);
if (!group) { onSelectedGearChange?.(null); return; } if (matches.length === 0) { onSelectedGearChange?.(null); return; }
const parent = group.members.find(m => m.isParent); // 서브클러스터 멤버 합산
const gears = group.members.filter(m => !m.isParent); const seen = new Set<string>();
const toShip = (m: typeof group.members[0]): Ship => ({ const allMembers: typeof matches[0]['members'] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } }
const parent = allMembers.find(m => m.isParent);
const gears = allMembers.filter(m => !m.isParent);
const toShip = (m: typeof allMembers[0]): Ship => ({
mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon, mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
heading: m.cog, speed: m.sog, course: m.cog, heading: m.cog, speed: m.sog, course: m.cog,
category: 'fishing', lastSeen: Date.now(), category: 'fishing', lastSeen: Date.now(),
@ -360,6 +325,97 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
enabledModels, enabledVessels, hoveredMmsi, enabledModels, enabledVessels, hoveredMmsi,
}); });
// ── deck.gl 이벤트 콜백 ──
const handleDeckPolygonClick = useCallback((features: PickedPolygonFeature[], coordinate: [number, number]) => {
if (features.length === 0) return;
if (features.length === 1) {
const c = features[0];
if (c.type === 'fleet' && c.clusterId != null) {
setExpandedFleet(prev => prev === c.clusterId ? null : c.clusterId!);
setActiveSection('fleet');
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === c.clusterId);
if (group && group.members.length > 0) {
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}
loadHistory(String(c.clusterId));
} else if (c.type === 'gear' && c.name) {
setSelectedGearGroup(prev => prev === c.name ? null : c.name!);
setExpandedGearGroup(c.name);
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === c.name);
setActiveSection(isInZone ? 'inZone' : 'outZone');
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const group = allGroups.find(g => g.groupKey === c.name);
if (group && group.members.length > 0) {
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}
loadHistory(c.name);
}
} else {
// 겹친 폴리곤 → 피커 팝업
const candidates: PickerCandidate[] = features.map(f => {
if (f.type === 'fleet') {
const g = groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === f.clusterId);
return { name: g?.groupLabel ?? `선단 #${f.clusterId}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: f.clusterId };
}
return { name: f.name ?? '', count: f.gearCount ?? 0, inZone: !!f.inZone, isFleet: false };
});
setGearPickerPopup({ lng: coordinate[0], lat: coordinate[1], candidates });
}
}, [groupPolygons, onFleetZoom, loadHistory]);
const handleDeckPolygonHover = useCallback((info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => {
if (info) {
if (info.type === 'fleet') {
setHoveredFleetId(info.id as number);
setHoveredGearName(null);
} else {
setHoveredFleetId(null);
setHoveredGearName(info.id as string);
}
setHoverTooltip({ lng: info.lng, lat: info.lat, type: info.type, id: info.id });
} else {
setHoveredFleetId(null);
setHoveredGearName(null);
setHoverTooltip(null);
}
}, []);
// ── deck.gl 레이어 빌드 ──
const focusMode = useGearReplayStore(s => s.focusMode);
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
const fleetDeckLayers = useFleetClusterDeckLayers(geo, {
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup: hoveredGearName,
enabledModels,
historyActive,
hasCorrelationTracks: correlationTracks.length > 0,
zoomScale,
zoomLevel,
fontScale: fontScale.analysis,
focusMode,
onPolygonClick: handleDeckPolygonClick,
onPolygonHover: handleDeckPolygonHover,
});
useEffect(() => {
onDeckLayersChange?.(fleetDeckLayers);
}, [fleetDeckLayers, onDeckLayersChange]);
// ── 어구 그룹 데이터 ── // ── 어구 그룹 데이터 ──
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? []; const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];
@ -421,26 +477,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const selectedGroupMemberCount = useMemo(() => { const selectedGroupMemberCount = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return 0; if (!selectedGearGroup || !groupPolygons) return 0;
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
return allGroups.find(g => g.groupKey === selectedGearGroup)?.memberCount ?? 0; return allGroups.filter(g => g.groupKey === selectedGearGroup).reduce((sum, g) => sum + g.memberCount, 0);
}, [selectedGearGroup, groupPolygons]); }, [selectedGearGroup, groupPolygons]);
return ( return (
<> <>
{/* ── 맵 레이어 ── */} {/* ── 맵 레이어 ── */}
<FleetClusterMapLayers <FleetClusterMapLayers
geo={geo}
selectedGearGroup={selectedGearGroup} selectedGearGroup={selectedGearGroup}
hoveredMmsi={hoveredMmsi}
enabledModels={enabledModels}
expandedFleet={expandedFleet} expandedFleet={expandedFleet}
historyActive={historyActive}
hoverTooltip={hoverTooltip} hoverTooltip={hoverTooltip}
gearPickerPopup={gearPickerPopup} gearPickerPopup={gearPickerPopup}
pickerHoveredGroup={pickerHoveredGroup} pickerHoveredGroup={pickerHoveredGroup}
groupPolygons={groupPolygons} groupPolygons={groupPolygons}
companies={companies} companies={companies}
analysisMap={analysisMap} analysisMap={analysisMap}
hasCorrelationTracks={correlationTracks.length > 0}
onPickerHover={setPickerHoveredGroup} onPickerHover={setPickerHoveredGroup}
onPickerSelect={handlePickerSelect} onPickerSelect={handlePickerSelect}
onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }} onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }}

파일 보기

@ -1,4 +1,4 @@
import { Source, Layer, Popup } from 'react-map-gl/maplibre'; import { Popup } from 'react-map-gl/maplibre';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import type { FleetCompany } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types'; import type { VesselAnalysisDto } from '../../types';
@ -8,16 +8,10 @@ import type {
GearPickerPopupState, GearPickerPopupState,
PickerCandidate, PickerCandidate,
} from './fleetClusterTypes'; } from './fleetClusterTypes';
import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
interface FleetClusterMapLayersProps { interface FleetClusterMapLayersProps {
geo: FleetClusterGeoJsonResult;
selectedGearGroup: string | null; selectedGearGroup: string | null;
hoveredMmsi: string | null;
enabledModels: Set<string>;
expandedFleet: number | null; expandedFleet: number | null;
historyActive: boolean;
// Popup/tooltip state // Popup/tooltip state
hoverTooltip: HoverTooltipState | null; hoverTooltip: HoverTooltipState | null;
gearPickerPopup: GearPickerPopupState | null; gearPickerPopup: GearPickerPopupState | null;
@ -26,199 +20,33 @@ interface FleetClusterMapLayersProps {
groupPolygons: UseGroupPolygonsResult | undefined; groupPolygons: UseGroupPolygonsResult | undefined;
companies: Map<number, FleetCompany>; companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>; analysisMap: Map<string, VesselAnalysisDto>;
// Whether any correlation trails exist (drives conditional render)
hasCorrelationTracks: boolean;
// Callbacks // Callbacks
onPickerHover: (group: string | null) => void; onPickerHover: (group: string | null) => void;
onPickerSelect: (candidate: PickerCandidate) => void; onPickerSelect: (candidate: PickerCandidate) => void;
onPickerClose: () => void; onPickerClose: () => void;
} }
/**
* FleetCluster overlay popups/tooltips.
* All MapLibre Source/Layer rendering has been moved to useFleetClusterDeckLayers (deck.gl).
* This component only renders MapLibre Popup-based overlays (tooltips, picker).
*/
const FleetClusterMapLayers = ({ const FleetClusterMapLayers = ({
geo,
selectedGearGroup, selectedGearGroup,
hoveredMmsi,
enabledModels,
expandedFleet, expandedFleet,
historyActive,
hoverTooltip, hoverTooltip,
gearPickerPopup, gearPickerPopup,
pickerHoveredGroup, pickerHoveredGroup,
groupPolygons, groupPolygons,
companies, companies,
analysisMap, analysisMap,
hasCorrelationTracks,
onPickerHover, onPickerHover,
onPickerSelect, onPickerSelect,
onPickerClose, onPickerClose,
}: FleetClusterMapLayersProps) => { }: FleetClusterMapLayersProps) => {
const {
fleetPolygonGeoJSON,
lineGeoJSON,
hoveredGeoJSON,
gearClusterGeoJson,
memberMarkersGeoJson,
pickerHighlightGeoJson,
operationalPolygons,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
} = geo;
return ( return (
<> <>
{/* 선단 폴리곤 레이어 */}
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
<Layer
id="fleet-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.1,
}}
/>
<Layer
id="fleet-cluster-line-layer"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
}}
/>
</Source>
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
<Layer
id="fleet-cluster-line-only"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 호버 하이라이트 (별도 Source) */}
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
<Layer
id="fleet-cluster-hovered-fill"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
}}
/>
</Source>
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
{selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group?.polygon) return null;
const hlGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: group.polygon,
}],
};
return (
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
</Source>
);
})()}
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
{selectedGearGroup && !historyActive && operationalPolygons.map(op => (
<Source key={`op-${op.modelName}`} id={`gear-op-${op.modelName}`} type="geojson" data={op.geojson}>
<Layer id={`gear-op-fill-${op.modelName}`} type="fill" paint={{
'fill-color': op.color, 'fill-opacity': 0.12,
}} />
<Layer id={`gear-op-line-${op.modelName}`} type="line" paint={{
'line-color': op.color, 'line-width': 2.5, 'line-opacity': 0.8,
'line-dasharray': [6, 3],
}} />
</Source>
))}
{/* 비허가 어구 클러스터 폴리곤 */}
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
<Layer
id="gear-cluster-fill-layer"
type="fill"
paint={{
'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': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
'line-opacity': 0.7,
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */}
<Source id="group-member-markers" type="geojson" data={historyActive ? ({ type: 'FeatureCollection', features: [] } as GeoJSON.FeatureCollection) : memberMarkersGeoJson}>
<Layer
id="group-member-icon"
type="symbol"
layout={{
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'baseSize'], 0.9],
6, ['*', ['get', 'baseSize'], 1.2],
8, ['*', ['get', 'baseSize'], 1.8],
10, ['*', ['get', 'baseSize'], 2.6],
12, ['*', ['get', 'baseSize'], 3.2],
13, ['*', ['get', 'baseSize'], 4.0],
14, ['*', ['get', 'baseSize'], 4.8],
],
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 8, 8, 12, 10],
'text-offset': [0, 1.4],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
}}
paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 0.5,
'text-color': ['get', 'color'],
'text-halo-color': '#000000',
'text-halo-width': 1,
}}
/>
</Source>
{/* 어구 picker 호버 하이라이트 */}
<Source id="gear-picker-highlight" type="geojson" data={pickerHighlightGeoJson}>
<Layer id="gear-picker-highlight-fill" type="fill"
paint={{ 'fill-color': '#ffffff', 'fill-opacity': 0.25 }} />
<Layer id="gear-picker-highlight-line" type="line"
paint={{ 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [3, 2] }} />
</Source>
{/* 어구 다중 선택 팝업 */} {/* 어구 다중 선택 팝업 */}
{gearPickerPopup && ( {gearPickerPopup && (
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat} <Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
@ -242,7 +70,7 @@ const FleetClusterMapLayers = ({
marginBottom: 2, borderRadius: 2, marginBottom: 2, borderRadius: 2,
backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent', backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent',
}}> }}>
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? ' ' : ''}{c.name}</span> <span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '\u2693 ' : ''}{c.name}</span>
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span> <span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
</div> </div>
))} ))}
@ -272,7 +100,7 @@ const FleetClusterMapLayers = ({
const role = dto?.algorithms.fleetRole.role ?? m.role; const role = dto?.algorithms.fleetRole.role ?? m.role;
return ( return (
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}> <div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
{role === 'LEADER' ? '★' : '} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span> {role === 'LEADER' ? '\u2605' : '\u00B7'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
</div> </div>
); );
})} })}
@ -286,10 +114,13 @@ const FleetClusterMapLayers = ({
const allGroups = groupPolygons const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: []; : [];
const group = allGroups.find(g => g.groupKey === name); const matches = allGroups.filter(g => g.groupKey === name);
if (!group) return null; if (matches.length === 0) return null;
const parentMember = group.members.find(m => m.isParent); const seen = new Set<string>();
const gearMembers = group.members.filter(m => !m.isParent); const mergedMembers: typeof matches[0]['members'] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); mergedMembers.push(m); } }
const parentMember = mergedMembers.find(m => m.isParent);
const gearMembers = mergedMembers.filter(m => !m.isParent);
return ( return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat} <Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom" closeButton={false} closeOnClick={false} anchor="bottom"
@ -314,82 +145,6 @@ const FleetClusterMapLayers = ({
} }
return null; return null;
})()} })()}
{/* ── 연관 대상 트레일 + 마커 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && hasCorrelationTracks && (
<Source id="correlation-trails" type="geojson" data={correlationTrailGeoJson}>
<Layer id="correlation-trails-line" type="line" paint={{
'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.6,
'line-dasharray': [6, 3],
}} />
</Source>
)}
{selectedGearGroup && !historyActive && (
<Source id="correlation-vessels" type="geojson" data={correlationVesselGeoJson}>
<Layer id="correlation-vessels-icon" type="symbol" layout={{
'icon-image': ['case', ['==', ['get', 'isVessel'], 1], 'ship-triangle', 'gear-diamond'],
'icon-size': ['case', ['==', ['get', 'isVessel'], 1], 0.7, 0.5],
'icon-rotate': ['case', ['==', ['get', 'isVessel'], 1], ['get', 'cog'], 0],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
}} paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 1,
}} />
<Layer id="correlation-vessels-label" type="symbol" layout={{
'text-field': ['get', 'name'],
'text-size': 8,
'text-offset': [0, 1.5],
'text-allow-overlap': false,
}} paint={{
'text-color': ['get', 'color'],
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}} />
</Source>
)}
{/* ── 모델 배지 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && (
<Source id="model-badges" type="geojson" data={modelBadgesGeoJson}>
{MODEL_ORDER.map((model, i) => (
enabledModels.has(model) ? (
<Layer key={`badge-${model}`} id={`model-badge-${model}`} type="circle"
filter={['==', ['get', `m${i}`], 1]}
paint={{
'circle-radius': 3,
'circle-color': MODEL_COLORS[model] ?? '#94a3b8',
'circle-stroke-width': 0.5,
'circle-stroke-color': 'rgba(0,0,0,0.6)',
'circle-translate': [10 + i * 7, -6],
}}
/>
) : null
))}
</Source>
)}
{/* ── 호버 하이라이트 (비재생 모드) ── */}
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-point" type="geojson" data={hoverHighlightGeoJson}>
<Layer id="hover-highlight-glow" type="circle" paint={{
'circle-radius': 14, 'circle-color': '#ffffff', 'circle-opacity': 0.25,
'circle-blur': 0.8,
}} />
<Layer id="hover-highlight-ring" type="circle" paint={{
'circle-radius': 8, 'circle-color': 'transparent',
'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff',
}} />
</Source>
)}
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-trail" type="geojson" data={hoverHighlightTrailGeoJson}>
<Layer id="hover-highlight-trail-line" type="line" paint={{
'line-color': '#ffffff', 'line-width': 3, 'line-opacity': 0.7,
}} />
</Source>
)}
</> </>
); );
}; };

파일 보기

@ -13,6 +13,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const frameCount = useGearReplayStore(s => s.historyFrames.length); const frameCount = useGearReplayStore(s => s.historyFrames.length);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode);
const progressBarRef = useRef<HTMLInputElement>(null); const progressBarRef = useRef<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null); const progressIndicatorRef = useRef<HTMLDivElement>(null);
@ -46,11 +47,14 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
return ( return (
<div style={{ <div style={{
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', position: 'absolute', bottom: 20,
left: 'calc(50% + 100px)', transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
minWidth: 380, maxWidth: 1320,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4, borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420, boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
}}> }}>
{/* 프로그레스 바 */} {/* 프로그레스 바 */}
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}> <div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
@ -99,6 +103,11 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시"> style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
</button> </button>
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171', borderColor: 'rgba(239,68,68,0.4)' } : btnStyle}
title="집중 모드 — 주변 라이브 정보 숨김">
</button>
<span style={{ color: '#475569', margin: '0 2px' }}>|</span> <span style={{ color: '#475569', margin: '0 2px' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span> <span style={{ color: '#64748b', fontSize: 9 }}></span>
<select <select

파일 보기

@ -13,7 +13,11 @@ import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers'; import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers'; import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { ShipLayer } from '../layers/ShipLayer'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
import maplibregl from 'maplibre-gl';
import { InfraLayer } from './InfraLayer'; import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer'; import { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer'; import { AircraftLayer } from '../layers/AircraftLayer';
@ -215,6 +219,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const mapRef = useRef<MapRef>(null); const mapRef = useRef<MapRef>(null);
const overlayRef = useRef<MapboxOverlay | null>(null); const overlayRef = useRef<MapboxOverlay | null>(null);
const replayLayerRef = useRef<DeckLayer[]>([]); const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const requestRenderRef = useRef<(() => void) | null>(null);
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
fleetClusterLayerRef.current = layers;
requestRenderRef.current?.();
}, []);
const [infra, setInfra] = useState<PowerFacility[]>([]); const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
@ -229,15 +239,18 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
if (z !== zoomRef.current) { if (z !== zoomRef.current) {
zoomRef.current = z; zoomRef.current = z;
setZoomLevel(z); setZoomLevel(z);
useShipDeckStore.getState().setZoomLevel(z);
} }
}, []); }, []);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null); const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []); const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null); const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
const replayFocusMode = useGearReplayStore(s => s.focusMode);
// ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ── // ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
const reactLayersRef = useRef<DeckLayer[]>([]); const reactLayersRef = useRef<DeckLayer[]>([]);
const shipLayerRef = useRef<DeckLayer[]>([]);
type ShipPos = { lng: number; lat: number; course?: number }; type ShipPos = { lng: number; lat: number; course?: number };
const shipsRef = useRef(new globalThis.Map<string, ShipPos>()); const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
// live 선박 위치를 ref에 동기화 (리플레이 fallback용) // live 선박 위치를 ref에 동기화 (리플레이 fallback용)
@ -248,16 +261,62 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const requestRender = useCallback(() => { const requestRender = useCallback(() => {
if (!overlayRef.current) return; if (!overlayRef.current) return;
const focus = useGearReplayStore.getState().focusMode;
overlayRef.current.setProps({ overlayRef.current.setProps({
layers: [...reactLayersRef.current, ...replayLayerRef.current], layers: focus
? [...replayLayerRef.current]
: [...reactLayersRef.current, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current],
}); });
}, []); }, []);
requestRenderRef.current = requestRender;
useShipDeckLayers(shipLayerRef, requestRender);
useGearReplayLayers(replayLayerRef, requestRender, shipsRef); useGearReplayLayers(replayLayerRef, requestRender, shipsRef);
useEffect(() => { useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {}); fetchKoreaInfra().then(setInfra).catch(() => {});
}, []); }, []);
// MapLibre에 ship-triangle/gear-diamond SDF 이미지 등록 (FleetClusterMapLayers에서 사용)
const handleMapLoad = useCallback(() => {
const m = mapRef.current?.getMap() as maplibregl.Map | undefined;
if (!m) return;
if (!m.hasImage('ship-triangle')) {
const s = 64;
const c = document.createElement('canvas'); c.width = s; c.height = s;
const ctx = c.getContext('2d')!;
ctx.beginPath(); ctx.moveTo(s/2,2); ctx.lineTo(s*0.12,s-2); ctx.lineTo(s/2,s*0.62); ctx.lineTo(s*0.88,s-2); ctx.closePath();
ctx.fillStyle = '#fff'; ctx.fill();
const d = ctx.getImageData(0,0,s,s);
m.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
}
if (!m.hasImage('gear-diamond')) {
const s = 64;
const c = document.createElement('canvas'); c.width = s; c.height = s;
const ctx = c.getContext('2d')!;
ctx.beginPath(); ctx.moveTo(s/2,4); ctx.lineTo(s-4,s/2); ctx.lineTo(s/2,s-4); ctx.lineTo(4,s/2); ctx.closePath();
ctx.fillStyle = '#fff'; ctx.fill();
const d = ctx.getImageData(0,0,s,s);
m.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
}
}, []);
// ── shipDeckStore 동기화 ──
useEffect(() => {
useShipDeckStore.getState().setShips(allShipsList);
}, [allShipsList]);
useEffect(() => {
useShipDeckStore.getState().setFilters({
militaryOnly: layers.militaryOnly,
layerVisible: layers.ships,
hiddenShipCategories,
hiddenNationalities,
});
}, [layers.militaryOnly, layers.ships, hiddenShipCategories, hiddenNationalities]);
// Korea 탭에서는 한국선박 강조 OFF (Iran 탭 전용 기능)
// highlightKorean 기본값 false 유지
useEffect(() => { useEffect(() => {
if (flyToTarget && mapRef.current) { if (flyToTarget && mapRef.current) {
mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 }); mapRef.current.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom, duration: 1500 });
@ -289,12 +348,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => { const handleFleetZoom = useCallback((bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => {
mapRef.current?.fitBounds( mapRef.current?.fitBounds(
[[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]], [[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
{ padding: 60, duration: 1500, maxZoom: 12 }, { padding: 60, duration: 1500, maxZoom: 10 },
); );
}, []); }, []);
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing;
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향 // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
const zoomScale = useMemo(() => { const zoomScale = useMemo(() => {
if (zoomLevel <= 4) return 0.8; if (zoomLevel <= 4) return 0.8;
@ -429,7 +486,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl) // 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
const selectedGearLayers = useMemo(() => { const selectedGearLayers = useMemo(() => {
if (!selectedGearData) return []; if (!selectedGearData || replayFocusMode) return [];
const { parent, gears, groupName } = selectedGearData; const { parent, gears, groupName } = selectedGearData;
const layers = []; const layers = [];
@ -507,11 +564,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
} }
return layers; return layers;
}, [selectedGearData, zoomScale, fontScale.analysis]); }, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl) // 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => { const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData) return []; if (!selectedFleetData || replayFocusMode) return [];
const { ships: fleetShips, clusterId } = selectedFleetData; const { ships: fleetShips, clusterId } = selectedFleetData;
if (fleetShips.length === 0) return []; if (fleetShips.length === 0) return [];
@ -526,7 +583,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const color: [number, number, number, number] = [r, g, b, 255]; const color: [number, number, number, number] = [r, g, b, 255];
const fillColor: [number, number, number, number] = [r, g, b, 80]; const fillColor: [number, number, number, number] = [r, g, b, 80];
const result: Layer[] = []; const result: DeckLayer[] = [];
// 소속 선박 — 강조 원형 // 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({ result.push(new ScatterplotLayer({
@ -593,7 +650,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
} }
return result; return result;
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]); }, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
// 분석 결과 deck.gl 레이어 // 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing' const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
@ -601,28 +658,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
: koreaFilters.cnFishing ? 'cnFishing' : koreaFilters.cnFishing ? 'cnFishing'
: null; : null;
// AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시) // shipDeckStore에 분석 상태 동기화
const analysisShipMarkersGeoJson = useMemo(() => { useEffect(() => {
const features: GeoJSON.Feature[] = []; useShipDeckStore.getState().setAnalysis(
if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features }; vesselAnalysis?.analysisMap ?? null,
const allS = allShips ?? ships; analysisActiveFilter,
for (const s of allS) { );
const dto = vesselAnalysis.analysisMap.get(s.mmsi); }, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
if (!dto) continue;
const level = dto.algorithms.riskScore.level;
const color = level === 'CRITICAL' ? '#ef4444' : level === 'HIGH' ? '#f97316' : level === 'MEDIUM' ? '#eab308' : '#22c55e';
const isGear = /^.+?_\d+_\d+_?$/.test(s.name || '') ? 1 : 0;
features.push({
type: 'Feature',
properties: { mmsi: s.mmsi, name: s.name || s.mmsi, cog: s.heading ?? 0, color, baseSize: 0.16, isGear },
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
});
}
return { type: 'FeatureCollection' as const, features };
}, [vesselAnalysis, analysisActiveFilter, allShips, ships]);
const analysisDeckLayers = useAnalysisDeckLayers( const analysisDeckLayers = useAnalysisDeckLayers(
vesselAnalysis?.analysisMap ?? new Map(), vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
allShips ?? ships, allShips ?? ships,
analysisActiveFilter, analysisActiveFilter,
zoomScale, zoomScale,
@ -635,6 +680,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE} mapStyle={MAP_STYLE}
onZoom={handleZoom} onZoom={handleZoom}
onLoad={handleMapLoad}
> >
<NavigationControl position="top-right" /> <NavigationControl position="top-right" />
@ -702,13 +748,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/> />
</Source> </Source>
{layers.ships && <ShipLayer {/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
militaryOnly={layers.militaryOnly}
analysisMap={vesselAnalysis?.analysisMap}
hiddenShipCategories={hiddenShipCategories}
hiddenNationalities={hiddenNationalities}
/>}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom"> <Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
@ -780,13 +820,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined} analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined} clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
groupPolygons={groupPolygons} groupPolygons={groupPolygons}
zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers}
onShipSelect={handleAnalysisShipSelect} onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom} onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData} onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData} onSelectedFleetChange={setSelectedFleetData}
/> />
)} )}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
<AnalysisOverlay <AnalysisOverlay
ships={allShips ?? ships} ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap} analysisMap={vesselAnalysis.analysisMap}
@ -795,42 +837,17 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/> />
)} )}
{/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */} {/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
{analysisActiveFilter && (
<Source id="analysis-ship-markers" type="geojson" data={analysisShipMarkersGeoJson}> {/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */}
<Layer <ShipHoverTooltip />
id="analysis-ship-icon" <ShipPopupOverlay />
type="symbol"
layout={{
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'baseSize'], 1.0],
6, ['*', ['get', 'baseSize'], 1.3],
8, ['*', ['get', 'baseSize'], 2.0],
10, ['*', ['get', 'baseSize'], 2.8],
12, ['*', ['get', 'baseSize'], 3.5],
13, ['*', ['get', 'baseSize'], 4.2],
14, ['*', ['get', 'baseSize'], 5.0],
],
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
}}
paint={{
'icon-color': ['get', 'color'],
'icon-halo-color': 'rgba(0,0,0,0.6)',
'icon-halo-width': 0.5,
}}
/>
</Source>
)}
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */} {/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay <DeckGLOverlay
overlayRef={overlayRef} overlayRef={overlayRef}
layers={(() => { layers={(() => {
const base = [ const base = replayFocusMode ? [] : [
...staticDeckLayers, ...staticDeckLayers,
illegalFishingLayer, illegalFishingLayer,
illegalFishingLabelLayer, illegalFishingLabelLayer,
@ -838,9 +855,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
...selectedGearLayers, ...selectedGearLayers,
...selectedFleetLayers, ...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []), ...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean); ].filter(Boolean) as DeckLayer[];
reactLayersRef.current = base; reactLayersRef.current = base;
return [...base, ...replayLayerRef.current]; return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current];
})()} })()}
/> />
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}

파일 보기

@ -48,6 +48,26 @@ export interface FleetClusterGeoJsonResult {
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시)
const FLEET_PALETTE = [
'#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24',
'#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc',
];
/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */
function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) {
const matches = groups.filter(g => g.groupKey === groupKey);
if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches };
const seen = new Set<string>();
const members: GroupPolygonDto['members'] = [];
for (const g of matches) {
for (const m of g.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); }
}
}
return { members, groups: matches };
}
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
const { const {
ships, ships,
@ -70,9 +90,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!groupPolygons) return { type: 'FeatureCollection', features }; if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.fleetGroups) { for (const g of groupPolygons.fleetGroups) {
if (!g.polygon) continue; if (!g.polygon) continue;
const cid = Number(g.groupKey);
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
features.push({ features.push({
type: 'Feature', type: 'Feature',
properties: { clusterId: Number(g.groupKey), color: g.color }, properties: { clusterId: cid, color },
geometry: g.polygon, geometry: g.polygon,
}); });
} }
@ -93,7 +115,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
type: 'FeatureCollection', type: 'FeatureCollection',
features: [{ features: [{
type: 'Feature', type: 'Feature',
properties: { clusterId: hoveredFleetId, color: g.color }, properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
geometry: g.polygon, geometry: g.polygon,
}], }],
}; };
@ -124,9 +146,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const operationalPolygons = useMemo(() => { const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return []; if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const { members: mergedMembers } = mergeSubClusterMembers(allGroups, selectedGearGroup);
if (!group) return []; if (mergedMembers.length === 0) return [];
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]); const basePts: [number, number][] = mergedMembers.map(m => [m.lon, m.lat]);
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
@ -152,11 +174,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
return result; return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공) // 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
const gearClusterGeoJson = useMemo((): GeoJSON => { const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = []; const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features }; if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
if (!g.polygon) continue; if (!g.polygon) continue;
features.push({ features.push({
type: 'Feature', type: 'Feature',
@ -205,7 +227,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
}; };
for (const g of groupPolygons.fleetGroups) { for (const g of groupPolygons.fleetGroups) {
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); const cid = Number(g.groupKey);
const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length];
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor);
} }
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
@ -231,15 +255,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const allGroups = groupPolygons const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: []; : [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
if (!group?.polygon) return null; if (matches.length === 0) return null;
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: [{ features: matches.map(g => ({
type: 'Feature', type: 'Feature' as const,
properties: {}, properties: { subClusterId: g.subClusterId },
geometry: group.polygon, geometry: g.polygon!,
}], })),
}; };
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]); }, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
@ -303,7 +327,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (enabledModels.has('identity') && groupPolygons) { if (enabledModels.has('identity') && groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? []; const { members } = mergeSubClusterMembers(all, selectedGearGroup);
for (const m of members) { for (const m of members) {
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() }; const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
@ -336,7 +360,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
if (groupPolygons) { if (groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi); const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup);
const m = allMembers.find(x => x.mmsi === hoveredMmsi);
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
} }
const s = ships.find(x => x.mmsi === hoveredMmsi); const s = ships.find(x => x.mmsi === hoveredMmsi);
@ -363,7 +388,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
label: g.groupLabel, label: g.groupLabel,
memberCount: g.memberCount, memberCount: g.memberCount,
areaSqNm: g.areaSqNm, areaSqNm: g.areaSqNm,
color: g.color, color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
members: g.members, members: g.members,
})).sort((a, b) => b.memberCount - a.memberCount); })).sort((a, b) => b.memberCount - a.memberCount);
}, [groupPolygons]); }, [groupPolygons]);

파일 보기

@ -0,0 +1,675 @@
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useMap } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next';
import type { Ship } from '../../types';
import { MT_TYPE_COLORS, getMTType, NAVY_COLORS, FLAG_EMOJI, isMilitary } from '../../utils/shipClassification';
import { useShipDeckStore } from '../../stores/shipDeckStore';
// ── Local Korean ship photos ──────────────────────────────────────────────────
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'440034000': '/ships/440034000.jpg',
'440150000': '/ships/440150000.jpg',
'440272000': '/ships/440272000.jpg',
'440274000': '/ships/440274000.jpg',
'440323000': '/ships/440323000.jpg',
'440384000': '/ships/440384000.jpg',
'440880000': '/ships/440880000.jpg',
'441046000': '/ships/441046000.jpg',
'441345000': '/ships/441345000.jpg',
'441353000': '/ships/441353000.jpg',
'441393000': '/ships/441393000.jpg',
'441423000': '/ships/441423000.jpg',
'441548000': '/ships/441548000.jpg',
'441708000': '/ships/441708000.png',
'441866000': '/ships/441866000.jpg',
};
interface VesselPhotoData {
url: string;
}
const vesselPhotoCache = new Map<string, VesselPhotoData | null>();
type PhotoSource = 'spglobal' | 'marinetraffic';
interface VesselPhotoProps {
mmsi: string;
imo?: string;
shipImagePath?: string | null;
shipImageCount?: number;
}
/**
* S&P Global API
* GET /signal-batch/api/v1/shipimg/{imo}
* path에 _1.jpg() / _2.jpg()
*/
interface SpgImageInfo {
picId: number;
path: string; // e.g. "/shipimg/22738/2273823"
copyright: string;
date: string;
}
const spgImageCache = new Map<string, SpgImageInfo[] | null>();
async function fetchSpgImages(imo: string): Promise<SpgImageInfo[]> {
if (spgImageCache.has(imo)) return spgImageCache.get(imo) || [];
try {
const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`);
if (!res.ok) throw new Error(`${res.status}`);
const data: SpgImageInfo[] = await res.json();
spgImageCache.set(imo, data);
return data;
} catch {
spgImageCache.set(imo, null);
return [];
}
}
function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
const localUrl = LOCAL_SHIP_PHOTOS[mmsi];
const hasSPGlobal = !!shipImagePath;
const [activeTab, setActiveTab] = useState<PhotoSource>(hasSPGlobal ? 'spglobal' : 'marinetraffic');
const [spgSlideIdx, setSpgSlideIdx] = useState(0);
const [spgErrors, setSpgErrors] = useState<Set<number>>(new Set());
const [spgImages, setSpgImages] = useState<SpgImageInfo[]>([]);
useEffect(() => {
setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic');
setSpgSlideIdx(0);
setSpgErrors(new Set());
setSpgImages([]);
if (imo && hasSPGlobal) {
fetchSpgImages(imo).then(setSpgImages);
} else if (shipImagePath) {
setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]);
}
}, [mmsi, imo, hasSPGlobal, shipImagePath]);
const spgUrls = useMemo(
() => spgImages.map(img => `${img.path}_2.jpg`),
[spgImages],
);
const validSpgCount = spgUrls.length;
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined;
});
useEffect(() => {
setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined);
}, [mmsi]);
useEffect(() => {
if (activeTab !== 'marinetraffic') return;
if (mtPhoto !== undefined) return;
const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image();
img.onload = () => {
const result = { url: imgUrl };
vesselPhotoCache.set(mmsi, result);
setMtPhoto(result);
};
img.onerror = () => {
vesselPhotoCache.set(mmsi, null);
setMtPhoto(null);
};
img.src = imgUrl;
}, [mmsi, activeTab, mtPhoto]);
let currentUrl: string | null = null;
if (localUrl) {
currentUrl = localUrl;
} else if (activeTab === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) {
currentUrl = spgUrls[spgSlideIdx];
} else if (activeTab === 'marinetraffic' && mtPhoto) {
currentUrl = mtPhoto.url;
}
if (localUrl) {
return (
<div className="mb-1.5">
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
<img
src={localUrl}
alt="Vessel"
className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
);
}
const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i));
const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null;
return (
<div className="mb-1.5">
<div className="flex mb-1">
{hasSPGlobal && (
<div
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
activeTab === 'spglobal'
? 'border-[#1565c0] text-white bg-white/5'
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
}`}
onClick={() => setActiveTab('spglobal')}
>
S&P Global
</div>
)}
<div
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
activeTab === 'marinetraffic'
? 'border-[#1565c0] text-white bg-white/5'
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
}`}
onClick={() => setActiveTab('marinetraffic')}
>
MarineTraffic
</div>
</div>
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
{currentUrl ? (
<img
key={currentUrl}
src={currentUrl}
alt="Vessel"
className="w-full h-full object-contain"
onError={() => {
if (activeTab === 'spglobal') {
setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
}
}}
/>
) : noPhoto ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</div>
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
Loading...
</div>
) : (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</div>
)}
{activeTab === 'spglobal' && validSpgCount > 1 && (
<>
<button
type="button"
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i - 1 + validSpgCount) % validSpgCount); }}
>
&lt;
</button>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i + 1) % validSpgCount); }}
>
&gt;
</button>
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1">
{spgUrls.map((_, i) => (
<span
key={i}
className={`w-1.5 h-1.5 rounded-full ${i === spgSlideIdx ? 'bg-white' : 'bg-white/40'}`}
/>
))}
</div>
</>
)}
</div>
</div>
);
}
// ── Fleet group type ──────────────────────────────────────────────────────────
interface FleetMember {
ship: Ship;
role: string;
roleKo: string;
}
interface FleetGroup {
members: FleetMember[];
fleetTypeKo: string;
}
// ── Popup content ─────────────────────────────────────────────────────────────
const FLEET_ROLE_COLORS: Record<string, string> = {
pair: '#ef4444',
carrier: '#f97316',
lighting: '#eab308',
mothership: '#dc2626',
subsidiary: '#6b7280',
};
interface ShipPopupContentProps {
ship: Ship;
onClose: () => void;
fleetGroup: FleetGroup | null;
isDragging: boolean;
onMouseDown: (e: React.MouseEvent) => void;
}
const ShipPopupContent = memo(function ShipPopupContent({
ship,
onClose,
fleetGroup,
isDragging: _isDragging,
onMouseDown,
}: ShipPopupContentProps) {
const { t } = useTranslation('ships');
const mtType = getMTType(ship);
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
const isMil = isMilitary(ship.category);
const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined;
const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined;
const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : '';
return (
<div className="ship-popup-body" onMouseDown={onMouseDown}>
{/* Header — draggable handle */}
<div
className="ship-popup-header"
style={{ background: isMil ? '#1a1a2e' : '#1565c0', cursor: 'grab' }}
>
{flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
<strong className="ship-popup-name">{ship.name}</strong>
{navyLabel && (
<span className="ship-popup-navy-badge" style={{ background: navyAccent || color }}>
{navyLabel}
</span>
)}
<button
type="button"
className="ml-auto text-white/60 hover:text-white text-sm leading-none flex items-center justify-center"
style={{ minWidth: 28, minHeight: 28, padding: '4px 6px' }}
onClick={onClose}
onMouseDown={(e) => e.stopPropagation()}
>
</button>
</div>
{/* Photo */}
<VesselPhoto
mmsi={ship.mmsi}
imo={ship.imo}
shipImagePath={ship.shipImagePath}
shipImageCount={ship.shipImageCount}
/>
{/* Type tags */}
<div className="ship-popup-tags">
<span className="ship-tag ship-tag-primary" style={{ background: color }}>
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}
</span>
<span className="ship-tag ship-tag-secondary">
{t(`categoryLabel.${ship.category}`)}
</span>
{ship.typeDesc && (
<span className="ship-tag ship-tag-dim">{ship.typeDesc}</span>
)}
</div>
{/* Data grid — paired rows */}
<div className="ship-popup-grid">
<div className="ship-popup-row">
<span className="ship-popup-label">MMSI</span>
<span className="ship-popup-value">{ship.mmsi}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">IMO</span>
<span className="ship-popup-value">{ship.imo || '-'}</span>
</div>
{ship.callSign && (
<>
<div className="ship-popup-row">
<span className="ship-popup-label">{t('popup.callSign')}</span>
<span className="ship-popup-value">{ship.callSign}</span>
</div>
<div className="ship-popup-row" />
</>
)}
<div className="ship-popup-row">
<span className="ship-popup-label">Lat</span>
<span className="ship-popup-value">{ship.lat.toFixed(4)}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Lon</span>
<span className="ship-popup-value">{ship.lng.toFixed(4)}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">HDG</span>
<span className="ship-popup-value">{ship.heading.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">COG</span>
<span className="ship-popup-value">{ship.course.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">SOG</span>
<span className="ship-popup-value">{ship.speed.toFixed(1)} kn</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Draught</span>
<span className="ship-popup-value">{ship.draught ? `${ship.draught.toFixed(2)}m` : '-'}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Length</span>
<span className="ship-popup-value">{ship.length ? `${ship.length}m` : '-'}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Width</span>
<span className="ship-popup-value">{ship.width ? `${ship.width}m` : '-'}</span>
</div>
</div>
{/* Long-value fields */}
{ship.status && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Status</span>
<span className="ship-popup-value">{ship.status}</span>
</div>
)}
{ship.destination && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Dest</span>
<span className="ship-popup-value">{ship.destination}</span>
</div>
)}
{ship.eta && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">ETA</span>
<span className="ship-popup-value">{new Date(ship.eta).toLocaleString()}</span>
</div>
)}
{/* Fleet info */}
{fleetGroup && fleetGroup.members.length > 0 && (
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
{'\uD83D\uDD17'} {fleetGroup.fleetTypeKo} {fleetGroup.members.length}
</div>
{fleetGroup.members.slice(0, 5).map(m => (
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
<span style={{ color: FLEET_ROLE_COLORS[m.role] || '#ef4444', fontWeight: 700, minWidth: 55 }}>
{m.roleKo}
</span>
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
</div>
))}
{fleetGroup.members.length > 5 && (
<div style={{ fontSize: 8, color: '#666' }}>... {fleetGroup.members.length - 5}</div>
)}
</div>
)}
{/* Footer */}
<div className="ship-popup-footer">
<span className="ship-popup-timestamp">
{t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()}
</span>
<a
href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank"
rel="noopener noreferrer"
className="ship-popup-link"
>
MarineTraffic &rarr;
</a>
</div>
</div>
);
});
// ── Position tracking ─────────────────────────────────────────────────────────
interface ScreenPos {
x: number;
y: number;
}
// Popup tip/arrow height (CSS triangle pointing downward toward ship)
const POPUP_TIP_HEIGHT = 10;
// Vertical offset above ship icon so popup sits above with some gap
const POPUP_ANCHOR_OFFSET = 16;
// ── Main overlay component ────────────────────────────────────────────────────
export function ShipPopupOverlay() {
const { current: mapRef } = useMap();
const selectedMmsi = useShipDeckStore(s => s.selectedMmsi);
const ship = useShipDeckStore(s =>
s.selectedMmsi ? s.shipMap.get(s.selectedMmsi) ?? null : null,
);
const analysisMap = useShipDeckStore(s => s.analysisMap);
const ships = useShipDeckStore(s => s.ships);
// Compute fleet group from analysis map (same logic as ShipLayer lines 414-455)
const fleetGroup = useMemo((): FleetGroup | null => {
if (!selectedMmsi || !analysisMap) return null;
const dto = analysisMap.get(selectedMmsi);
if (!dto) return null;
const clusterId = dto.algorithms.cluster.clusterId;
if (clusterId < 0) return null;
const members: FleetMember[] = [];
for (const [mmsi, d] of analysisMap) {
if (d.algorithms.cluster.clusterId !== clusterId) continue;
const memberShip = ships.find(s => s.mmsi === mmsi);
if (!memberShip) continue;
const isLeader = d.algorithms.fleetRole.isLeader;
members.push({
ship: memberShip,
role: d.algorithms.fleetRole.role,
roleKo: isLeader ? '본선' : '선단원',
});
}
if (members.length === 0) return null;
return { members, fleetTypeKo: '선단' };
}, [selectedMmsi, analysisMap, ships]);
// Screen position of the popup (anchored below ship, updated on map move)
const [screenPos, setScreenPos] = useState<ScreenPos | null>(null);
// Once dragged, detach from map tracking and use fixed position
const [draggedPos, setDraggedPos] = useState<ScreenPos | null>(null);
const dragging = useRef(false);
const dragStartOffset = useRef<ScreenPos>({ x: 0, y: 0 });
const popupRef = useRef<HTMLDivElement>(null);
// Project ship coordinates to screen, accounting for popup height (anchor bottom)
const projectShipToScreen = useCallback((): ScreenPos | null => {
if (!mapRef || !ship) return null;
const m = mapRef.getMap();
const point = m.project([ship.lng, ship.lat]);
// Anchor bottom: popup tip points down to ship position
// We want the tip to be at ship pixel, so offset upward by popup height + tip
return { x: point.x, y: point.y };
}, [mapRef, ship]);
// Update anchored position on map move / resize (only if not dragged)
useEffect(() => {
if (!mapRef || !ship) {
setScreenPos(null);
return;
}
if (draggedPos !== null) return; // detached, skip
const update = () => {
setScreenPos(projectShipToScreen());
};
update(); // initial
const m = mapRef.getMap();
m.on('move', update);
m.on('zoom', update);
return () => {
m.off('move', update);
m.off('zoom', update);
};
}, [mapRef, ship, draggedPos, projectShipToScreen]);
// Reset drag state when ship changes
useEffect(() => {
setDraggedPos(null);
setScreenPos(projectShipToScreen());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMmsi]);
// Drag handlers
const onMouseDown = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.ship-popup-header')) return;
e.preventDefault();
dragging.current = true;
const el = popupRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
dragStartOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
setDraggedPos({
x: e.clientX - dragStartOffset.current.x,
y: e.clientY - dragStartOffset.current.y,
});
};
const onMouseUp = () => { dragging.current = false; };
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}, []);
const handleClose = useCallback(() => {
useShipDeckStore.getState().setSelectedMmsi(null);
}, []);
if (!ship) return null;
// Determine final CSS position
// draggedPos: user dragged → use fixed left/top directly (popup div is positioned inside map container)
// screenPos: anchored to ship → offset upward so tip touches ship
let style: React.CSSProperties;
if (draggedPos !== null) {
style = {
position: 'absolute',
left: draggedPos.x,
top: draggedPos.y,
transform: 'none',
};
} else if (screenPos !== null) {
// Offset: translate(-50%, -100%) then subtract tip height + anchor gap
// We use transform for centering horizontally and anchoring at bottom
style = {
position: 'absolute',
left: screenPos.x,
top: screenPos.y - POPUP_ANCHOR_OFFSET - POPUP_TIP_HEIGHT,
transform: 'translateX(-50%) translateY(-100%)',
};
} else {
return null;
}
return (
<div
ref={popupRef}
className="z-50 select-none rounded-lg shadow-lg overflow-hidden"
style={{ ...style, background: '#1a1a2e', border: '1px solid rgba(255,255,255,0.15)' }}
>
{/* Popup body */}
<ShipPopupContent
ship={ship}
onClose={handleClose}
fleetGroup={fleetGroup}
isDragging={dragging.current}
onMouseDown={onMouseDown}
/>
{/* CSS triangle arrow pointing down toward ship (only when anchored) */}
{draggedPos === null && (
<div
style={{
position: 'absolute',
bottom: -POPUP_TIP_HEIGHT,
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: `${POPUP_TIP_HEIGHT}px solid rgba(10, 10, 26, 0.96)`,
pointerEvents: 'none',
}}
/>
)}
</div>
);
}
// ── Ship Hover Tooltip ───────────────────────────────────────────────────────
export function ShipHoverTooltip() {
const hoveredMmsi = useShipDeckStore(s => s.hoveredMmsi);
const hoverScreenPos = useShipDeckStore(s => s.hoverScreenPos);
const ship = useShipDeckStore(s => s.hoveredMmsi ? s.shipMap.get(s.hoveredMmsi) ?? null : null);
const selectedMmsi = useShipDeckStore(s => s.selectedMmsi);
// 팝업이 열려있으면 툴팁 숨김
if (!hoveredMmsi || !hoverScreenPos || !ship || selectedMmsi === hoveredMmsi) return null;
const lastSeen = ship.lastSeen
? new Date(ship.lastSeen).toLocaleString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '-';
return (
<div
className="pointer-events-none z-40"
style={{
position: 'absolute',
left: hoverScreenPos.x + 14,
top: hoverScreenPos.y - 10,
}}
>
<div
className="rounded px-2.5 py-1.5 font-mono text-[10px] leading-relaxed whitespace-nowrap shadow-lg"
style={{ background: '#1a1a2e', border: '1px solid rgba(255,255,255,0.15)' }}
>
<div className="text-[11px] font-bold text-white/90 mb-0.5">
{ship.name || 'Unknown'}
</div>
<div className="text-white/50">MMSI {ship.mmsi}</div>
<div className="text-white/50">
{ship.lat.toFixed(4)}, {ship.lng.toFixed(4)}
</div>
<div className="text-white/50">
{ship.speed?.toFixed(1) ?? '-'} kn / {ship.heading?.toFixed(0) ?? '-'}&deg;
</div>
<div className="text-white/40">{lastSeen}</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,552 @@
import { useMemo } from 'react';
import type { Layer } from '@deck.gl/core';
import { GeoJsonLayer, IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import type { FleetClusterGeoJsonResult } from '../components/korea/useFleetClusterGeoJson';
import { FONT_MONO } from '../styles/fonts';
import { clusterLabels } from '../utils/labelCluster';
// ── Config ────────────────────────────────────────────────────────────────────
export interface FleetClusterDeckConfig {
selectedGearGroup: string | null;
hoveredMmsi: string | null;
hoveredGearGroup: string | null; // gear polygon hover highlight
enabledModels: Set<string>;
historyActive: boolean;
hasCorrelationTracks: boolean;
zoomScale: number;
zoomLevel: number; // integer zoom for label clustering
fontScale?: number; // fontScale.analysis (default 1)
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void;
}
export interface PickedPolygonFeature {
type: 'fleet' | 'gear';
clusterId?: number;
name?: string;
gearCount?: number;
inZone?: boolean;
}
// ── Hex → RGBA (module-level cache) ──────────────────────────────────────────
const hexRgbaCache = new Map<string, [number, number, number, number]>();
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const cacheKey = `${hex}-${alpha}`;
const cached = hexRgbaCache.get(cacheKey);
if (cached) return cached;
const h = hex.replace('#', '');
let r = parseInt(h.substring(0, 2), 16) || 0;
let g = parseInt(h.substring(2, 4), 16) || 0;
let b = parseInt(h.substring(4, 6), 16) || 0;
// 어두운 색상 밝기 보정 (바다 배경 대비)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
if (lum < 0.3) {
const boost = 0.3 / Math.max(lum, 0.01);
r = Math.min(255, Math.round(r * boost));
g = Math.min(255, Math.round(g * boost));
b = Math.min(255, Math.round(b * boost));
}
const rgba: [number, number, number, number] = [r, g, b, alpha];
hexRgbaCache.set(cacheKey, rgba);
return rgba;
}
// ── Gear cluster color helpers ────────────────────────────────────────────────
const GEAR_IN_ZONE_FILL: [number, number, number, number] = [220, 38, 38, 25]; // #dc2626 opacity 0.10
const GEAR_IN_ZONE_LINE: [number, number, number, number] = [220, 38, 38, 200]; // #dc2626
const GEAR_OUT_ZONE_FILL: [number, number, number, number] = [249, 115, 22, 25]; // #f97316 opacity 0.10
const GEAR_OUT_ZONE_LINE: [number, number, number, number] = [249, 115, 22, 200]; // #f97316
const ICON_PX = 64;
// ── Point-in-polygon (ray casting) ──────────────────────────────────────────
function pointInRing(point: [number, number], ring: number[][]): boolean {
const [px, py] = point;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
function pointInPolygon(point: [number, number], geometry: GeoJSON.Geometry): boolean {
if (geometry.type === 'Polygon') {
return pointInRing(point, geometry.coordinates[0]);
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.some(poly => pointInRing(point, poly[0]));
}
return false;
}
/** Find all fleet/gear polygons at a given coordinate */
function findPolygonsAtPoint(
point: [number, number],
fleetFc: GeoJSON.FeatureCollection,
gearFc: GeoJSON.FeatureCollection,
): PickedPolygonFeature[] {
const results: PickedPolygonFeature[] = [];
for (const f of fleetFc.features) {
if (pointInPolygon(point, f.geometry)) {
results.push({
type: 'fleet',
clusterId: f.properties?.clusterId,
name: f.properties?.name,
});
}
}
for (const f of gearFc.features) {
if (pointInPolygon(point, f.geometry)) {
results.push({
type: 'gear',
name: f.properties?.name,
gearCount: f.properties?.gearCount,
inZone: f.properties?.inZone === 1,
});
}
}
return results;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Converts FleetClusterGeoJsonResult (produced by useFleetClusterGeoJson) into
* deck.gl Layer instances.
*
* Uses useMemo fleet data changes infrequently (every 5 minutes) and on user
* interaction (hover, select). No Zustand subscribe pattern needed.
*/
export function useFleetClusterDeckLayers(
geo: FleetClusterGeoJsonResult | null,
config: FleetClusterDeckConfig,
): Layer[] {
const {
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
enabledModels,
historyActive,
zoomScale,
zoomLevel,
fontScale: fs = 1,
onPolygonClick,
onPolygonHover,
} = config;
const focusMode = config.focusMode ?? false;
return useMemo((): Layer[] => {
if (!geo || focusMode) return [];
const layers: Layer[] = [];
// ── 1. Fleet polygons (fleetPolygonGeoJSON) ──────────────────────────────
const fleetPoly = geo.fleetPolygonGeoJSON as GeoJSON.FeatureCollection;
if (fleetPoly.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-polygons',
data: fleetPoly,
getFillColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 25),
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 128),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: true,
onHover: (info) => {
if (info.object) {
const f = info.object as GeoJSON.Feature;
const cid = f.properties?.clusterId;
if (cid != null) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'fleet', id: cid });
}
} else {
onPolygonHover?.(null);
}
},
onClick: (info) => {
if (!info.object || !info.coordinate || !onPolygonClick) return;
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
},
updateTriggers: {},
}));
}
// ── 2. Hovered fleet highlight (hoveredGeoJSON) ──────────────────────────
const hoveredPoly = geo.hoveredGeoJSON as GeoJSON.FeatureCollection;
if (hoveredPoly.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-hover-highlight',
data: hoveredPoly,
getFillColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 64),
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 200),
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 3. Fleet 2-ship lines (lineGeoJSON) ──────────────────────────────────
// Currently always empty (server handles 2-ship fleets as Polygon), kept for future
const lineFc = geo.lineGeoJSON as GeoJSON.FeatureCollection;
if (lineFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-lines',
data: lineFc,
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#63b3ed', 180),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
// ── 4. Gear cluster polygons (gearClusterGeoJson) ────────────────────────
const gearFc = geo.gearClusterGeoJson as GeoJSON.FeatureCollection;
if (gearFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-cluster-polygons',
data: gearFc,
getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? GEAR_IN_ZONE_FILL : GEAR_OUT_ZONE_FILL,
getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? GEAR_IN_ZONE_LINE : GEAR_OUT_ZONE_LINE,
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: true,
onHover: (info) => {
if (info.object) {
const f = info.object as GeoJSON.Feature;
const name = f.properties?.name;
if (name) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name });
}
} else {
onPolygonHover?.(null);
}
},
onClick: (info) => {
if (!info.object || !info.coordinate || !onPolygonClick) return;
const pt: [number, number] = [info.coordinate[0], info.coordinate[1]];
onPolygonClick(findPolygonsAtPoint(pt, fleetPoly, gearFc), pt);
},
}));
}
// ── 4b. Gear hover highlight ──────────────────────────────────────────
if (hoveredGearGroup && gearFc.features.length > 0) {
const hoveredGearFeatures = gearFc.features.filter(
f => f.properties?.name === hoveredGearGroup,
);
if (hoveredGearFeatures.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-hover-highlight',
data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures },
getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64],
getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
getLineWidth: 2.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
}
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-selected-highlight',
data: geo.selectedGearHighlightGeoJson,
getFillColor: [249, 115, 22, 40],
getLineColor: [249, 115, 22, 230],
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 6. Member markers (memberMarkersGeoJson) — skip when historyActive ───
if (!historyActive) {
const memberFc = geo.memberMarkersGeoJson as GeoJSON.FeatureCollection;
if (memberFc.features.length > 0) {
layers.push(new IconLayer<GeoJSON.Feature>({
id: 'fleet-member-icons',
data: memberFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getIcon: (f: GeoJSON.Feature) =>
f.properties?.isGear === 1
? SHIP_ICON_MAPPING['gear-diamond']
: SHIP_ICON_MAPPING['ship-triangle'],
getSize: (f: GeoJSON.Feature) =>
(f.properties?.baseSize ?? 0.14) * zoomScale * ICON_PX,
getAngle: (f: GeoJSON.Feature) =>
f.properties?.isGear === 1 ? 0 : -(f.properties?.cog ?? 0),
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#9e9e9e'),
sizeUnits: 'pixels',
sizeMinPixels: 3,
billboard: false,
pickable: false,
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
const clusteredMembers = clusterLabels(
memberFc.features,
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
zoomLevel,
);
layers.push(new TextLayer<GeoJSON.Feature>({
id: 'fleet-member-labels',
data: clusteredMembers,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getText: (f: GeoJSON.Feature) => {
const isParent = f.properties?.isParent === 1;
return isParent ? `\u2605 ${f.properties?.name ?? ''}` : (f.properties?.name ?? '');
},
getSize: 8 * zoomScale * fs,
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#e2e8f0'),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
billboard: false,
characterSet: 'auto',
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
}
}
// ── 7. Picker highlight (pickerHighlightGeoJson) ──────────────────────────
const pickerFc = geo.pickerHighlightGeoJson as GeoJSON.FeatureCollection;
if (pickerFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-picker-highlight',
data: pickerFc,
getFillColor: [255, 255, 255, 25],
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── Correlation layers (only when gear group selected) ────────────────────
if (selectedGearGroup) {
// ── 8. Operational polygons (per model) ────────────────────────────────
for (const op of geo.operationalPolygons) {
if (!enabledModels.has(op.modelName)) continue;
if (op.geojson.features.length === 0) continue;
const modelColor = MODEL_COLORS[op.modelName] ?? '#94a3b8';
layers.push(new GeoJsonLayer({
id: `fleet-op-polygon-${op.modelName}`,
data: op.geojson,
getFillColor: hexToRgba(modelColor, 30),
getLineColor: hexToRgba(modelColor, 180),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: true,
stroked: true,
pickable: false,
}));
}
// ── 9. Correlation trails (correlationTrailGeoJson) ────────────────────
const trailFc = geo.correlationTrailGeoJson as GeoJSON.FeatureCollection;
if (trailFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-correlation-trails',
data: trailFc,
getLineColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa', 160),
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
// ── 10. Correlation vessels (correlationVesselGeoJson) ─────────────────
const corrVesselFc = geo.correlationVesselGeoJson as GeoJSON.FeatureCollection;
if (corrVesselFc.features.length > 0) {
layers.push(new IconLayer<GeoJSON.Feature>({
id: 'fleet-correlation-vessel-icons',
data: corrVesselFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
getSize: () =>
0.14 * zoomScale * ICON_PX,
getAngle: (f: GeoJSON.Feature) => -(f.properties?.cog ?? 0),
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa'),
sizeUnits: 'pixels',
sizeMinPixels: 3,
billboard: false,
pickable: false,
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
const clusteredCorr = clusterLabels(
corrVesselFc.features,
f => (f.geometry as GeoJSON.Point).coordinates as [number, number],
zoomLevel,
);
layers.push(new TextLayer<GeoJSON.Feature>({
id: 'fleet-correlation-vessel-labels',
data: clusteredCorr,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getText: (f: GeoJSON.Feature) => f.properties?.name ?? '',
getSize: 8 * zoomScale * fs,
getColor: (f: GeoJSON.Feature) =>
hexToRgba(f.properties?.color ?? '#60a5fa'),
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 10],
fontFamily: FONT_MONO,
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
billboard: false,
characterSet: 'auto',
updateTriggers: {
getSize: [zoomScale, fs],
},
}));
}
// ── 11. Model badges (modelBadgesGeoJson) ─────────────────────────────
// Rendered as small ScatterplotLayer dots, one layer per active model.
// Position is offset in world coordinates (small lng offset per model index).
// Badge size is intentionally small (4px) as visual indicators only.
const badgeFc = geo.modelBadgesGeoJson as GeoJSON.FeatureCollection;
if (badgeFc.features.length > 0) {
MODEL_ORDER.forEach((modelName, i) => {
if (!enabledModels.has(modelName)) return;
const modelColor = MODEL_COLORS[modelName] ?? '#94a3b8';
const activeFeatures = badgeFc.features.filter(
(f) => f.properties?.[`m${i}`] === 1,
);
if (activeFeatures.length === 0) return;
// Small lng offset per model index to avoid overlap (≈ 300m at z10)
const lngOffset = i * 0.003;
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
id: `fleet-model-badge-${modelName}`,
data: activeFeatures,
getPosition: (f: GeoJSON.Feature) => {
const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates;
return [lng + lngOffset, lat] as [number, number];
},
getRadius: 4,
getFillColor: hexToRgba(modelColor, 230),
getLineColor: [0, 0, 0, 200],
getLineWidth: 1,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
pickable: false,
}));
});
}
// ── 12. Hover highlight (hoverHighlightGeoJson + trail) ───────────────
if (hoveredMmsi) {
const hoverFc = geo.hoverHighlightGeoJson as GeoJSON.FeatureCollection;
if (hoverFc.features.length > 0) {
layers.push(new ScatterplotLayer<GeoJSON.Feature>({
id: 'fleet-hover-ring',
data: hoverFc.features,
getPosition: (f: GeoJSON.Feature) =>
(f.geometry as GeoJSON.Point).coordinates as [number, number],
getRadius: 18,
getFillColor: [255, 255, 255, 20],
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
pickable: false,
}));
}
const hoverTrailFc = geo.hoverHighlightTrailGeoJson as GeoJSON.FeatureCollection;
if (hoverTrailFc.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'fleet-hover-trail',
data: hoverTrailFc,
getLineColor: [255, 255, 255, 150],
getLineWidth: 1.5,
lineWidthUnits: 'pixels',
filled: false,
stroked: true,
pickable: false,
}));
}
}
}
return layers;
}, [
geo,
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
enabledModels,
historyActive,
zoomScale,
zoomLevel,
fs,
focusMode,
onPolygonClick,
onPolygonHover,
]);
}

파일 보기

@ -9,6 +9,9 @@ import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConst
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis'; import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { useFontScale } from './useFontScale';
import { useShipDeckStore } from '../stores/shipDeckStore';
import { clusterLabels } from '../utils/labelCluster';
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@ -60,6 +63,7 @@ export function useGearReplayLayers(
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData); const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments); const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions); const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
const subClusterCenters = useGearReplayStore(s => s.subClusterCenters);
const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels); const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
@ -67,6 +71,9 @@ export function useGearReplayLayers(
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const { fontScale } = useFontScale();
const fs = fontScale.analysis;
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
// ── Refs ───────────────────────────────────────────────────────────────── // ── Refs ─────────────────────────────────────────────────────────────────
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
@ -126,6 +133,26 @@ export function useGearReplayLayers(
})); }));
} }
// ── 서브클러스터별 독립 center trail (PathLayer) ─────────────────────
const SUB_COLORS: [number, number, number, number][] = [
[251, 191, 36, 200], // sub=0 (unified) — 기존 gold
[96, 165, 250, 200], // sub=1 — blue
[74, 222, 128, 200], // sub=2 — green
[251, 146, 60, 200], // sub=3 — orange
[167, 139, 250, 200], // sub=4 — purple
];
for (const sc of subClusterCenters) {
if (sc.path.length < 2) continue;
const color = SUB_COLORS[sc.subClusterId % SUB_COLORS.length];
layers.push(new PathLayer({
id: `replay-sub-center-${sc.subClusterId}`,
data: [{ path: sc.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: color,
widthMinPixels: 2,
}));
}
// ── Dynamic layers (depend on currentTime) ──────────────────────────── // ── Dynamic layers (depend on currentTime) ────────────────────────────
if (frameIdx < 0) { if (frameIdx < 0) {
@ -224,24 +251,30 @@ export function useGearReplayLayers(
billboard: false, billboard: false,
})); }));
// Member labels — showLabels 제어 // Member labels — showLabels 제어 + 줌 레벨별 클러스터
if (showLabels) layers.push(new TextLayer<MemberPosition>({ if (showLabels) {
const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels', id: 'replay-member-labels',
data: members, data: clusteredMembers,
getPosition: d => [d.lon, d.lat], getPosition: d => [d.lon, d.lat],
getText: d => d.name || d.mmsi, getText: d => {
const prefix = d.isParent ? '\u2605 ' : '';
return prefix + (d.name || d.mmsi);
},
getColor: d => d.stale getColor: d => d.stale
? [148, 163, 184, 200] ? [148, 163, 184, 200]
: d.isGear : d.isGear
? [226, 232, 240, 255] ? [226, 232, 240, 255]
: [251, 191, 36, 255], : [251, 191, 36, 255],
getSize: 10, getSize: 10 * fs,
getPixelOffset: [0, 14], getPixelOffset: [0, 14],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1], backgroundPadding: [2, 1],
fontFamily: '"Fira Code Variable", monospace', fontFamily: '"Fira Code Variable", monospace',
})); }));
}
} }
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback) // 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
@ -368,18 +401,21 @@ export function useGearReplayLayers(
billboard: false, billboard: false,
})); }));
if (showLabels) layers.push(new TextLayer<CorrPosition>({ if (showLabels) {
const clusteredCorr = clusterLabels(corrPositions, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<CorrPosition>({
id: 'replay-corr-labels', id: 'replay-corr-labels',
data: corrPositions, data: clusteredCorr,
getPosition: d => [d.lon, d.lat], getPosition: d => [d.lon, d.lat],
getText: d => d.name, getText: d => d.name,
getColor: d => d.color, getColor: d => d.color,
getSize: 8, getSize: 8 * fs,
getPixelOffset: [0, 15], getPixelOffset: [0, 15],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1], backgroundPadding: [2, 1],
})); }));
}
} }
// 7. Hover highlight // 7. Hover highlight
@ -517,7 +553,7 @@ export function useGearReplayLayers(
getPosition: (d: { position: [number, number] }) => d.position, getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName, getText: () => trail.modelName,
getColor: [r, g, b, 255], getColor: [r, g, b, 255],
getSize: 9, getSize: 9 * fs,
getPixelOffset: [0, -12], getPixelOffset: [0, -12],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
@ -630,7 +666,7 @@ export function useGearReplayLayers(
historyFrames, memberTripsData, correlationTripsData, historyFrames, memberTripsData, correlationTripsData,
centerTrailSegments, centerDotsPositions, centerTrailSegments, centerDotsPositions,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel, enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, showTrails, showLabels, modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel,
replayLayerRef, requestRender, replayLayerRef, requestRender,
]); ]);
@ -649,7 +685,12 @@ export function useGearReplayLayers(
// ── zustand.subscribe effect (currentTime → renderFrame) ───────────────── // ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
useEffect(() => { useEffect(() => {
if (historyFrames.length === 0) return; if (historyFrames.length === 0) {
// Reset 시 레이어 클리어
replayLayerRef.current = [];
requestRender();
return;
}
// Initial render // Initial render
renderFrame(); renderFrame();

파일 보기

@ -4,6 +4,33 @@ import type { GroupPolygonDto } from '../services/vesselAnalysis';
const POLL_INTERVAL_MS = 5 * 60_000; // 5분 const POLL_INTERVAL_MS = 5 * 60_000; // 5분
/** 같은 groupKey의 서브클러스터를 하나로 합산 (멤버 합산, 가장 큰 폴리곤 사용) */
function mergeByGroupKey(groups: GroupPolygonDto[]): GroupPolygonDto[] {
const byKey = new Map<string, GroupPolygonDto[]>();
for (const g of groups) {
const list = byKey.get(g.groupKey) ?? [];
list.push(g);
byKey.set(g.groupKey, list);
}
const result: GroupPolygonDto[] = [];
for (const [, items] of byKey) {
if (items.length === 1) { result.push(items[0]); continue; }
const seen = new Set<string>();
const allMembers: GroupPolygonDto['members'] = [];
for (const item of items) for (const m of item.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
}
const biggest = items.reduce((a, b) => a.memberCount >= b.memberCount ? a : b);
result.push({
...biggest,
subClusterId: 0,
members: allMembers,
memberCount: allMembers.length,
});
}
return result;
}
export interface UseGroupPolygonsResult { export interface UseGroupPolygonsResult {
fleetGroups: GroupPolygonDto[]; fleetGroups: GroupPolygonDto[];
gearInZoneGroups: GroupPolygonDto[]; gearInZoneGroups: GroupPolygonDto[];
@ -49,17 +76,17 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
}, [enabled, load]); }, [enabled, load]);
const fleetGroups = useMemo( const fleetGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'FLEET'), () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')),
[allGroups], [allGroups],
); );
const gearInZoneGroups = useMemo( const gearInZoneGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'), () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')),
[allGroups], [allGroups],
); );
const gearOutZoneGroups = useMemo( const gearOutZoneGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'), () => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')),
[allGroups], [allGroups],
); );

파일 보기

@ -0,0 +1,349 @@
import { useEffect, useCallback } from 'react';
import type { Layer } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useShipDeckStore } from '../stores/shipDeckStore';
import { useGearReplayStore } from '../stores/gearReplayStore';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { MT_TYPE_HEX, getMTType, SIZE_MAP, isMilitary } from '../utils/shipClassification';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import { getNationalityGroup } from './useKoreaData';
import { FONT_MONO } from '../styles/fonts';
import type { Ship, VesselAnalysisDto } from '../types';
// ── Constants ─────────────────────────────────────────────────────────────────
/** Zoom level → icon scale multiplier (matches MapLibre interpolate) */
const ZOOM_SCALE: Record<number, number> = {
4: 0.8, 5: 0.9, 6: 1.0, 7: 1.2, 8: 1.5, 9: 1.8,
10: 2.2, 11: 2.5, 12: 2.8, 13: 3.5,
};
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
function getZoomScale(zoom: number): number {
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
}
/** MapLibre icon-size is a multiplier on native icon size (64px SVG).
* deck.gl getSize with sizeUnits='pixels' specifies actual pixel height.
* So: baseSize(0.16) * zoomScale(1.0) * 64 = 10.24px MapLibre equivalent. */
const ICON_PX = 64;
const GEAR_RE = /^.+?_\d+_\d+_?$/;
// ── Hex → RGBA conversion (cached per session) ──────────────────────────────
const hexCache = new Map<string, [number, number, number, number]>();
function hexToRgba(hex: string, alpha = 230): [number, number, number, number] {
const cached = hexCache.get(hex);
if (cached) return cached;
const h = hex.replace('#', '');
const rgba: [number, number, number, number] = [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
alpha,
];
hexCache.set(hex, rgba);
return rgba;
}
// ── Pre-computed ship render datum (avoids repeated computation in accessors)
interface ShipRenderDatum {
mmsi: string;
name: string;
lng: number;
lat: number;
heading: number;
isGear: boolean;
isKorean: boolean;
isMil: boolean;
category: string;
color: [number, number, number, number];
baseSize: number; // SIZE_MAP value
}
function buildShipRenderData(
ships: Ship[],
militaryOnly: boolean,
hiddenCategories: Set<string>,
hiddenNationalities: Set<string>,
): ShipRenderDatum[] {
const result: ShipRenderDatum[] = [];
for (const ship of ships) {
const mtCategory = getMarineTrafficCategory(ship.typecode, ship.category);
const natGroup = getNationalityGroup(ship.flag);
// CPU-side filtering
if (militaryOnly && !isMilitary(ship.category)) continue;
if (hiddenCategories.size > 0 && hiddenCategories.has(mtCategory)) continue;
if (hiddenNationalities.size > 0 && hiddenNationalities.has(natGroup)) continue;
const isGear = GEAR_RE.test(ship.name || '');
const hex = MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
result.push({
mmsi: ship.mmsi,
name: ship.name || '',
lng: ship.lng,
lat: ship.lat,
heading: ship.heading,
isGear,
isKorean: ship.flag === 'KR',
isMil: isMilitary(ship.category),
category: ship.category,
color: hexToRgba(hex),
baseSize: (isGear ? 0.8 : 1) * (SIZE_MAP[ship.category] ?? 0.12),
});
}
return result;
}
// ── Analysis ship markers ────────────────────────────────────────────────────
interface AnalysisRenderDatum {
mmsi: string;
lng: number;
lat: number;
cog: number;
isGear: boolean;
color: [number, number, number, number];
baseSize: number;
}
const RISK_COLORS: Record<string, [number, number, number, number]> = {
CRITICAL: [239, 68, 68, 255],
HIGH: [249, 115, 22, 255],
MEDIUM: [234, 179, 8, 255],
LOW: [34, 197, 94, 255],
};
function buildAnalysisData(
ships: Ship[],
analysisMap: Map<string, VesselAnalysisDto>,
): AnalysisRenderDatum[] {
const result: AnalysisRenderDatum[] = [];
for (const ship of ships) {
const dto = analysisMap.get(ship.mmsi);
if (!dto) continue;
const level = dto.algorithms.riskScore.level;
const isGear = GEAR_RE.test(ship.name || '');
result.push({
mmsi: ship.mmsi,
lng: ship.lng,
lat: ship.lat,
cog: ship.heading ?? 0,
isGear,
color: RISK_COLORS[level] ?? RISK_COLORS.LOW,
baseSize: 0.16,
});
}
return result;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Builds deck.gl layers for live ship rendering.
*
* Uses zustand.subscribe to bypass React re-render cycle.
* Ship data updates (5s polling) and filter/hover/zoom changes
* trigger imperative layer rebuild overlay.setProps().
*/
export function useShipDeckLayers(
shipLayerRef: React.MutableRefObject<Layer[]>,
requestRender: () => void,
): void {
const renderFrame = useCallback(() => {
const state = useShipDeckStore.getState();
const { ships, layerVisible, militaryOnly, hiddenShipCategories, hiddenNationalities,
hoveredMmsi, highlightKorean, zoomLevel, analysisMap, analysisActiveFilter } = state;
// Layer off or focus mode → clear
const focusMode = useGearReplayStore.getState().focusMode;
if (!layerVisible || ships.length === 0 || focusMode) {
shipLayerRef.current = [];
requestRender();
return;
}
const zoomScale = getZoomScale(zoomLevel);
const layers: Layer[] = [];
// 1. Build filtered ship render data (~3K ships, <1ms)
const data = buildShipRenderData(ships, militaryOnly, hiddenShipCategories, hiddenNationalities);
// 2. Main ship icons — IconLayer
layers.push(new IconLayer<ShipRenderDatum>({
id: 'ship-icons',
data,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => d.isGear
? SHIP_ICON_MAPPING['gear-diamond']
: SHIP_ICON_MAPPING['ship-triangle'],
getSize: (d) => d.baseSize * zoomScale * ICON_PX,
getAngle: (d) => d.isGear ? 0 : -d.heading,
getColor: (d) => d.color,
sizeUnits: 'pixels',
sizeMinPixels: 3,
billboard: false,
pickable: true,
onClick: (info) => {
if (info.object) {
useShipDeckStore.getState().setSelectedMmsi(info.object.mmsi);
}
},
onHover: (info) => {
useShipDeckStore.getState().setHoveredMmsi(
info.object?.mmsi ?? null,
info.object ? { x: info.x, y: info.y } : undefined,
);
},
updateTriggers: {
getSize: [zoomScale],
},
}));
// 3. Korean ship rings + labels — only when highlightKorean is active
if (highlightKorean) {
const koreanShips = data.filter(d => d.isKorean);
if (koreanShips.length > 0) {
layers.push(new ScatterplotLayer<ShipRenderDatum>({
id: 'korean-ship-rings',
data: koreanShips,
getPosition: (d) => [d.lng, d.lat],
getRadius: 10 * zoomScale,
getFillColor: [0, 229, 255, 20],
getLineColor: [0, 229, 255, 255],
getLineWidth: 2.5,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
updateTriggers: {
getRadius: [zoomScale],
},
}));
layers.push(new TextLayer<ShipRenderDatum>({
id: 'korean-ship-labels',
data: koreanShips,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name || d.mmsi,
getSize: 11 * zoomScale,
getColor: [0, 229, 255, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 14],
fontFamily: FONT_MONO,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] },
}));
}
}
// 4. Hover highlight — ScatterplotLayer (conditional)
if (hoveredMmsi) {
const hoveredShip = data.find(d => d.mmsi === hoveredMmsi);
if (hoveredShip) {
layers.push(new ScatterplotLayer({
id: 'ship-hover-highlight',
data: [hoveredShip],
getPosition: (d: ShipRenderDatum) => [d.lng, d.lat],
getRadius: 18,
getFillColor: [255, 255, 255, 25],
getLineColor: [255, 255, 255, 230],
getLineWidth: 2,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
}));
}
}
// 5. Carrier labels — TextLayer (very few ships)
const carriers = data.filter(d => d.category === 'carrier');
if (carriers.length > 0) {
layers.push(new TextLayer<ShipRenderDatum>({
id: 'carrier-labels',
data: carriers,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name,
getSize: 12 * zoomScale,
getColor: (d) => d.color,
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 16],
fontFamily: FONT_MONO,
fontSettings: { sdf: true },
outlineWidth: 3,
outlineColor: [0, 0, 0, 255],
billboard: false,
characterSet: 'auto',
updateTriggers: { getSize: [zoomScale] },
}));
}
// 6. Analysis ship markers — IconLayer (conditional on analysisActiveFilter)
if (analysisMap && analysisActiveFilter) {
const analysisData = buildAnalysisData(ships, analysisMap);
if (analysisData.length > 0) {
layers.push(new IconLayer<AnalysisRenderDatum>({
id: 'analysis-ship-markers',
data: analysisData,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => d.isGear
? SHIP_ICON_MAPPING['gear-diamond']
: SHIP_ICON_MAPPING['ship-triangle'],
getSize: (d) => d.baseSize * zoomScale * ICON_PX * 1.3,
getAngle: (d) => d.isGear ? 0 : -d.cog,
getColor: (d) => d.color,
sizeUnits: 'pixels',
sizeMinPixels: 4,
billboard: false,
updateTriggers: { getSize: [zoomScale] },
}));
}
}
shipLayerRef.current = layers;
requestRender();
}, [shipLayerRef, requestRender]);
// Subscribe to all relevant state changes
useEffect(() => {
renderFrame(); // initial render
const unsub = useShipDeckStore.subscribe(
(s) => ({
ships: s.ships,
militaryOnly: s.militaryOnly,
hiddenShipCategories: s.hiddenShipCategories,
hiddenNationalities: s.hiddenNationalities,
layerVisible: s.layerVisible,
hoveredMmsi: s.hoveredMmsi,
highlightKorean: s.highlightKorean,
zoomLevel: s.zoomLevel,
analysisMap: s.analysisMap,
analysisActiveFilter: s.analysisActiveFilter,
}),
() => renderFrame(),
);
// focusMode 변경 시에도 레이어 갱신
const unsubFocus = useGearReplayStore.subscribe(
s => s.focusMode,
() => renderFrame(),
);
return () => { unsub(); unsubFocus(); };
}, [renderFrame]);
}

파일 보기

@ -62,6 +62,7 @@ export interface GroupPolygonDto {
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
groupKey: string; groupKey: string;
groupLabel: string; groupLabel: string;
subClusterId: number; // 0=단일/병합, 1,2,...=서브클러스터
snapshotTime: string; snapshotTime: string;
polygon: GeoJSON.Polygon | null; polygon: GeoJSON.Polygon | null;
centerLat: number; centerLat: number;

파일 보기

@ -65,6 +65,9 @@ interface GearReplayState {
correlationTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[];
centerTrailSegments: CenterTrailSegment[]; centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][]; centerDotsPositions: [number, number][];
subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
/** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */
allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[];
snapshotRanges: number[]; snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[]; modelCenterTrails: ModelCenterTrail[];
@ -75,6 +78,7 @@ interface GearReplayState {
correlationByModel: Map<string, GearCorrelationItem[]>; correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean; showTrails: boolean;
showLabels: boolean; showLabels: boolean;
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
// Actions // Actions
loadHistory: ( loadHistory: (
@ -93,6 +97,7 @@ interface GearReplayState {
setHoveredMmsi: (mmsi: string | null) => void; setHoveredMmsi: (mmsi: string | null) => void;
setShowTrails: (show: boolean) => void; setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void; setShowLabels: (show: boolean) => void;
setFocusMode: (focus: boolean) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void; reset: () => void;
} }
@ -142,6 +147,8 @@ export const useGearReplayStore = create<GearReplayState>()(
correlationTripsData: [], correlationTripsData: [],
centerTrailSegments: [], centerTrailSegments: [],
centerDotsPositions: [], centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [], snapshotRanges: [],
modelCenterTrails: [], modelCenterTrails: [],
@ -151,6 +158,7 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null, hoveredMmsi: null,
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false,
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ──────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────
@ -238,6 +246,7 @@ export const useGearReplayStore = create<GearReplayState>()(
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setShowTrails: (show) => set({ showTrails: show }), setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }), setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }),
updateCorrelation: (corrData, corrTracks) => { updateCorrelation: (corrData, corrTracks) => {
const state = get(); const state = get();
@ -282,6 +291,8 @@ export const useGearReplayStore = create<GearReplayState>()(
correlationTripsData: [], correlationTripsData: [],
centerTrailSegments: [], centerTrailSegments: [],
centerDotsPositions: [], centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [], snapshotRanges: [],
modelCenterTrails: [], modelCenterTrails: [],
enabledModels: new Set<string>(), enabledModels: new Set<string>(),
@ -289,6 +300,7 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null, hoveredMmsi: null,
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false,
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
}); });
}, },

파일 보기

@ -0,0 +1,108 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import type { Ship, VesselAnalysisDto } from '../types';
// ── Store interface ───────────────────────────────────────────────
interface ShipDeckState {
// Ship data (from 5-second polling)
ships: Ship[];
shipMap: Map<string, Ship>; // mmsi → Ship lookup (for popup, hover)
// Filter state
militaryOnly: boolean;
hiddenShipCategories: Set<string>; // mtCategory strings like 'cargo', 'tanker'
hiddenNationalities: Set<string>; // natGroup strings like 'KR', 'JP'
layerVisible: boolean; // layers.ships toggle from LayerPanel
// Interaction state
hoveredMmsi: string | null;
hoverScreenPos: { x: number; y: number } | null; // screen coords for tooltip
selectedMmsi: string | null; // popup target
focusMmsi: string | null; // external focus request (e.g., from analysis panel)
// Display state
highlightKorean: boolean; // korean ships ring + label toggle
zoomLevel: number; // integer floor of map zoom
// Analysis state (for analysis ship markers overlay)
analysisMap: Map<string, VesselAnalysisDto> | null;
analysisActiveFilter: string | null; // 'illegalFishing' | 'darkVessel' | 'cnFishing' | null
// Actions
setShips: (ships: Ship[]) => void;
setFilters: (patch: {
militaryOnly?: boolean;
hiddenShipCategories?: Set<string>;
hiddenNationalities?: Set<string>;
layerVisible?: boolean;
}) => void;
setHoveredMmsi: (mmsi: string | null, screenPos?: { x: number; y: number }) => void;
setSelectedMmsi: (mmsi: string | null) => void;
setFocusMmsi: (mmsi: string | null) => void;
setHighlightKorean: (hl: boolean) => void;
setZoomLevel: (zoom: number) => void;
setAnalysis: (map: Map<string, VesselAnalysisDto> | null, filter: string | null) => void;
}
// ── Store ─────────────────────────────────────────────────────────
export const useShipDeckStore = create<ShipDeckState>()(
subscribeWithSelector((set) => ({
// Ship data
ships: [],
shipMap: new Map<string, Ship>(),
// Filter state
militaryOnly: false,
hiddenShipCategories: new Set<string>(),
hiddenNationalities: new Set<string>(),
layerVisible: true,
// Interaction state
hoveredMmsi: null,
hoverScreenPos: null,
selectedMmsi: null,
focusMmsi: null,
// Display state
highlightKorean: false,
zoomLevel: 5,
// Analysis state
analysisMap: null,
analysisActiveFilter: null,
// ── Actions ────────────────────────────────────────────────
setShips: (ships) => {
const shipMap = new Map<string, Ship>();
for (const ship of ships) {
shipMap.set(ship.mmsi, ship);
}
set({ ships, shipMap });
},
setFilters: (patch) => set((state) => ({
militaryOnly: patch.militaryOnly ?? state.militaryOnly,
hiddenShipCategories: patch.hiddenShipCategories ?? state.hiddenShipCategories,
hiddenNationalities: patch.hiddenNationalities ?? state.hiddenNationalities,
layerVisible: patch.layerVisible ?? state.layerVisible,
})),
setHoveredMmsi: (mmsi, screenPos) => set({
hoveredMmsi: mmsi,
hoverScreenPos: mmsi ? (screenPos ?? null) : null,
}),
setSelectedMmsi: (mmsi) => set({ selectedMmsi: mmsi }),
setFocusMmsi: (mmsi) => set({ focusMmsi: mmsi }),
setHighlightKorean: (hl) => set({ highlightKorean: hl }),
setZoomLevel: (zoom) => set({ zoomLevel: zoom }),
setAnalysis: (map, filter) => set({ analysisMap: map, analysisActiveFilter: filter }),
})),
);

파일 보기

@ -0,0 +1,51 @@
/**
* .
* N개만 .
* (useMemo deps에 ).
* z10+ .
*/
/** 줌 레벨별 그리드 셀 크기 (도 단위, 약 80~100px 상당) */
const CELL_SIZE_BY_ZOOM: Record<number, number> = {
4: 4.0,
5: 2.0,
6: 1.0,
7: 0.5,
8: 0.25,
9: 0.12,
};
/**
* .
* @param data
* @param getCoords [lng, lat]
* @param zoomLevel
* @param maxPerCell ( 1)
*/
export function clusterLabels<T>(
data: T[],
getCoords: (d: T) => [number, number],
zoomLevel: number,
maxPerCell = 1,
): T[] {
// z10 이상이면 모두 표시
if (zoomLevel >= 10) return data;
const cellSize = CELL_SIZE_BY_ZOOM[zoomLevel] ?? 0.12;
const grid = new Map<string, number>();
const result: T[] = [];
for (const item of data) {
const [lng, lat] = getCoords(item);
const cx = Math.floor(lng / cellSize);
const cy = Math.floor(lat / cellSize);
const key = `${cx},${cy}`;
const count = grid.get(key) ?? 0;
if (count < maxPerCell) {
grid.set(key, count + 1);
result.push(item);
}
}
return result;
}

파일 보기

@ -561,6 +561,7 @@ def run_gear_correlation(
for gear_group in gear_groups: for gear_group in gear_groups:
parent_name = gear_group['parent_name'] parent_name = gear_group['parent_name']
sub_cluster_id = gear_group.get('sub_cluster_id', 0)
members = gear_group['members'] members = gear_group['members']
if not members: if not members:
continue continue
@ -617,7 +618,7 @@ def run_gear_correlation(
# raw 메트릭 배치 수집 # raw 메트릭 배치 수집
raw_batch.append(( raw_batch.append((
now, parent_name, target_mmsi, target_type, target_name, now, parent_name, sub_cluster_id, target_mmsi, target_type, target_name,
metrics.get('proximity_ratio'), metrics.get('visit_score'), metrics.get('proximity_ratio'), metrics.get('visit_score'),
metrics.get('activity_sync'), metrics.get('dtw_similarity'), metrics.get('activity_sync'), metrics.get('dtw_similarity'),
metrics.get('speed_correlation'), metrics.get('heading_coherence'), metrics.get('speed_correlation'), metrics.get('heading_coherence'),
@ -637,7 +638,7 @@ def run_gear_correlation(
) )
# 사전 로드된 점수에서 조회 (DB 쿼리 없음) # 사전 로드된 점수에서 조회 (DB 쿼리 없음)
score_key = (model.model_id, parent_name, target_mmsi) score_key = (model.model_id, parent_name, sub_cluster_id, target_mmsi)
prev = all_scores.get(score_key) prev = all_scores.get(score_key)
prev_score = prev['current_score'] if prev else None prev_score = prev['current_score'] if prev else None
streak = prev['streak_count'] if prev else 0 streak = prev['streak_count'] if prev else 0
@ -651,7 +652,7 @@ def run_gear_correlation(
if new_score >= model.track_threshold or prev is not None: if new_score >= model.track_threshold or prev is not None:
score_batch.append(( score_batch.append((
model.model_id, parent_name, target_mmsi, model.model_id, parent_name, sub_cluster_id, target_mmsi,
target_type, target_name, target_type, target_name,
round(new_score, 6), new_streak, state, round(new_score, 6), new_streak, state,
now, now, now, now, now, now,
@ -703,21 +704,21 @@ def _load_active_models(conn) -> list[ModelParams]:
def _load_all_scores(conn) -> dict[tuple, dict]: def _load_all_scores(conn) -> dict[tuple, dict]:
"""모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}""" """모든 점수를 사전 로드. {(model_id, group_key, sub_cluster_id, target_mmsi): {...}}"""
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute( cur.execute(
"SELECT model_id, group_key, target_mmsi, " "SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
"current_score, streak_count, last_observed_at " "current_score, streak_count, last_observed_at "
"FROM kcg.gear_correlation_scores" "FROM kcg.gear_correlation_scores"
) )
result = {} result = {}
for row in cur.fetchall(): for row in cur.fetchall():
key = (row[0], row[1], row[2]) key = (row[0], row[1], row[2], row[3])
result[key] = { result[key] = {
'current_score': row[3], 'current_score': row[4],
'streak_count': row[4], 'streak_count': row[5],
'last_observed_at': row[5], 'last_observed_at': row[6],
} }
return result return result
except Exception as e: except Exception as e:
@ -737,7 +738,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_raw_metrics """INSERT INTO kcg.gear_correlation_raw_metrics
(observed_at, group_key, target_mmsi, target_type, target_name, (observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
proximity_ratio, visit_score, activity_sync, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, dtw_similarity, speed_correlation, heading_coherence,
drift_similarity, shadow_stay, shadow_return, drift_similarity, shadow_stay, shadow_return,
@ -762,11 +763,11 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_scores """INSERT INTO kcg.gear_correlation_scores
(model_id, group_key, target_mmsi, target_type, target_name, (model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
current_score, streak_count, freeze_state, current_score, streak_count, freeze_state,
first_observed_at, last_observed_at, updated_at) first_observed_at, last_observed_at, updated_at)
VALUES %s VALUES %s
ON CONFLICT (model_id, group_key, target_mmsi) ON CONFLICT (model_id, group_key, sub_cluster_id, target_mmsi)
DO UPDATE SET DO UPDATE SET
target_type = EXCLUDED.target_type, target_type = EXCLUDED.target_type,
target_name = EXCLUDED.target_name, target_name = EXCLUDED.target_name,

파일 보기

@ -171,6 +171,10 @@ def detect_gear_groups(
if not m: if not m:
continue continue
# 한국 국적 선박(MMSI 440/441)은 어구 AIS 미사용 → 제외
if mmsi.startswith('440') or mmsi.startswith('441'):
continue
parent_raw = (m.group(1) or name).strip() parent_raw = (m.group(1) or name).strip()
parent_key = _normalize_parent(parent_raw) parent_key = _normalize_parent(parent_raw)
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
@ -256,14 +260,15 @@ def detect_gear_groups(
for i in idxs for i in idxs
] ]
# 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2 # group_key는 항상 원본명 유지, 서브클러스터는 별도 ID로 구분
sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}' sub_cluster_id = 0 if len(clusters) == 1 else (ci + 1)
sub_mmsi = parent_mmsi if has_seed else None sub_mmsi = parent_mmsi if has_seed else None
results.append({ results.append({
'parent_name': sub_name, 'parent_name': display_name,
'parent_key': parent_key, 'parent_key': parent_key,
'parent_mmsi': sub_mmsi, 'parent_mmsi': sub_mmsi,
'sub_cluster_id': sub_cluster_id,
'members': members, 'members': members,
}) })
@ -294,6 +299,7 @@ def detect_gear_groups(
existing_mmsis.add(m['mmsi']) existing_mmsis.add(m['mmsi'])
if not big['parent_mmsi'] and small['parent_mmsi']: if not big['parent_mmsi'] and small['parent_mmsi']:
big['parent_mmsi'] = small['parent_mmsi'] big['parent_mmsi'] = small['parent_mmsi']
big['sub_cluster_id'] = 0 # 병합됨 → 단일 클러스터
skip.add(j) skip.add(j)
del big['parent_key'] del big['parent_key']
merged.append(big) merged.append(big)
@ -462,6 +468,7 @@ def build_all_group_snapshots(
'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE',
'group_key': parent_name, 'group_key': parent_name,
'group_label': parent_name, 'group_label': parent_name,
'sub_cluster_id': group.get('sub_cluster_id', 0),
'snapshot_time': now, 'snapshot_time': now,
'polygon_wkt': polygon_wkt, 'polygon_wkt': polygon_wkt,
'center_wkt': center_wkt, 'center_wkt': center_wkt,

파일 보기

@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
insert_sql = """ insert_sql = """
INSERT INTO kcg.group_polygon_snapshots ( INSERT INTO kcg.group_polygon_snapshots (
group_type, group_key, group_label, snapshot_time, group_type, group_key, group_label, sub_cluster_id, snapshot_time,
polygon, center_point, area_sq_nm, member_count, polygon, center_point, area_sq_nm, member_count,
zone_id, zone_name, members, color zone_id, zone_name, members, color
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s,
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
%s, %s, %s, %s, %s::jsonb, %s %s, %s, %s, %s, %s::jsonb, %s
) )
@ -175,6 +175,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
s['group_type'], s['group_type'],
s['group_key'], s['group_key'],
s['group_label'], s['group_label'],
s.get('sub_cluster_id', 0),
s['snapshot_time'], s['snapshot_time'],
s.get('polygon_wkt'), s.get('polygon_wkt'),
s.get('center_wkt'), s.get('center_wkt'),