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

- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers)
- 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip)
- 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동
- Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외
- DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션
- Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 15:43:42 +09:00
부모 1b14aacd89
커밋 6f4044ce39
22개의 변경된 파일2373개의 추가작업 그리고 633개의 파일을 삭제

@ -0,0 +1 @@
Subproject commit ef342769d461dfe48d0e981eb534d467721a41f5

파일 보기

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

파일 보기

@ -28,7 +28,7 @@ public class GroupPolygonService {
private volatile long lastCacheTime = 0;
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_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
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 = """
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_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
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 = """
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_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
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 = """
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score, s.streak_count, s.observation_count,
s.freeze_state, s.shadow_bonus_total,
r.proximity_ratio, r.visit_score, r.heading_coherence,
m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WITH best_scores AS (
SELECT DISTINCT ON (m.id, s.target_mmsi)
s.target_mmsi, s.target_type, s.target_name,
s.current_score, s.streak_count, s.observation_count,
s.freeze_state, s.shadow_bonus_total,
m.id AS model_id, m.name AS model_name, m.is_default
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 (
SELECT proximity_ratio, visit_score, heading_coherence
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
) r ON TRUE
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
ORDER BY m.is_default DESC, s.current_score DESC
ORDER BY bs.model_id, bs.current_score DESC
""";
private static final String GEAR_STATS_SQL = """
@ -121,7 +127,7 @@ public class GroupPolygonService {
row.put("modelName", rs.getString("model_name"));
row.put("isDefault", rs.getBoolean("is_default"));
return row;
}, groupKey, minScore);
}, groupKey, minScore, groupKey);
} catch (Exception e) {
log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage());
return List.of();
@ -162,6 +168,7 @@ public class GroupPolygonService {
/**
* 특정 그룹의 시간별 히스토리.
* sub_cluster_id 포함하여 raw 반환 프론트에서 서브클러스터별 독립 center trail 구성.
*/
public List<GroupPolygonDto> getGroupHistory(String groupKey, int 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"))
.groupKey(rs.getString("group_key"))
.groupLabel(rs.getString("group_label"))
.subClusterId(rs.getInt("sub_cluster_id"))
.snapshotTime(rs.getString("snapshot_time"))
.polygon(polygonObj)
.centerLat(rs.getDouble("center_lat"))

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 { MemberInfo } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
@ -44,80 +46,126 @@ const CorrelationPanel = ({
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
// Compute identity data from groupPolygons
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
const identityVessels = group?.members.filter(m => m.isParent) ?? [];
const identityGear = group?.members.filter(m => !m.isParent) ?? [];
// Card expand state
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
// Card ref map for tooltip positioning
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => {
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
void _MODEL_ORDER;
// Common card styles
const CARD_WIDTH = 180;
const cardStyle: React.CSSProperties = {
background: 'rgba(12,24,37,0.95)',
borderRadius: 6,
minWidth: 160,
maxWidth: 200,
width: CARD_WIDTH,
minWidth: CARD_WIDTH,
flexShrink: 0,
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
};
const cardScrollStyle: React.CSSProperties = {
padding: '6px 8px',
maxHeight: 200,
overflowY: 'auto',
const CARD_COLLAPSED_H = 200;
const CARD_EXPANDED_H = 500;
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) => {
if (!pinnedModelTip) setHoveredModelTip(model);
};
const handleTipLeave = () => {
if (!pinnedModelTip) setHoveredModelTip(null);
};
const handleTipClick = (model: string) => {
const handleTipContextMenu = (e: React.MouseEvent, model: string) => {
e.preventDefault();
setPinnedModelTip(prev => prev === model ? null : model);
setHoveredModelTip(null);
};
const renderModelTip = (model: string, color: string) => {
if (activeModelTip !== model) return null;
const desc = MODEL_DESC[model];
// 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음)
const renderFloatingTip = () => {
if (!activeModelTip) return null;
const desc = MODEL_DESC[activeModelTip];
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 (
<div style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
position: 'fixed',
left: rect.left,
top: rect.top - 4,
transform: 'translateY(-100%)',
padding: '6px 10px',
background: 'rgba(15,23,42,0.97)',
border: `1px solid ${color}66`,
borderRadius: 5,
fontSize: 9,
color: '#e2e8f0',
zIndex: 30,
zIndex: 50,
whiteSpace: 'nowrap',
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>
{desc.details.map((line, i) => (
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
))}
{pinnedModelTip === model && (
{pinnedModelTip && (
<div style={{
color: '#64748b',
fontSize: 8,
marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: 3,
color: '#64748b', fontSize: 8, marginTop: 4,
borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 3,
}}>
</div>
)}
</div>
@ -142,7 +190,7 @@ const CorrelationPanel = ({
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
return (
<div
key={c.targetMmsi}
key={`${modelName}-${c.targetMmsi}`}
style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
@ -172,11 +220,11 @@ const CorrelationPanel = ({
};
// 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';
return (
<div
key={m.mmsi}
key={`${keyPrefix}-${m.mmsi}`}
style={{
fontSize: 9,
marginBottom: 1,
@ -203,27 +251,25 @@ const CorrelationPanel = ({
<div style={{
position: 'absolute',
bottom: historyActive ? 120 : 20,
left: 'calc(50% - 210px)',
left: 'calc(50% + 100px)',
transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
maxWidth: 1320,
display: 'flex',
gap: 6,
alignItems: 'flex-start',
alignItems: 'flex-end',
zIndex: 21,
fontFamily: FONT_MONO,
fontSize: 10,
color: '#e2e8f0',
pointerEvents: 'auto',
maxWidth: 'calc(100vw - 40px)',
overflowX: 'auto',
overflowY: 'visible',
}}>
{/* 고정: 토글 패널 */}
{/* 고정: 토글 패널 (스크롤 밖) */}
<div style={{
background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
position: 'sticky',
left: 0,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
@ -268,20 +314,16 @@ const CorrelationPanel = ({
})}
</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) && (
<div style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)' }}>
{renderModelTip('identity', '#f97316')}
<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>
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({identityVessels.length})</div>
@ -291,13 +333,21 @@ const CorrelationPanel = ({
{identityGear.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div>
{identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))}
{identityGear.length > 12 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{identityGear.length - 12} </div>
)}
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</>
)}
</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>
)}
@ -309,40 +359,37 @@ const CorrelationPanel = ({
const gears = items.filter(c => c.targetType !== 'VESSEL');
if (vessels.length === 0 && gears.length === 0) return null;
return (
<div key={m.name} style={{ ...cardStyle, borderColor: `${color}40` }}>
{renderModelTip(m.name, color)}
<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>
<div key={m.name} ref={(el) => setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}>
<div style={getCardBodyStyle(m.name)}>
{vessels.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> ({vessels.length})</div>
{vessels.slice(0, 10).map(c => renderRow(c, color, m.name))}
{vessels.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{vessels.length - 10} </div>
)}
{vessels.map(c => renderRow(c, color, m.name))}
</>
)}
{gears.length > 0 && (
<>
<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.length > 10 && (
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{gears.length - 10} </div>
)}
{gears.map(c => renderRow(c, color, m.name))}
</>
)}
</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>{/* 스크롤 영역 끝 */}
{renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
</div>
);
};

파일 보기

@ -1,11 +1,82 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useMap } from 'react-map-gl/maplibre';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { Layer as DeckLayer } from '@deck.gl/core';
import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis';
import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
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';
@ -29,10 +100,13 @@ interface Props {
onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void;
onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void;
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 { fontScale } = useFontScale();
// ── 선단/어구 패널 상태 ──
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 toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [hoveredGearName, setHoveredGearName] = useState<string | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = 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);
// ── 맵 + 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(() => {
@ -78,9 +150,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
]);
// 2. 데이터 전처리
const sorted = history.reverse();
const filled = fillGapFrames(sorted);
// 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center
const { frames: filled, subClusterCenters } = splitAndMergeHistory(history);
const corrData = corrRes.items;
const corrTracks = trackRes.vessels;
@ -105,12 +176,36 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작
const store = useGearReplayStore.getState();
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();
};
const closeHistory = useCallback(() => {
useGearReplayStore.getState().reset();
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 동기화 ──
@ -140,141 +235,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyActive, closeHistory]);
// ── 맵 이벤트 등록 ──
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]);
// 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover)
// ── ships map ──
const shipMap = useMemo(() => {
@ -283,7 +244,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return m;
}, [ships]);
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
// ── 부모 콜백 동기화: 어구 그룹 선택 ──
useEffect(() => {
@ -293,11 +254,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return;
}
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) { onSelectedGearChange?.(null); return; }
const parent = group.members.find(m => m.isParent);
const gears = group.members.filter(m => !m.isParent);
const toShip = (m: typeof group.members[0]): Ship => ({
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup);
if (matches.length === 0) { onSelectedGearChange?.(null); return; }
// 서브클러스터 멤버 합산
const seen = new Set<string>();
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,
heading: m.cog, speed: m.sog, course: m.cog,
category: 'fishing', lastSeen: Date.now(),
@ -360,6 +325,97 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
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 outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];
@ -421,26 +477,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const selectedGroupMemberCount = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return 0;
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]);
return (
<>
{/* ── 맵 레이어 ── */}
<FleetClusterMapLayers
geo={geo}
selectedGearGroup={selectedGearGroup}
hoveredMmsi={hoveredMmsi}
enabledModels={enabledModels}
expandedFleet={expandedFleet}
historyActive={historyActive}
hoverTooltip={hoverTooltip}
gearPickerPopup={gearPickerPopup}
pickerHoveredGroup={pickerHoveredGroup}
groupPolygons={groupPolygons}
companies={companies}
analysisMap={analysisMap}
hasCorrelationTracks={correlationTracks.length > 0}
onPickerHover={setPickerHoveredGroup}
onPickerSelect={handlePickerSelect}
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 type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
@ -8,16 +8,10 @@ import type {
GearPickerPopupState,
PickerCandidate,
} from './fleetClusterTypes';
import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
interface FleetClusterMapLayersProps {
geo: FleetClusterGeoJsonResult;
selectedGearGroup: string | null;
hoveredMmsi: string | null;
enabledModels: Set<string>;
expandedFleet: number | null;
historyActive: boolean;
// Popup/tooltip state
hoverTooltip: HoverTooltipState | null;
gearPickerPopup: GearPickerPopupState | null;
@ -26,199 +20,33 @@ interface FleetClusterMapLayersProps {
groupPolygons: UseGroupPolygonsResult | undefined;
companies: Map<number, FleetCompany>;
analysisMap: Map<string, VesselAnalysisDto>;
// Whether any correlation trails exist (drives conditional render)
hasCorrelationTracks: boolean;
// Callbacks
onPickerHover: (group: string | null) => void;
onPickerSelect: (candidate: PickerCandidate) => 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 = ({
geo,
selectedGearGroup,
hoveredMmsi,
enabledModels,
expandedFleet,
historyActive,
hoverTooltip,
gearPickerPopup,
pickerHoveredGroup,
groupPolygons,
companies,
analysisMap,
hasCorrelationTracks,
onPickerHover,
onPickerSelect,
onPickerClose,
}: FleetClusterMapLayersProps) => {
const {
fleetPolygonGeoJSON,
lineGeoJSON,
hoveredGeoJSON,
gearClusterGeoJson,
memberMarkersGeoJson,
pickerHighlightGeoJson,
operationalPolygons,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
} = geo;
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 && (
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
@ -242,7 +70,7 @@ const FleetClusterMapLayers = ({
marginBottom: 2, borderRadius: 2,
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>
</div>
))}
@ -272,7 +100,7 @@ const FleetClusterMapLayers = ({
const role = dto?.algorithms.fleetRole.role ?? m.role;
return (
<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>
);
})}
@ -286,10 +114,13 @@ const FleetClusterMapLayers = ({
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === name);
if (!group) return null;
const parentMember = group.members.find(m => m.isParent);
const gearMembers = group.members.filter(m => !m.isParent);
const matches = allGroups.filter(g => g.groupKey === name);
if (matches.length === 0) return null;
const seen = new Set<string>();
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 (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
@ -314,82 +145,6 @@ const FleetClusterMapLayers = ({
}
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 showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode);
const progressBarRef = useRef<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
@ -46,11 +47,14 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
return (
<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)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
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' }}>
@ -99,6 +103,11 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
</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: '#64748b', fontSize: 9 }}></span>
<select

파일 보기

@ -13,7 +13,11 @@ import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
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 { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
@ -215,6 +219,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const mapRef = useRef<MapRef>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
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 [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | 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) {
zoomRef.current = z;
setZoomLevel(z);
useShipDeckStore.getState().setZoomLevel(z);
}
}, []);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
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 shipLayerRef = useRef<DeckLayer[]>([]);
type ShipPos = { lng: number; lat: number; course?: number };
const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
// live 선박 위치를 ref에 동기화 (리플레이 fallback용)
@ -248,16 +261,62 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const requestRender = useCallback(() => {
if (!overlayRef.current) return;
const focus = useGearReplayStore.getState().focusMode;
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);
useEffect(() => {
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(() => {
if (flyToTarget && mapRef.current) {
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 }) => {
mapRef.current?.fitBounds(
[[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단계씩 상향
const zoomScale = useMemo(() => {
if (zoomLevel <= 4) return 0.8;
@ -429,7 +486,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
const selectedGearLayers = useMemo(() => {
if (!selectedGearData) return [];
if (!selectedGearData || replayFocusMode) return [];
const { parent, gears, groupName } = selectedGearData;
const layers = [];
@ -507,11 +564,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
return layers;
}, [selectedGearData, zoomScale, fontScale.analysis]);
}, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData) return [];
if (!selectedFleetData || replayFocusMode) return [];
const { ships: fleetShips, clusterId } = selectedFleetData;
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 fillColor: [number, number, number, number] = [r, g, b, 80];
const result: Layer[] = [];
const result: DeckLayer[] = [];
// 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({
@ -593,7 +650,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
}
return result;
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]);
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
@ -601,28 +658,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
: koreaFilters.cnFishing ? 'cnFishing'
: null;
// AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시)
const analysisShipMarkersGeoJson = useMemo(() => {
const features: GeoJSON.Feature[] = [];
if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features };
const allS = allShips ?? ships;
for (const s of allS) {
const dto = vesselAnalysis.analysisMap.get(s.mmsi);
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]);
// shipDeckStore에 분석 상태 동기화
useEffect(() => {
useShipDeckStore.getState().setAnalysis(
vesselAnalysis?.analysisMap ?? null,
analysisActiveFilter,
);
}, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
const analysisDeckLayers = useAnalysisDeckLayers(
vesselAnalysis?.analysisMap ?? new Map(),
vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
allShips ?? ships,
analysisActiveFilter,
zoomScale,
@ -635,6 +680,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
style={{ width: '100%', height: '100%' }}
mapStyle={MAP_STYLE}
onZoom={handleZoom}
onLoad={handleMapLoad}
>
<NavigationControl position="top-right" />
@ -702,13 +748,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/>
</Source>
{layers.ships && <ShipLayer
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
militaryOnly={layers.militaryOnly}
analysisMap={vesselAnalysis?.analysisMap}
hiddenShipCategories={hiddenShipCategories}
hiddenNationalities={hiddenNationalities}
/>}
{/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
{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">
@ -780,13 +820,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
groupPolygons={groupPolygons}
zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
<AnalysisOverlay
ships={allShips ?? ships}
analysisMap={vesselAnalysis.analysisMap}
@ -795,42 +837,17 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
/>
)}
{/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */}
{analysisActiveFilter && (
<Source id="analysis-ship-markers" type="geojson" data={analysisShipMarkersGeoJson}>
<Layer
id="analysis-ship-icon"
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>
)}
{/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
{/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */}
<ShipHoverTooltip />
<ShipPopupOverlay />
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay
overlayRef={overlayRef}
layers={(() => {
const base = [
const base = replayFocusMode ? [] : [
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
@ -838,9 +855,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean);
].filter(Boolean) as DeckLayer[];
reactLayersRef.current = base;
return [...base, ...replayLayerRef.current];
return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current];
})()}
/>
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}

파일 보기

@ -48,6 +48,26 @@ export interface FleetClusterGeoJsonResult {
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 {
const {
ships,
@ -70,9 +90,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.fleetGroups) {
if (!g.polygon) continue;
const cid = Number(g.groupKey);
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
features.push({
type: 'Feature',
properties: { clusterId: Number(g.groupKey), color: g.color },
properties: { clusterId: cid, color },
geometry: g.polygon,
});
}
@ -93,7 +115,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { clusterId: hoveredFleetId, color: g.color },
properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
geometry: g.polygon,
}],
};
@ -124,9 +146,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) return [];
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]);
const { members: mergedMembers } = mergeSubClusterMembers(allGroups, selectedGearGroup);
if (mergedMembers.length === 0) return [];
const basePts: [number, number][] = mergedMembers.map(m => [m.lon, m.lat]);
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
@ -152,11 +174,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공)
// 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
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;
features.push({
type: 'Feature',
@ -205,7 +227,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
};
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]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
@ -231,15 +255,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group?.polygon) return null;
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
if (matches.length === 0) return null;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: group.polygon,
}],
features: matches.map(g => ({
type: 'Feature' as const,
properties: { subClusterId: g.subClusterId },
geometry: g.polygon!,
})),
};
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
@ -303,7 +327,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (enabledModels.has('identity') && groupPolygons) {
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) {
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');
@ -336,7 +360,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
if (groupPolygons) {
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] } }] };
}
const s = ships.find(x => x.mmsi === hoveredMmsi);
@ -363,7 +388,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
label: g.groupLabel,
memberCount: g.memberCount,
areaSqNm: g.areaSqNm,
color: g.color,
color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
members: g.members,
})).sort((a, b) => b.memberCount - a.memberCount);
}, [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 type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { useFontScale } from './useFontScale';
import { useShipDeckStore } from '../stores/shipDeckStore';
import { clusterLabels } from '../utils/labelCluster';
// ── Constants ─────────────────────────────────────────────────────────────────
@ -60,6 +63,7 @@ export function useGearReplayLayers(
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
const subClusterCenters = useGearReplayStore(s => s.subClusterCenters);
const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
@ -67,6 +71,9 @@ export function useGearReplayLayers(
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
const { fontScale } = useFontScale();
const fs = fontScale.analysis;
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
// ── Refs ─────────────────────────────────────────────────────────────────
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) ────────────────────────────
if (frameIdx < 0) {
@ -224,24 +251,30 @@ export function useGearReplayLayers(
billboard: false,
}));
// Member labels — showLabels 제어
if (showLabels) layers.push(new TextLayer<MemberPosition>({
// Member labels — showLabels 제어 + 줌 레벨별 클러스터
if (showLabels) {
const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels',
data: members,
data: clusteredMembers,
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
? [148, 163, 184, 200]
: d.isGear
? [226, 232, 240, 255]
: [251, 191, 36, 255],
getSize: 10,
getSize: 10 * fs,
getPixelOffset: [0, 14],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
}
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
@ -368,18 +401,21 @@ export function useGearReplayLayers(
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',
data: corrPositions,
data: clusteredCorr,
getPosition: d => [d.lon, d.lat],
getText: d => d.name,
getColor: d => d.color,
getSize: 8,
getSize: 8 * fs,
getPixelOffset: [0, 15],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1],
}));
}
}
// 7. Hover highlight
@ -517,7 +553,7 @@ export function useGearReplayLayers(
getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName,
getColor: [r, g, b, 255],
getSize: 9,
getSize: 9 * fs,
getPixelOffset: [0, -12],
background: true,
getBackgroundColor: [0, 0, 0, 200],
@ -630,7 +666,7 @@ export function useGearReplayLayers(
historyFrames, memberTripsData, correlationTripsData,
centerTrailSegments, centerDotsPositions,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, showTrails, showLabels,
modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel,
replayLayerRef, requestRender,
]);
@ -649,7 +685,12 @@ export function useGearReplayLayers(
// ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
useEffect(() => {
if (historyFrames.length === 0) return;
if (historyFrames.length === 0) {
// Reset 시 레이어 클리어
replayLayerRef.current = [];
requestRender();
return;
}
// Initial render
renderFrame();

파일 보기

@ -4,6 +4,33 @@ import type { GroupPolygonDto } from '../services/vesselAnalysis';
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 {
fleetGroups: GroupPolygonDto[];
gearInZoneGroups: GroupPolygonDto[];
@ -49,17 +76,17 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
}, [enabled, load]);
const fleetGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'FLEET'),
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')),
[allGroups],
);
const gearInZoneGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'),
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')),
[allGroups],
);
const gearOutZoneGroups = useMemo(
() => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'),
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')),
[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';
groupKey: string;
groupLabel: string;
subClusterId: number; // 0=단일/병합, 1,2,...=서브클러스터
snapshotTime: string;
polygon: GeoJSON.Polygon | null;
centerLat: number;

파일 보기

@ -65,6 +65,9 @@ interface GearReplayState {
correlationTripsData: TripsLayerDatum[];
centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][];
subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
/** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */
allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[];
snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[];
@ -75,6 +78,7 @@ interface GearReplayState {
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
// Actions
loadHistory: (
@ -93,6 +97,7 @@ interface GearReplayState {
setHoveredMmsi: (mmsi: string | null) => void;
setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
setFocusMode: (focus: boolean) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void;
}
@ -142,6 +147,8 @@ export const useGearReplayStore = create<GearReplayState>()(
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [],
modelCenterTrails: [],
@ -151,6 +158,7 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null,
showTrails: true,
showLabels: true,
focusMode: false,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ────────────────────────────────────────────────
@ -238,6 +246,7 @@ export const useGearReplayStore = create<GearReplayState>()(
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }),
updateCorrelation: (corrData, corrTracks) => {
const state = get();
@ -282,6 +291,8 @@ export const useGearReplayStore = create<GearReplayState>()(
correlationTripsData: [],
centerTrailSegments: [],
centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [],
modelCenterTrails: [],
enabledModels: new Set<string>(),
@ -289,6 +300,7 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null,
showTrails: true,
showLabels: true,
focusMode: false,
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:
parent_name = gear_group['parent_name']
sub_cluster_id = gear_group.get('sub_cluster_id', 0)
members = gear_group['members']
if not members:
continue
@ -617,7 +618,7 @@ def run_gear_correlation(
# raw 메트릭 배치 수집
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('activity_sync'), metrics.get('dtw_similarity'),
metrics.get('speed_correlation'), metrics.get('heading_coherence'),
@ -637,7 +638,7 @@ def run_gear_correlation(
)
# 사전 로드된 점수에서 조회 (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_score = prev['current_score'] if prev else None
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:
score_batch.append((
model.model_id, parent_name, target_mmsi,
model.model_id, parent_name, sub_cluster_id, target_mmsi,
target_type, target_name,
round(new_score, 6), new_streak, state,
now, now, now,
@ -703,21 +704,21 @@ def _load_active_models(conn) -> list[ModelParams]:
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()
try:
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 "
"FROM kcg.gear_correlation_scores"
)
result = {}
for row in cur.fetchall():
key = (row[0], row[1], row[2])
key = (row[0], row[1], row[2], row[3])
result[key] = {
'current_score': row[3],
'streak_count': row[4],
'last_observed_at': row[5],
'current_score': row[4],
'streak_count': row[5],
'last_observed_at': row[6],
}
return result
except Exception as e:
@ -737,7 +738,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
execute_values(
cur,
"""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,
dtw_similarity, speed_correlation, heading_coherence,
drift_similarity, shadow_stay, shadow_return,
@ -762,11 +763,11 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
execute_values(
cur,
"""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,
first_observed_at, last_observed_at, updated_at)
VALUES %s
ON CONFLICT (model_id, group_key, target_mmsi)
ON CONFLICT (model_id, group_key, sub_cluster_id, target_mmsi)
DO UPDATE SET
target_type = EXCLUDED.target_type,
target_name = EXCLUDED.target_name,

파일 보기

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

파일 보기

@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
insert_sql = """
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,
zone_id, zone_name, members, color
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s, %s,
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
%s, %s, %s, %s, %s::jsonb, %s
)
@ -175,6 +175,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
s['group_type'],
s['group_key'],
s['group_label'],
s.get('sub_cluster_id', 0),
s['snapshot_time'],
s.get('polygon_wkt'),
s.get('center_wkt'),