Merge pull request 'release: 2026-04-01 (55건 커밋)' (#214) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m41s

This commit is contained in:
htlee 2026-04-01 12:36:34 +09:00
커밋 31f557e54d
35개의 변경된 파일3922개의 추가작업 그리고 976개의 파일을 삭제

파일 보기

@ -14,6 +14,7 @@ public class GroupPolygonDto {
private String groupType; private String groupType;
private String groupKey; private String groupKey;
private String groupLabel; private String groupLabel;
private int subClusterId;
private String snapshotTime; private String snapshotTime;
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON) private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
private double centerLat; private double centerLat;
@ -24,4 +25,5 @@ public class GroupPolygonDto {
private String zoneName; private String zoneName;
private List<Map<String, Object>> members; private List<Map<String, Object>> members;
private String color; private String color;
private String resolution;
} }

파일 보기

@ -28,20 +28,21 @@ public class GroupPolygonService {
private volatile long lastCacheTime = 0; private volatile long lastCacheTime = 0;
private static final String LATEST_GROUPS_SQL = """ private static final String LATEST_GROUPS_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
FROM kcg.group_polygon_snapshots FROM kcg.group_polygon_snapshots
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h')
AND resolution = '1h'
ORDER BY group_type, member_count DESC ORDER BY group_type, member_count DESC
"""; """;
private static final String GROUP_DETAIL_SQL = """ private static final String GROUP_DETAIL_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
FROM kcg.group_polygon_snapshots FROM kcg.group_polygon_snapshots
WHERE group_key = ? WHERE group_key = ?
ORDER BY snapshot_time DESC ORDER BY snapshot_time DESC
@ -49,39 +50,47 @@ public class GroupPolygonService {
"""; """;
private static final String GROUP_HISTORY_SQL = """ private static final String GROUP_HISTORY_SQL = """
SELECT group_type, group_key, group_label, snapshot_time, SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon) AS polygon_geojson, ST_AsGeoJSON(polygon) AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members, color area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
FROM kcg.group_polygon_snapshots FROM kcg.group_polygon_snapshots
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL) WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
ORDER BY snapshot_time DESC ORDER BY snapshot_time DESC
"""; """;
private static final String GROUP_CORRELATIONS_SQL = """ private static final String GROUP_CORRELATIONS_SQL = """
SELECT s.target_mmsi, s.target_type, s.target_name, WITH best_scores AS (
SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi)
s.target_mmsi, s.target_type, s.target_name,
s.current_score, s.streak_count, s.observation_count, s.current_score, s.streak_count, s.observation_count,
s.freeze_state, s.shadow_bonus_total, s.freeze_state, s.shadow_bonus_total,
r.proximity_ratio, r.visit_score, r.heading_coherence, s.sub_cluster_id,
m.id AS model_id, m.name AS model_name, m.is_default m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id 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.sub_cluster_id, s.target_mmsi, s.current_score DESC
)
SELECT bs.*,
r.proximity_ratio, r.visit_score, r.heading_coherence
FROM best_scores bs
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT proximity_ratio, visit_score, heading_coherence SELECT proximity_ratio, visit_score, heading_coherence
FROM kcg.gear_correlation_raw_metrics FROM kcg.gear_correlation_raw_metrics
WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi WHERE group_key = ? AND target_mmsi = bs.target_mmsi
ORDER BY observed_at DESC LIMIT 1 ORDER BY observed_at DESC LIMIT 1
) r ON TRUE ) r ON TRUE
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE ORDER BY bs.model_id, bs.current_score DESC
ORDER BY m.is_default DESC, s.current_score DESC
"""; """;
private static final String GEAR_STATS_SQL = """ private static final String GEAR_STATS_SQL = """
SELECT COUNT(*) AS gear_groups, SELECT COUNT(*) AS gear_groups,
COALESCE(SUM(member_count), 0) AS gear_count COALESCE(SUM(member_count), 0) AS gear_count
FROM kcg.group_polygon_snapshots FROM kcg.group_polygon_snapshots
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h')
AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE') AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
AND resolution = '1h'
"""; """;
/** /**
@ -114,6 +123,7 @@ public class GroupPolygonService {
row.put("observations", rs.getInt("observation_count")); row.put("observations", rs.getInt("observation_count"));
row.put("freezeState", rs.getString("freeze_state")); row.put("freezeState", rs.getString("freeze_state"));
row.put("shadowBonus", rs.getDouble("shadow_bonus_total")); row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
row.put("subClusterId", rs.getInt("sub_cluster_id"));
row.put("proximityRatio", rs.getObject("proximity_ratio")); row.put("proximityRatio", rs.getObject("proximity_ratio"));
row.put("visitScore", rs.getObject("visit_score")); row.put("visitScore", rs.getObject("visit_score"));
row.put("headingCoherence", rs.getObject("heading_coherence")); row.put("headingCoherence", rs.getObject("heading_coherence"));
@ -121,7 +131,7 @@ public class GroupPolygonService {
row.put("modelName", rs.getString("model_name")); row.put("modelName", rs.getString("model_name"));
row.put("isDefault", rs.getBoolean("is_default")); row.put("isDefault", rs.getBoolean("is_default"));
return row; return row;
}, groupKey, minScore); }, groupKey, minScore, groupKey);
} catch (Exception e) { } catch (Exception e) {
log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage()); log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage());
return List.of(); return List.of();
@ -162,6 +172,7 @@ public class GroupPolygonService {
/** /**
* 특정 그룹의 시간별 히스토리. * 특정 그룹의 시간별 히스토리.
* sub_cluster_id 포함하여 raw 반환 프론트에서 서브클러스터별 독립 center trail 구성.
*/ */
public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) { public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) {
return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours)); return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours));
@ -192,6 +203,7 @@ public class GroupPolygonService {
.groupType(rs.getString("group_type")) .groupType(rs.getString("group_type"))
.groupKey(rs.getString("group_key")) .groupKey(rs.getString("group_key"))
.groupLabel(rs.getString("group_label")) .groupLabel(rs.getString("group_label"))
.subClusterId(rs.getInt("sub_cluster_id"))
.snapshotTime(rs.getString("snapshot_time")) .snapshotTime(rs.getString("snapshot_time"))
.polygon(polygonObj) .polygon(polygonObj)
.centerLat(rs.getDouble("center_lat")) .centerLat(rs.getDouble("center_lat"))
@ -202,6 +214,7 @@ public class GroupPolygonService {
.zoneName(rs.getString("zone_name")) .zoneName(rs.getString("zone_name"))
.members(members) .members(members)
.color(rs.getString("color")) .color(rs.getString("color"))
.resolution(rs.getString("resolution"))
.build(); .build();
} }
} }

파일 보기

@ -0,0 +1,14 @@
-- 011: group_polygon_snapshots에 resolution 컬럼 추가 (1h/6h 듀얼 폴리곤)
-- 기존 데이터는 DEFAULT '6h'로 취급
ALTER TABLE kcg.group_polygon_snapshots
ADD COLUMN IF NOT EXISTS resolution VARCHAR(4) DEFAULT '6h';
-- 기존 인덱스 교체: resolution 포함
DROP INDEX IF EXISTS kcg.idx_gps_type_time;
CREATE INDEX idx_gps_type_res_time
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
DROP INDEX IF EXISTS kcg.idx_gps_key_time;
CREATE INDEX idx_gps_key_res_time
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);

파일 보기

@ -4,6 +4,41 @@
## [Unreleased] ## [Unreleased]
## [2026-04-01]
### 추가
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
- 리플레이 컨트롤러 A-B 구간 반복 기능
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조
- 항공기 아이콘 줌레벨 기반 스케일 적용
- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x)
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
- 선박 클릭 팝업 React 오버레이 전환 (ShipPopupOverlay + 드래그 지원)
- 선박 호버 툴팁 (이름, MMSI, 위치, 속도, 수신시각)
- 리플레이 집중 모드 — 주변 라이브 정보 숨김 토글
- 라벨 클러스터링 (줌 레벨별 그리드, z10+ 전체 표시)
- 어구 서브클러스터 독립 추적 (DB sub_cluster_id + Python group_key 고정)
- 서브클러스터별 독립 center trail (PathLayer 색상 구분)
### 변경
- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius
- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용
- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링
- 선단 폴리곤 색상: API 기본색 → 밝은 파스텔 팔레트 (바다 배경 대비)
- 멤버/연관 라벨: SDF outline → 검정 배경 블록 + fontScale.analysis 연동
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
### 수정
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
- 한국 국적 선박(440/441) 어구 오탐 제외
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
- 리플레이 종료/탭 off 시 deck.gl 레이어 + gearReplayStore 완전 초기화
### 기타
- DB 마이그레이션: sub_cluster_id + resolution 컬럼, 인덱스 교체
## [2026-03-31] ## [2026-03-31]
### 추가 ### 추가

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

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

파일 보기

@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor'; import CollectorMonitor from './components/common/CollectorMonitor';
import { SharedFilterProvider } from './contexts/SharedFilterContext'; import { SharedFilterProvider } from './contexts/SharedFilterContext';
import { FontScaleProvider } from './contexts/FontScaleContext'; import { FontScaleProvider } from './contexts/FontScaleContext';
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
import { IranDashboard } from './components/iran/IranDashboard'; import { IranDashboard } from './components/iran/IranDashboard';
import { KoreaDashboard } from './components/korea/KoreaDashboard'; import { KoreaDashboard } from './components/korea/KoreaDashboard';
import './App.css'; import './App.css';
@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
return ( return (
<FontScaleProvider> <FontScaleProvider>
<SymbolScaleProvider>
<SharedFilterProvider> <SharedFilterProvider>
<div className={`app ${isLive ? 'app-live' : ''}`}> <div className={`app ${isLive ? 'app-live' : ''}`}>
<header className="app-header"> <header className="app-header">
@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
)} )}
</div> </div>
</SharedFilterProvider> </SharedFilterProvider>
</SymbolScaleProvider>
</FontScaleProvider> </FontScaleProvider>
); );
} }

파일 보기

@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalStorageSet } from '../../hooks/useLocalStorage'; import { useLocalStorageSet } from '../../hooks/useLocalStorage';
import { FontScalePanel } from './FontScalePanel'; import { FontScalePanel } from './FontScalePanel';
import { SymbolScalePanel } from './SymbolScalePanel';
// Aircraft category colors (matches AircraftLayer military fixed colors) // Aircraft category colors (matches AircraftLayer military fixed colors)
const AC_CAT_COLORS: Record<string, string> = { const AC_CAT_COLORS: Record<string, string> = {
@ -897,6 +898,7 @@ export function LayerPanel({
)} )}
</div> </div>
<FontScalePanel /> <FontScalePanel />
<SymbolScalePanel />
</div> </div>
); );
} }

파일 보기

@ -0,0 +1,43 @@
import { useState } from 'react';
import { useSymbolScale } from '../../hooks/useSymbolScale';
import type { SymbolScaleConfig } from '../../contexts/symbolScaleState';
const LABELS: Record<keyof SymbolScaleConfig, string> = {
ship: '선박 심볼',
aircraft: '항공기 심볼',
};
export function SymbolScalePanel() {
const { symbolScale, setSymbolScale } = useSymbolScale();
const [open, setOpen] = useState(false);
const update = (key: keyof SymbolScaleConfig, val: number) => {
setSymbolScale({ ...symbolScale, [key]: Math.round(val * 10) / 10 });
};
return (
<div className="font-scale-section">
<button type="button" className="font-scale-toggle" onClick={() => setOpen(!open)}>
<span>&#9670; </span>
<span>{open ? '▼' : '▶'}</span>
</button>
{open && (
<div className="font-scale-sliders">
{(Object.keys(LABELS) as (keyof SymbolScaleConfig)[]).map(key => (
<div key={key} className="font-scale-row">
<label>{LABELS[key]}</label>
<input type="range" min={0.5} max={2.0} step={0.1}
value={symbolScale[key]}
onChange={e => update(key, parseFloat(e.target.value))} />
<span>{symbolScale[key].toFixed(1)}</span>
</div>
))}
<button type="button" className="font-scale-reset"
onClick={() => setSymbolScale({ ship: 1.0, aircraft: 1.0 })}>
</button>
</div>
)}
</div>
);
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,31 +1,114 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { MODEL_COLORS } from './fleetClusterConstants';
import type { HistoryFrame } from './fleetClusterTypes';
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
interface HistoryReplayControllerProps { interface HistoryReplayControllerProps {
onClose: () => void; onClose: () => void;
onFilterByScore: (minPct: number | null) => void; onFilterByScore: (minPct: number | null) => void;
} }
const MIN_AB_GAP_MS = 2 * 3600_000;
// 멤버 정보 + 소속 모델 매핑
interface TooltipMember {
mmsi: string;
name: string;
isGear: boolean;
isParent: boolean;
sources: { label: string; color: string }[]; // 소속 (1h, 6h, 모델명)
}
function buildTooltipMembers(
frame1h: HistoryFrame | null,
frame6h: HistoryFrame | null,
correlationByModel: Map<string, GearCorrelationItem[]>,
enabledModels: Set<string>,
enabledVessels: Set<string>,
): TooltipMember[] {
const map = new Map<string, TooltipMember>();
const addSource = (mmsi: string, name: string, isGear: boolean, isParent: boolean, label: string, color: string) => {
const existing = map.get(mmsi);
if (existing) {
existing.sources.push({ label, color });
} else {
map.set(mmsi, { mmsi, name, isGear, isParent, sources: [{ label, color }] });
}
};
// 1h 멤버
if (frame1h) {
for (const m of frame1h.members) {
const isGear = m.role === 'GEAR';
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '1h', '#fbbf24');
}
}
// 6h 멤버
if (frame6h) {
for (const m of frame6h.members) {
const isGear = m.role === 'GEAR';
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '6h', '#93c5fd');
}
}
// 활성 모델의 일치율 대상
for (const [modelName, items] of correlationByModel) {
if (modelName === 'identity') continue;
if (!enabledModels.has(modelName)) continue;
const color = MODEL_COLORS[modelName] ?? '#94a3b8';
for (const c of items) {
if (!enabledVessels.has(c.targetMmsi)) continue;
const isGear = c.targetType === 'GEAR_BUOY';
addSource(c.targetMmsi, c.targetName || c.targetMmsi, isGear, false, modelName, color);
}
}
return [...map.values()];
}
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying); const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const frameCount = useGearReplayStore(s => s.historyFrames.length); const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
const historyFrames = useGearReplayStore(s => s.historyFrames);
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
const frameCount = historyFrames.length;
const frameCount6h = historyFrames6h.length;
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
const abLoop = useGearReplayStore(s => s.abLoop);
const abA = useGearReplayStore(s => s.abA);
const abB = useGearReplayStore(s => s.abB);
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const has6hData = frameCount6h > 0;
const progressBarRef = useRef<HTMLInputElement>(null); const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [dragging, setDragging] = useState<'A' | 'B' | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null); const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null); const timeDisplayRef = useRef<HTMLSpanElement>(null);
const store = useGearReplayStore;
// currentTime → 진행 인디케이터
useEffect(() => { useEffect(() => {
const unsub = useGearReplayStore.subscribe( const unsub = store.subscribe(
s => s.currentTime, s => s.currentTime,
(currentTime) => { (currentTime) => {
const { startTime, endTime } = useGearReplayStore.getState(); const { startTime, endTime } = store.getState();
if (endTime <= startTime) return; if (endTime <= startTime) return;
const progress = (currentTime - startTime) / (endTime - startTime); const progress = (currentTime - startTime) / (endTime - startTime);
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000));
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`; if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
if (timeDisplayRef.current) { if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
@ -33,9 +116,141 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
}, },
); );
return unsub; return unsub;
}, [store]);
// 재생 시작 시 고정 툴팁 해제
useEffect(() => {
if (isPlaying) setPinnedTooltip(null);
}, [isPlaying]);
const posToProgress = useCallback((clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0;
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}, []); }, []);
const store = useGearReplayStore; const progressToTime = useCallback((p: number) => {
const { startTime, endTime } = store.getState();
return startTime + p * (endTime - startTime);
}, [store]);
// 특정 시간에 가장 가까운 1h/6h 프레임 찾기
const findClosestFrames = useCallback((t: number) => {
const { startTime, endTime } = store.getState();
const threshold = (endTime - startTime) * 0.01;
let f1h: HistoryFrame | null = null;
let f6h: HistoryFrame | null = null;
let minD1h = Infinity;
let minD6h = Infinity;
for (const f of historyFrames) {
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
if (d < minD1h && d < threshold) { minD1h = d; f1h = f; }
}
for (const f of historyFrames6h) {
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
if (d < minD6h && d < threshold) { minD6h = d; f6h = f; }
}
return { f1h, f6h };
}, [store, historyFrames, historyFrames6h]);
// 트랙 클릭 → seek + 일시정지 + 툴팁 고정/갱신
const handleTrackClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (dragging) return;
const progress = posToProgress(e.clientX);
const t = progressToTime(progress);
store.getState().pause();
store.getState().seek(t);
// 가까운 프레임이 있으면 툴팁 고정
const { f1h, f6h } = findClosestFrames(t);
if (f1h || f6h) {
setPinnedTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
const mmsis = new Set<string>();
if (f1h) f1h.members.forEach(m => mmsis.add(m.mmsi));
if (f6h) f6h.members.forEach(m => mmsis.add(m.mmsi));
for (const [mn, items] of correlationByModel) {
if (mn === 'identity' || !enabledModels.has(mn)) continue;
for (const c of items) {
if (enabledVessels.has(c.targetMmsi)) mmsis.add(c.targetMmsi);
}
}
store.getState().setPinnedMmsis(mmsis);
} else {
setPinnedTooltip(null);
store.getState().setPinnedMmsis(new Set());
}
}, [store, posToProgress, progressToTime, findClosestFrames, dragging, correlationByModel, enabledModels, enabledVessels]);
// 호버 → 1h+6h 프레임 동시 검색
const handleTrackHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (dragging || pinnedTooltip) return;
const progress = posToProgress(e.clientX);
const t = progressToTime(progress);
const { f1h, f6h } = findClosestFrames(t);
if (f1h || f6h) {
setHoveredTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
} else {
setHoveredTooltip(null);
}
}, [posToProgress, progressToTime, findClosestFrames, dragging, pinnedTooltip]);
// A-B 드래그
const handleAbDown = useCallback((marker: 'A' | 'B') => (e: React.MouseEvent) => {
if (isPlaying) return;
e.stopPropagation();
setDragging(marker);
}, [isPlaying]);
useEffect(() => {
if (!dragging) return;
const handleMove = (e: MouseEvent) => {
const t = progressToTime(posToProgress(e.clientX));
const { startTime, endTime } = store.getState();
const s = store.getState();
if (dragging === 'A') {
store.getState().setAbA(Math.max(startTime, Math.min(s.abB - MIN_AB_GAP_MS, t)));
} else {
store.getState().setAbB(Math.min(endTime, Math.max(s.abA + MIN_AB_GAP_MS, t)));
}
};
const handleUp = () => setDragging(null);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); };
}, [dragging, store, posToProgress, progressToTime]);
const abAPos = useMemo(() => {
if (!abLoop || abA <= 0) return -1;
const { startTime, endTime } = store.getState();
return endTime > startTime ? (abA - startTime) / (endTime - startTime) : -1;
}, [abLoop, abA, store]);
const abBPos = useMemo(() => {
if (!abLoop || abB <= 0) return -1;
const { startTime, endTime } = store.getState();
return endTime > startTime ? (abB - startTime) / (endTime - startTime) : -1;
}, [abLoop, abB, store]);
// 고정 툴팁 멤버 빌드
const pinnedMembers = useMemo(() => {
if (!pinnedTooltip) return [];
return buildTooltipMembers(pinnedTooltip.frame1h, pinnedTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
}, [pinnedTooltip, correlationByModel, enabledModels, enabledVessels]);
// 호버 리치 멤버 목록 (고정 툴팁과 동일 형식)
const hoveredMembers = useMemo(() => {
if (!hoveredTooltip) return [];
return buildTooltipMembers(hoveredTooltip.frame1h, hoveredTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
}, [hoveredTooltip, correlationByModel, enabledModels, enabledVessels]);
// 닫기 핸들러 (고정 해제 포함)
const handleClose = useCallback(() => {
setPinnedTooltip(null);
store.getState().setPinnedMmsis(new Set());
onClose();
}, [store, onClose]);
const btnStyle: React.CSSProperties = { const btnStyle: React.CSSProperties = {
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO, color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
@ -46,80 +261,238 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
return ( return (
<div style={{ <div style={{
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', position: 'absolute', bottom: 20,
left: 'calc(50% + 100px)', transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
minWidth: 380, maxWidth: 1320,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4, borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420, boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
}}> }}>
{/* 프로그레스 바 */} {/* 프로그레스 트랙 */}
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}> <div
{snapshotRanges.map((pos, i) => ( ref={trackRef}
<div key={i} style={{ style={{ position: 'relative', height: 18, cursor: 'pointer' }}
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%', onClick={handleTrackClick}
background: 'rgba(251,191,36,0.4)', onMouseMove={handleTrackHover}
}} /> onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
))} >
<div ref={progressIndicatorRef} style={{ <div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
}} />
</div>
{/* 컨트롤 행 1: 재생 + 타임라인 */} {/* A-B 구간 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> {abLoop && abAPos >= 0 && abBPos >= 0 && (
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }} <div style={{
style={{ ...btnStyle, fontSize: 12 }}> position: 'absolute', left: `${abAPos * 100}%`, top: 5,
{isPlaying ? '⏸' : '▶'} width: `${(abBPos - abAPos) * 100}%`, height: 8,
</button> background: 'rgba(34,197,94,0.12)', borderRadius: 4, pointerEvents: 'none',
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span> }} />
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0} )}
onChange={e => {
const { startTime, endTime } = store.getState(); {snapshotRanges6h.map((pos, i) => (
const progress = Number(e.target.value) / 1000; <div key={`6h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 9, width: 2, height: 4, background: 'rgba(147,197,253,0.4)' }} />
store.getState().pause(); ))}
store.getState().seek(startTime + progress * (endTime - startTime)); {snapshotRanges.map((pos, i) => (
}} <div key={`1h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 5, width: 2, height: 4, background: 'rgba(251,191,36,0.5)' }} />
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} ))}
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}</span> {/* A-B 마커 */}
<button type="button" onClick={onClose} {abLoop && abAPos >= 0 && (
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}> <div onMouseDown={handleAbDown('A')} style={{
position: 'absolute', left: `${abAPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>A</span>
</div>
)}
{abLoop && abBPos >= 0 && (
<div onMouseDown={handleAbDown('B')} style={{
position: 'absolute', left: `${abBPos * 100}%`, top: 0, width: 8, height: 18,
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>B</span>
</div>
)}
{/* 호버 하이라이트 */}
{hoveredTooltip && !pinnedTooltip && (
<div style={{
position: 'absolute', left: `${hoveredTooltip.pos * 100}%`, top: 3, width: 4, height: 12,
background: 'rgba(255,255,255,0.6)',
borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 고정 마커 */}
{pinnedTooltip && (
<div style={{
position: 'absolute', left: `${pinnedTooltip.pos * 100}%`, top: 1, width: 5, height: 16,
background: 'rgba(255,255,255,0.9)', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
)}
{/* 진행 인디케이터 */}
<div ref={progressIndicatorRef} style={{
position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
}} />
{/* 호버 리치 툴팁 (고정 아닌 상태) */}
{hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && (
<div style={{
position: 'absolute',
left: `${Math.min(hoveredTooltip.pos * 100, 85)}%`,
top: -8, transform: 'translateY(-100%)',
background: 'rgba(10,20,32,0.95)', border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 6, padding: '5px 7px', maxWidth: 300, maxHeight: 160, overflowY: 'auto',
fontSize: 9, zIndex: 30, pointerEvents: 'none',
}}>
<div style={{ color: '#fbbf24', fontWeight: 600, marginBottom: 3 }}>
{new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
{hoveredMembers.map(m => (
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0' }}>
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{ color: '#e2e8f0', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
{/* 고정 리치 툴팁 */}
{pinnedTooltip && pinnedMembers.length > 0 && (
<div
onClick={e => e.stopPropagation()}
style={{
position: 'absolute',
left: `${Math.min(pinnedTooltip.pos * 100, 85)}%`,
top: -8,
transform: 'translateY(-100%)',
background: 'rgba(10,20,32,0.97)', border: '1px solid rgba(99,179,237,0.4)',
borderRadius: 6, padding: '6px 8px', maxWidth: 320, maxHeight: 200, overflowY: 'auto',
fontSize: 9, zIndex: 40, pointerEvents: 'auto',
}}>
{/* 헤더 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#fbbf24', fontWeight: 600 }}>
{new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); }}
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: 10, padding: 0 }}>
</button> </button>
</div> </div>
{/* 컨트롤 행 2: 표시 옵션 */} {/* 멤버 목록 (호버 → 지도 강조) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}> {pinnedMembers.map(m => (
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)} <div
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시"> key={m.mmsi}
onMouseEnter={() => store.getState().setHoveredMmsi(m.mmsi)}
</button> onMouseLeave={() => store.getState().setHoveredMmsi(null)}
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
</button>
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span>
<select
onChange={e => {
const val = e.target.value;
onFilterByScore(val === '' ? null : Number(val));
}}
style={{ style={{
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', display: 'flex', alignItems: 'center', gap: 4, padding: '2px 3px',
borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer',
padding: '1px 4px', cursor: 'pointer', borderRadius: 2,
background: hoveredMmsi === m.mmsi ? 'rgba(255,255,255,0.08)' : 'transparent',
}} }}
title="일치율 이상만 표시" aria-label="일치율 필터"
> >
<option value=""></option> <span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
</span>
<span style={{
color: hoveredMmsi === m.mmsi ? '#ffffff' : '#e2e8f0',
fontWeight: hoveredMmsi === m.mmsi ? 600 : 400,
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{m.name}
</span>
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
{m.sources.map((s, si) => (
<span key={si} style={{
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
lineHeight: '6px', textAlign: 'center',
}}>
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* 컨트롤 행 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
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' } : btnStyle}
title="집중 모드"></button>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)}
style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
title="1h 폴리곤">1h</button>
<button type="button" onClick={() => store.getState().setShow6hPolygon(!show6hPolygon)}
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
disabled={!has6hData} title="6h 폴리곤">6h</button>
<span style={{ color: '#475569' }}>|</span>
<button type="button" onClick={() => store.getState().setAbLoop(!abLoop)}
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
title="A-B 구간 반복">A-B</button>
<span style={{ color: '#475569' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span>
<select defaultValue="70"
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }}
style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }}
title="일치율 필터" aria-label="일치율 필터">
<option value=""> (30%+)</option>
<option value="50">50%+</option> <option value="50">50%+</option>
<option value="60">60%+</option> <option value="60">60%+</option>
<option value="70">70%+</option> <option value="70">70%+</option>
<option value="80">80%+</option> <option value="80">80%+</option>
<option value="90">90%+</option> <option value="90">90%+</option>
</select> </select>
<span style={{ flex: 1 }} />
<span style={{ color: '#64748b', fontSize: 9 }}>
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
{has6hData && <> / <span style={{ color: '#93c5fd' }}>{frameCount6h}</span></>}
</span>
<button type="button" onClick={handleClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div> </div>
</div> </div>
); );

파일 보기

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

파일 보기

@ -1,8 +1,21 @@
import type { Ship, VesselAnalysisDto } from '../../types'; import type { Ship, VesselAnalysisDto } from '../../types';
import type { MemberInfo } from '../../services/vesselAnalysis'; import type { MemberInfo, GroupPolygonDto } from '../../services/vesselAnalysis';
// ── 서브클러스터 프레임 ──
export interface SubFrame {
subClusterId: number; // 0=통합, 1,2,...=분리
centerLon: number;
centerLat: number;
members: MemberInfo[];
memberCount: number;
}
// ── 히스토리 스냅샷 + 보간 플래그 ── // ── 히스토리 스냅샷 + 보간 플래그 ──
export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean }; export type HistoryFrame = GroupPolygonDto & {
_interp?: boolean;
_longGap?: boolean;
subFrames: SubFrame[]; // 항상 1개 이상
};
// ── 외부 노출 타입 (KoreaMap에서 import) ── // ── 외부 노출 타입 (KoreaMap에서 import) ──
export interface SelectedGearGroupData { export interface SelectedGearGroupData {

파일 보기

@ -1,6 +1,5 @@
import type { GeoJSON } from 'geojson';
import type { GroupPolygonDto } from '../../services/vesselAnalysis'; import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import type { HistoryFrame } from './fleetClusterTypes'; import type { HistoryFrame, SubFrame } from './fleetClusterTypes';
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes'; import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */ /** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
@ -129,13 +128,20 @@ export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Po
* gap . * gap .
* - gap <= 30분: 5분 ( , ) * - gap <= 30분: 5분 ( , )
* - gap > 30분: 30분 + * - gap > 30분: 30분 +
*
* subFrames :
* - prev/next subClusterId 존재: 멤버/center
* - prev에만 존재: 마지막 frozen
* - next에만 존재:
*
* top-level members/centerLon/Lat: 전체 subFrames의 union ( )
*/ */
export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { export function fillGapFrames(snapshots: HistoryFrame[]): HistoryFrame[] {
if (snapshots.length < 2) return snapshots; if (snapshots.length < 2) return snapshots;
const STEP_SHORT_MS = 300_000; const STEP_SHORT_MS = 300_000;
const STEP_LONG_MS = 1_800_000; const STEP_LONG_MS = 1_800_000;
const THRESHOLD_MS = 1_800_000; const THRESHOLD_MS = 1_800_000;
const result: GroupPolygonDto[] = []; const result: HistoryFrame[] = [];
for (let i = 0; i < snapshots.length; i++) { for (let i = 0; i < snapshots.length; i++) {
result.push(snapshots[i]); result.push(snapshots[i]);
@ -152,25 +158,46 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
const common = prev.members.filter(m => nextMap.has(m.mmsi)); const common = prev.members.filter(m => nextMap.has(m.mmsi));
if (common.length === 0) continue; if (common.length === 0) continue;
const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf]));
if (gap <= THRESHOLD_MS) { if (gap <= THRESHOLD_MS) {
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) { for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
const ratio = (t - t0) / gap; const ratio = (t - t0) / gap;
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio; const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio; const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
// prev 기준으로 순회: prev에만 존재(frozen) + 양쪽 존재(center 보간)
// next에만 존재하는 subClusterId는 prev.subFrames에 없으므로 자동 생략
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
const nsf = nextSubMap.get(psf.subClusterId);
if (!nsf) {
// prev에만 존재 → frozen
return { ...psf };
}
// 양쪽 존재 → center 보간
return {
...psf,
centerLon: psf.centerLon + (nsf.centerLon - psf.centerLon) * ratio,
centerLat: psf.centerLat + (nsf.centerLat - psf.centerLat) * ratio,
};
});
result.push({ result.push({
...prev, ...prev,
snapshotTime: new Date(t).toISOString(), snapshotTime: new Date(t).toISOString(),
centerLon: cLon, centerLon: cLon,
centerLat: cLat, centerLat: cLat,
subFrames,
_interp: true, _interp: true,
}); });
} }
} else { } else {
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) { for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
const ratio = (t - t0) / gap; const ratio = (t - t0) / gap;
const positions: [number, number][] = [];
const members: typeof prev.members = [];
// top-level members 보간 (하위 호환)
const topPositions: [number, number][] = [];
const topMembers: GroupPolygonDto['members'] = [];
for (const pm of common) { for (const pm of common) {
const nm = nextMap.get(pm.mmsi)!; const nm = nextMap.get(pm.mmsi)!;
const lon = pm.lon + (nm.lon - pm.lon) * ratio; const lon = pm.lon + (nm.lon - pm.lon) * ratio;
@ -178,13 +205,53 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
const dLon = nm.lon - pm.lon; const dLon = nm.lon - pm.lon;
const dLat = nm.lat - pm.lat; const dLat = nm.lat - pm.lat;
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
positions.push([lon, lat]); topPositions.push([lon, lat]);
}
const cLon = topPositions.reduce((s, p) => s + p[0], 0) / topPositions.length;
const cLat = topPositions.reduce((s, p) => s + p[1], 0) / topPositions.length;
const polygon = buildInterpPolygon(topPositions);
// subFrames 보간
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
const nsf = nextSubMap.get(psf.subClusterId);
if (!nsf) {
// prev에만 존재 → frozen
return { ...psf };
}
// 양쪽 존재 → 멤버 위치 보간 + 폴리곤 재생성
const nsfMemberMap = new Map(nsf.members.map(m => [m.mmsi, m]));
const commonSfMembers = psf.members.filter(m => nsfMemberMap.has(m.mmsi));
const sfPositions: [number, number][] = [];
const sfMembers: SubFrame['members'] = [];
for (const pm of commonSfMembers) {
const nm = nsfMemberMap.get(pm.mmsi)!;
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
const dLon = nm.lon - pm.lon;
const dLat = nm.lat - pm.lat;
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
sfMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
sfPositions.push([lon, lat]);
} }
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length; if (sfPositions.length === 0) {
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length; // 공통 멤버 없으면 frozen
const polygon = buildInterpPolygon(positions); return { ...psf };
}
const sfCLon = sfPositions.reduce((s, p) => s + p[0], 0) / sfPositions.length;
const sfCLat = sfPositions.reduce((s, p) => s + p[1], 0) / sfPositions.length;
return {
subClusterId: psf.subClusterId,
centerLon: sfCLon,
centerLat: sfCLat,
members: sfMembers,
memberCount: sfMembers.length,
};
});
result.push({ result.push({
...prev, ...prev,
@ -192,8 +259,9 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
polygon, polygon,
centerLon: cLon, centerLon: cLon,
centerLat: cLat, centerLat: cLat,
memberCount: members.length, memberCount: topMembers.length,
members, members: topMembers,
subFrames,
_interp: true, _interp: true,
_longGap: true, _longGap: true,
}); });

파일 보기

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { GeoJSON } from 'geojson'; import type { GeoJSON } from 'geojson';
import type { Ship, VesselAnalysisDto } from '../../types'; import type { Ship, VesselAnalysisDto } from '../../types';
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes'; import type { FleetListItem } from './fleetClusterTypes';
import { buildInterpPolygon } from './fleetClusterUtils'; import { buildInterpPolygon } from './fleetClusterUtils';
@ -48,6 +48,26 @@ export interface FleetClusterGeoJsonResult {
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시)
const FLEET_PALETTE = [
'#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24',
'#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc',
];
/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */
function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) {
const matches = groups.filter(g => g.groupKey === groupKey);
if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches };
const seen = new Set<string>();
const members: GroupPolygonDto['members'] = [];
for (const g of matches) {
for (const m of g.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); }
}
}
return { members, groups: matches };
}
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
const { const {
ships, ships,
@ -70,9 +90,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!groupPolygons) return { type: 'FeatureCollection', features }; if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.fleetGroups) { for (const g of groupPolygons.fleetGroups) {
if (!g.polygon) continue; if (!g.polygon) continue;
const cid = Number(g.groupKey);
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
features.push({ features.push({
type: 'Feature', type: 'Feature',
properties: { clusterId: Number(g.groupKey), color: g.color }, properties: { clusterId: cid, color },
geometry: g.polygon, geometry: g.polygon,
}); });
} }
@ -93,7 +115,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
type: 'FeatureCollection', type: 'FeatureCollection',
features: [{ features: [{
type: 'Feature', type: 'Feature',
properties: { clusterId: hoveredFleetId, color: g.color }, properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
geometry: g.polygon, geometry: g.polygon,
}], }],
}; };
@ -120,43 +142,58 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
return models; return models;
}, [correlationByModel]); }, [correlationByModel]);
// 오퍼레이셔널 폴리곤 (비재생 정적 연산) // 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
const operationalPolygons = useMemo(() => { const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return []; if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; // 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지)
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const rawMatches = groupPolygons.allGroups.filter(
if (!group) return []; g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET',
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]); );
if (rawMatches.length === 0) return [];
// 서브클러스터별 basePts
const subMap = new Map<number, [number, number][]>();
for (const g of rawMatches) {
const sid = g.subClusterId ?? 0;
subMap.set(sid, g.members.map(m => [m.lon, m.lat]));
}
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
const extra: [number, number][] = []; const color = MODEL_COLORS[mn] ?? '#94a3b8';
// 연관 선박을 subClusterId로 그룹핑
const subExtras = new Map<number, [number, number][]>();
for (const c of items) { for (const c of items) {
if (c.score < 0.7) continue; if (c.score < 0.7) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi); const s = ships.find(x => x.mmsi === c.targetMmsi);
if (s) extra.push([s.lng, s.lat]); if (!s) continue;
const sid = c.subClusterId ?? 0;
const list = subExtras.get(sid) ?? [];
list.push([s.lng, s.lat]);
subExtras.set(sid, list);
}
const features: GeoJSON.Feature[] = [];
for (const [sid, extraPts] of subExtras) {
if (extraPts.length === 0) continue;
const basePts = subMap.get(sid) ?? subMap.get(0) ?? [];
const polygon = buildInterpPolygon([...basePts, ...extraPts]);
if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon });
}
if (features.length > 0) {
result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } });
} }
if (extra.length === 0) continue;
const polygon = buildInterpPolygon([...basePts, ...extra]);
if (!polygon) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
result.push({
modelName: mn,
color,
geojson: {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
},
});
} }
return result; return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공) // 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
const gearClusterGeoJson = useMemo((): GeoJSON => { const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = []; const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features }; if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
if (!g.polygon) continue; if (!g.polygon) continue;
features.push({ features.push({
type: 'Feature', type: 'Feature',
@ -205,7 +242,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
}; };
for (const g of groupPolygons.fleetGroups) { for (const g of groupPolygons.fleetGroups) {
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); const cid = Number(g.groupKey);
const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length];
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor);
} }
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
@ -231,15 +270,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const allGroups = groupPolygons const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: []; : [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup); const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
if (!group?.polygon) return null; if (matches.length === 0) return null;
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: [{ features: matches.map(g => ({
type: 'Feature', type: 'Feature' as const,
properties: {}, properties: { subClusterId: g.subClusterId },
geometry: group.polygon, geometry: g.polygon!,
}], })),
}; };
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]); }, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
@ -303,7 +342,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (enabledModels.has('identity') && groupPolygons) { if (enabledModels.has('identity') && groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? []; const { members } = mergeSubClusterMembers(all, selectedGearGroup);
for (const m of members) { for (const m of members) {
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() }; const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
@ -336,7 +375,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
if (groupPolygons) { if (groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi); const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup);
const m = allMembers.find(x => x.mmsi === hoveredMmsi);
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
} }
const s = ships.find(x => x.mmsi === hoveredMmsi); const s = ships.find(x => x.mmsi === hoveredMmsi);
@ -363,7 +403,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
label: g.groupLabel, label: g.groupLabel,
memberCount: g.memberCount, memberCount: g.memberCount,
areaSqNm: g.areaSqNm, areaSqNm: g.areaSqNm,
color: g.color, color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
members: g.members, members: g.members,
})).sort((a, b) => b.memberCount - a.memberCount); })).sort((a, b) => b.memberCount - a.memberCount);
}, [groupPolygons]); }, [groupPolygons]);

파일 보기

@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react';
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Aircraft, AircraftCategory } from '../../types'; import type { Aircraft, AircraftCategory } from '../../types';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import { getZoomScale } from '../../hooks/useShipDeckLayers';
import { useSymbolScale } from '../../hooks/useSymbolScale';
interface Props { interface Props {
aircraft: Aircraft[]; aircraft: Aircraft[];
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
const { t } = useTranslation('ships'); const { t } = useTranslation('ships');
const [showPopup, setShowPopup] = useState(false); const [showPopup, setShowPopup] = useState(false);
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
const { symbolScale } = useSymbolScale();
const color = getAircraftColor(ac); const color = getAircraftColor(ac);
const shape = getShape(ac); const shape = getShape(ac);
const size = shape.w; const zs = getZoomScale(zoomLevel);
const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8);
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;

파일 보기

@ -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,10 @@
import type { ReactNode } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { SymbolScaleCtx, DEFAULT_SYMBOL_SCALE } from './symbolScaleState';
export type { SymbolScaleConfig } from './symbolScaleState';
export function SymbolScaleProvider({ children }: { children: ReactNode }) {
const [symbolScale, setSymbolScale] = useLocalStorage('mapSymbolScale', DEFAULT_SYMBOL_SCALE);
return <SymbolScaleCtx.Provider value={{ symbolScale, setSymbolScale }}>{children}</SymbolScaleCtx.Provider>;
}

파일 보기

@ -0,0 +1,12 @@
import { createContext } from 'react';
export interface SymbolScaleConfig {
ship: number;
aircraft: number;
}
export const DEFAULT_SYMBOL_SCALE: SymbolScaleConfig = { ship: 1.0, aircraft: 1.0 };
export const SymbolScaleCtx = createContext<{ symbolScale: SymbolScaleConfig; setSymbolScale: (c: SymbolScaleConfig) => void }>({
symbolScale: DEFAULT_SYMBOL_SCALE, setSymbolScale: () => {},
});

파일 보기

@ -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, skip during replay) ─
if (selectedGearGroup && !historyActive) {
// ── 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,
]);
}

파일 보기

@ -3,12 +3,15 @@ import type { Layer } from '@deck.gl/core';
import { TripsLayer } from '@deck.gl/geo-layers'; import { TripsLayer } from '@deck.gl/geo-layers';
import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers'; import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
import { useGearReplayStore } from '../stores/gearReplayStore'; import { useGearReplayStore } from '../stores/gearReplayStore';
import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess'; import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
import type { MemberPosition } from '../stores/gearReplayPreprocess'; import type { MemberPosition } from '../stores/gearReplayPreprocess';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis'; import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
import { useFontScale } from './useFontScale';
import { useShipDeckStore } from '../stores/shipDeckStore';
import { clusterLabels } from '../utils/labelCluster';
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@ -60,6 +63,7 @@ export function useGearReplayLayers(
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData); const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments); const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions); const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
const subClusterCenters = useGearReplayStore(s => s.subClusterCenters);
const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels); const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
@ -67,6 +71,17 @@ export function useGearReplayLayers(
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h);
const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h);
const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h);
const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h);
const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis);
const { fontScale } = useFontScale();
const fs = fontScale.analysis;
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
// ── Refs ───────────────────────────────────────────────────────────────── // ── Refs ─────────────────────────────────────────────────────────────────
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
@ -96,9 +111,35 @@ export function useGearReplayLayers(
const layers: Layer[] = []; const layers: Layer[] = [];
// ── 항상 표시: 센터 트레일 + 도트 ────────────────────────────────── // ── 항상 표시: 센터 트레일 ──────────────────────────────────
// 서브클러스터가 존재하면 서브클러스터별 독립 trail만 표시 (전체 trail 숨김)
const hasSubClusters = subClusterCenters.length > 0 &&
subClusterCenters.some(sc => sc.subClusterId > 0);
// Center trail segments (PathLayer) — 항상 ON const SUB_TRAIL_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
];
if (hasSubClusters) {
// 서브클러스터별 독립 center trail (sub=0 합산 trail 제외)
for (const sc of subClusterCenters) {
if (sc.subClusterId === 0) continue; // 합산 center는 점프 유발 → 제외
if (sc.path.length < 2) continue;
const color = SUB_TRAIL_COLORS[sc.subClusterId % SUB_TRAIL_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,
}));
}
} else {
// 서브클러스터 없음: 기존 전체 center trail + dots
for (let i = 0; i < centerTrailSegments.length; i++) { for (let i = 0; i < centerTrailSegments.length; i++) {
const seg = centerTrailSegments[i]; const seg = centerTrailSegments[i];
if (seg.path.length < 2) continue; if (seg.path.length < 2) continue;
@ -112,8 +153,6 @@ export function useGearReplayLayers(
widthMinPixels: 2, widthMinPixels: 2,
})); }));
} }
// Center dots (real data only) — 항상 ON
if (centerDotsPositions.length > 0) { if (centerDotsPositions.length > 0) {
layers.push(new ScatterplotLayer({ layers.push(new ScatterplotLayer({
id: 'replay-center-dots', id: 'replay-center-dots',
@ -125,15 +164,52 @@ export function useGearReplayLayers(
radiusMinPixels: 2.5, radiusMinPixels: 2.5,
})); }));
} }
}
// ── 6h 센터 트레일 (정적, frameIdx와 무관) ───────────────────────────
if (state.show6hPolygon) {
const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0);
if (hasSub6h) {
for (const sc of subClusterCenters6h) {
if (sc.subClusterId === 0) continue;
if (sc.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-6h-sub-center-${sc.subClusterId}`,
data: [{ path: sc.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [147, 197, 253, 120] as [number, number, number, number],
widthMinPixels: 1.5,
}));
}
} else {
for (let i = 0; i < centerTrailSegments6h.length; i++) {
const seg = centerTrailSegments6h[i];
if (seg.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-6h-center-trail-${i}`,
data: [{ path: seg.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number],
widthMinPixels: 1.5,
}));
}
if (centerDotsPositions6h.length > 0) {
layers.push(new ScatterplotLayer({
id: 'replay-6h-center-dots',
data: centerDotsPositions6h,
getPosition: (d: [number, number]) => d,
getFillColor: [147, 197, 253, 120] as [number, number, number, number],
getRadius: 80,
radiusUnits: 'meters',
radiusMinPixels: 2,
}));
}
}
}
// ── Dynamic layers (depend on currentTime) ──────────────────────────── // ── Dynamic layers (depend on currentTime) ────────────────────────────
if (frameIdx < 0) { if (frameIdx >= 0) {
// No valid frame at this time — only show static layers
replayLayerRef.current = layers;
requestRender();
return;
}
const frame = state.historyFrames[frameIdx]; const frame = state.historyFrames[frameIdx];
const isStale = !!frame._longGap || !!frame._interp; const isStale = !!frame._longGap || !!frame._interp;
@ -142,6 +218,9 @@ export function useGearReplayLayers(
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ───────────── // ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
if (showTrails) { if (showTrails) {
// 멤버 전체 항적 (identity — 항상 ON) // 멤버 전체 항적 (identity — 항상 ON)
@ -224,18 +303,23 @@ export function useGearReplayLayers(
billboard: false, billboard: false,
})); }));
// Member labels — showLabels 제어 // Member labels — showLabels 제어 + 줌 레벨별 클러스터
if (showLabels) layers.push(new TextLayer<MemberPosition>({ if (showLabels) {
const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels', id: 'replay-member-labels',
data: members, data: clusteredMembers,
getPosition: d => [d.lon, d.lat], getPosition: d => [d.lon, d.lat],
getText: d => d.name || d.mmsi, getText: d => {
const prefix = d.isParent ? '\u2605 ' : '';
return prefix + (d.name || d.mmsi);
},
getColor: d => d.stale getColor: d => d.stale
? [148, 163, 184, 200] ? [148, 163, 184, 200]
: d.isGear : d.isGear
? [226, 232, 240, 255] ? [226, 232, 240, 255]
: [251, 191, 36, 255], : [251, 191, 36, 255],
getSize: 10, getSize: 10 * fs,
getPixelOffset: [0, 14], getPixelOffset: [0, 14],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
@ -243,6 +327,7 @@ export function useGearReplayLayers(
fontFamily: '"Fira Code Variable", monospace', fontFamily: '"Fira Code Variable", monospace',
})); }));
} }
}
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback) // 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
const corrPositions: CorrPosition[] = []; const corrPositions: CorrPosition[] = [];
@ -368,19 +453,22 @@ export function useGearReplayLayers(
billboard: false, billboard: false,
})); }));
if (showLabels) layers.push(new TextLayer<CorrPosition>({ if (showLabels) {
const clusteredCorr = clusterLabels(corrPositions, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<CorrPosition>({
id: 'replay-corr-labels', id: 'replay-corr-labels',
data: corrPositions, data: clusteredCorr,
getPosition: d => [d.lon, d.lat], getPosition: d => [d.lon, d.lat],
getText: d => d.name, getText: d => d.name,
getColor: d => d.color, getColor: d => d.color,
getSize: 8, getSize: 8 * fs,
getPixelOffset: [0, 15], getPixelOffset: [0, 15],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [2, 1], backgroundPadding: [2, 1],
})); }));
} }
}
// 7. Hover highlight // 7. Hover highlight
if (hoveredMmsi) { if (hoveredMmsi) {
@ -440,39 +528,121 @@ export function useGearReplayLayers(
} }
} }
// 8. Operational polygons (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성) // 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조)
if (state.pinnedMmsis.size > 0) {
const pinnedPositions: { position: [number, number] }[] = [];
for (const m of members) {
if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] });
}
for (const c of corrPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
}
if (pinnedPositions.length > 0) {
// glow
layers.push(new ScatterplotLayer({
id: 'replay-pinned-glow',
data: pinnedPositions,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [255, 255, 255, 40],
getRadius: 350,
radiusUnits: 'meters',
radiusMinPixels: 12,
}));
// ring
layers.push(new ScatterplotLayer({
id: 'replay-pinned-ring',
data: pinnedPositions,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [0, 0, 0, 0],
getRadius: 200,
radiusUnits: 'meters',
radiusMinPixels: 6,
stroked: true,
getLineColor: [255, 255, 255, 200],
lineWidthMinPixels: 1.5,
}));
}
// pinned trails (correlation tracks)
const relTime = ct - st;
for (const trip of correlationTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length;
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-trail-${trip.id}`,
data: [{ path: clippedPath }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [255, 255, 255, 150],
widthMinPixels: 2.5,
}));
}
}
// pinned member trails (identity tracks)
for (const trip of memberTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length;
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-mtrail-${trip.id}`,
data: [{ path: clippedPath }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [255, 200, 60, 180],
widthMinPixels: 2.5,
}));
}
}
}
// 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반)
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8'; const color = MODEL_COLORS[mn] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color); const [r, g, b] = hexToRgb(color);
const extraPts: [number, number][] = []; // 연관 선박을 subClusterId로 그룹핑
const subExtras = new Map<number, [number, number][]>();
for (const c of items as GearCorrelationItem[]) { for (const c of items as GearCorrelationItem[]) {
// enabledVessels로 개별 on/off 제어 (토글 대응)
if (!enabledVessels.has(c.targetMmsi)) continue; if (!enabledVessels.has(c.targetMmsi)) continue;
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
if (cp) extraPts.push([cp.lon, cp.lat]); if (!cp) continue;
const sid = c.subClusterId ?? 0;
const list = subExtras.get(sid) ?? [];
list.push([cp.lon, cp.lat]);
subExtras.set(sid, list);
} }
for (const [sid, extraPts] of subExtras) {
if (extraPts.length === 0) continue; if (extraPts.length === 0) continue;
// 해당 서브클러스터의 멤버 포인트
const basePts = memberPts; // identity 항상 ON const sf = subFrames.find(s => s.subClusterId === sid);
const basePts: [number, number][] = sf
? interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sid).map(m => [m.lon, m.lat])
: memberPts; // fallback: 전체 멤버
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]); const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
if (!opPolygon) continue; if (opPolygon) {
layers.push(new PolygonLayer({ layers.push(new PolygonLayer({
id: `replay-op-${mn}`, id: `replay-op-${mn}-sub${sid}`,
data: [{ polygon: opPolygon.coordinates }], data: [{ polygon: opPolygon.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon, getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: [r, g, b, 30], getFillColor: [r, g, b, 30],
getLineColor: [r, g, b, 200], getLineColor: [r, g, b, 200],
getLineWidth: 2, getLineWidth: 2, lineWidthMinPixels: 2, filled: true, stroked: true,
lineWidthMinPixels: 2,
filled: true,
stroked: true,
})); }));
} }
}
}
// 8.5. Model center trails + current center point (모델별 폴리곤 중심 경로) // 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로)
for (const trail of modelCenterTrails) { for (const trail of modelCenterTrails) {
if (!enabledModels.has(trail.modelName)) continue; if (!enabledModels.has(trail.modelName)) continue;
if (trail.path.length < 2) continue; if (trail.path.length < 2) continue;
@ -481,7 +651,7 @@ export function useGearReplayLayers(
// 중심 경로 (PathLayer, 연한 모델 색상) // 중심 경로 (PathLayer, 연한 모델 색상)
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-model-trail-${trail.modelName}`, id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
data: [{ path: trail.path }], data: [{ path: trail.path }],
getPath: (d: { path: [number, number][] }) => d.path, getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, 100], getColor: [r, g, b, 100],
@ -499,7 +669,7 @@ export function useGearReplayLayers(
const centerData = [{ position: [cx, cy] as [number, number] }]; const centerData = [{ position: [cx, cy] as [number, number] }];
layers.push(new ScatterplotLayer({ layers.push(new ScatterplotLayer({
id: `replay-model-center-${trail.modelName}`, id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
data: centerData, data: centerData,
getPosition: (d: { position: [number, number] }) => d.position, getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [r, g, b, 255], getFillColor: [r, g, b, 255],
@ -512,12 +682,12 @@ export function useGearReplayLayers(
})); }));
if (showLabels) { if (showLabels) {
layers.push(new TextLayer({ layers.push(new TextLayer({
id: `replay-model-center-label-${trail.modelName}`, id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
data: centerData, data: centerData,
getPosition: (d: { position: [number, number] }) => d.position, getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName, getText: () => trail.modelName,
getColor: [r, g, b, 255], getColor: [r, g, b, 255],
getSize: 9, getSize: 9 * fs,
getPixelOffset: [0, -12], getPixelOffset: [0, -12],
background: true, background: true,
getBackgroundColor: [0, 0, 0, 200], getBackgroundColor: [0, 0, 0, 200],
@ -580,22 +750,52 @@ export function useGearReplayLayers(
} }
} }
// ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══ // ══ Identity 레이어 (최상위 z-index — 서브클러스터별 독립 폴리곤) ══
// 폴리곤 const SUB_POLY_COLORS: [number, number, number, number][] = [
const identityPolygon = buildInterpPolygon(memberPts); [251, 191, 36, 40], // sub0 — gold
if (identityPolygon) { [96, 165, 250, 30], // sub1 — blue
[74, 222, 128, 30], // sub2 — green
[251, 146, 60, 30], // sub3 — orange
[167, 139, 250, 30], // sub4 — purple
];
const SUB_STROKE_COLORS: [number, number, number, number][] = [
[251, 191, 36, 180],
[96, 165, 250, 180],
[74, 222, 128, 180],
[251, 146, 60, 180],
[167, 139, 250, 180],
];
const SUB_CENTER_COLORS: [number, number, number, number][] = [
[239, 68, 68, 255],
[96, 165, 250, 255],
[74, 222, 128, 255],
[251, 146, 60, 255],
[167, 139, 250, 255],
];
// ── 1h 폴리곤 (진한색, 실선) ──
if (state.show1hPolygon) {
for (const sf of subFrames) {
const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId);
const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]);
const poly = buildInterpPolygon(sfPts);
if (!poly) continue;
const ci = sf.subClusterId % SUB_POLY_COLORS.length;
layers.push(new PolygonLayer({ layers.push(new PolygonLayer({
id: 'replay-identity-polygon', id: `replay-identity-polygon-1h-sub${sf.subClusterId}`,
data: [{ polygon: identityPolygon.coordinates }], data: [{ polygon: poly.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon, getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci],
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci],
getLineWidth: isStale ? 1 : 2, getLineWidth: isStale ? 1 : 2,
lineWidthMinPixels: 1, lineWidthMinPixels: 1,
filled: true, filled: true,
stroked: true, stroked: true,
})); }));
} }
}
// TripsLayer (멤버 트레일) // TripsLayer (멤버 트레일)
if (memberTripsData.length > 0) { if (memberTripsData.length > 0) {
layers.push(new TripsLayer({ layers.push(new TripsLayer({
@ -610,12 +810,26 @@ export function useGearReplayLayers(
currentTime: ct - st, currentTime: ct - st,
})); }));
} }
// 센터 포인트
// 센터 포인트 (서브클러스터별 독립)
for (const sf of subFrames) {
// 다음 프레임의 같은 서브클러스터 센터와 보간
const nextFrame = frameIdx < state.historyFrames.length - 1 ? state.historyFrames[frameIdx + 1] : null;
const nextSf = nextFrame?.subFrames?.find(s => s.subClusterId === sf.subClusterId);
let cx = sf.centerLon, cy = sf.centerLat;
if (nextSf && nextFrame) {
const t0 = new Date(frame.snapshotTime).getTime();
const t1 = new Date(nextFrame.snapshotTime).getTime();
const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0;
cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r;
cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r;
}
const ci = sf.subClusterId % SUB_CENTER_COLORS.length;
layers.push(new ScatterplotLayer({ layers.push(new ScatterplotLayer({
id: 'replay-identity-center', id: `replay-identity-center-sub${sf.subClusterId}`,
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }], data: [{ position: [cx, cy] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position, getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255], getFillColor: isStale ? [249, 115, 22, 255] : SUB_CENTER_COLORS[ci],
getRadius: 200, getRadius: 200,
radiusUnits: 'meters', radiusUnits: 'meters',
radiusMinPixels: 7, radiusMinPixels: 7,
@ -623,14 +837,128 @@ export function useGearReplayLayers(
getLineColor: [255, 255, 255, 255], getLineColor: [255, 255, 255, 255],
lineWidthMinPixels: 2, lineWidthMinPixels: 2,
})); }));
}
} // end if (frameIdx >= 0)
// ══ 6h Identity 레이어 (독립 — 1h/모델과 무관) ══
if (state.show6hPolygon && state.historyFrames6h.length > 0) {
const { index: frameIdx6h } = findFrameAtTime(state.frameTimes6h, ct, 0);
if (frameIdx6h >= 0) {
const frame6h = state.historyFrames6h[frameIdx6h];
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }];
const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct);
// 6h 폴리곤
for (const sf of subFrames6h) {
const sfMembers = interpolateSubFrameMembers(state.historyFrames6h, frameIdx6h, ct, sf.subClusterId);
const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]);
const poly = buildInterpPolygon(sfPts);
if (!poly) continue;
layers.push(new PolygonLayer({
id: `replay-6h-polygon-sub${sf.subClusterId}`,
data: [{ polygon: poly.coordinates }],
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
getFillColor: [147, 197, 253, 25] as [number, number, number, number],
getLineColor: [147, 197, 253, 160] as [number, number, number, number],
getLineWidth: 1,
lineWidthMinPixels: 1,
filled: true,
stroked: true,
}));
}
// 6h 멤버 아이콘
if (members6h.length > 0) {
layers.push(new IconLayer<MemberPosition>({
id: 'replay-6h-members',
data: members6h,
getPosition: d => [d.lon, d.lat],
getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'],
getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18,
getAngle: d => d.isGear ? 0 : -(d.cog || 0),
getColor: d => {
if (d.stale) return [100, 116, 139, 150];
return [147, 197, 253, 200];
},
sizeUnits: 'pixels',
billboard: false,
}));
// 6h 멤버 라벨
if (showLabels) {
const clustered6h = clusterLabels(members6h, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<MemberPosition>({
id: 'replay-6h-member-labels',
data: clustered6h,
getPosition: d => [d.lon, d.lat],
getText: d => {
const prefix = d.isParent ? '\u2605 ' : '';
return prefix + (d.name || d.mmsi);
},
getColor: [147, 197, 253, 230] as [number, number, number, number],
getSize: 10 * fs,
getPixelOffset: [0, 14],
background: true,
getBackgroundColor: [0, 0, 0, 200] as [number, number, number, number],
backgroundPadding: [2, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
}
// 6h TripsLayer (항적 애니메이션)
if (memberTripsData6h.length > 0) {
layers.push(new TripsLayer({
id: 'replay-6h-identity-trails',
data: memberTripsData6h,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [147, 197, 253, 180] as [number, number, number, number],
widthMinPixels: 2,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
// 6h 센터 포인트 (서브클러스터별 보간)
for (const sf of subFrames6h) {
const nextFrame6h = frameIdx6h < state.historyFrames6h.length - 1 ? state.historyFrames6h[frameIdx6h + 1] : null;
const nextSf = nextFrame6h?.subFrames?.find(s => s.subClusterId === sf.subClusterId);
let cx = sf.centerLon, cy = sf.centerLat;
if (nextSf && nextFrame6h) {
const t0 = new Date(frame6h.snapshotTime).getTime();
const t1 = new Date(nextFrame6h.snapshotTime).getTime();
const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0;
cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r;
cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r;
}
layers.push(new ScatterplotLayer({
id: `replay-6h-center-sub${sf.subClusterId}`,
data: [{ position: [cx, cy] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [147, 197, 253, 200] as [number, number, number, number],
getRadius: 150,
radiusUnits: 'meters',
radiusMinPixels: 5,
stroked: true,
getLineColor: [255, 255, 255, 200] as [number, number, number, number],
lineWidthMinPixels: 1.5,
}));
}
}
}
replayLayerRef.current = layers; replayLayerRef.current = layers;
requestRender(); requestRender();
}, [ }, [
historyFrames, memberTripsData, correlationTripsData, historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData,
centerTrailSegments, centerDotsPositions, centerTrailSegments, centerDotsPositions,
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel, enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, showTrails, showLabels, modelCenterTrails, subClusterCenters, showTrails, showLabels,
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
replayLayerRef, requestRender, replayLayerRef, requestRender,
]); ]);
@ -649,7 +977,12 @@ export function useGearReplayLayers(
// ── zustand.subscribe effect (currentTime → renderFrame) ───────────────── // ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
useEffect(() => { useEffect(() => {
if (historyFrames.length === 0) return; if (historyFrames.length === 0) {
// Reset 시 레이어 클리어
replayLayerRef.current = [];
requestRender();
return;
}
// Initial render // Initial render
renderFrame(); renderFrame();
@ -680,8 +1013,20 @@ export function useGearReplayLayers(
}, },
); );
// 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더
const unsubPolygonToggle = useGearReplayStore.subscribe(
s => [s.show1hPolygon, s.show6hPolygon] as const,
() => { debugLoggedRef.current = false; renderFrame(); },
);
const unsubPinned = useGearReplayStore.subscribe(
s => s.pinnedMmsis,
() => renderFrame(),
);
return () => { return () => {
unsub(); unsub();
unsubPolygonToggle();
unsubPinned();
if (pendingRafId) cancelAnimationFrame(pendingRafId); if (pendingRafId) cancelAnimationFrame(pendingRafId);
}; };
}, [historyFrames, renderFrame]); }, [historyFrames, renderFrame]);

파일 보기

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

파일 보기

@ -0,0 +1,352 @@
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';
import { useSymbolScale } from './useSymbolScale';
// ── 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+
export 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 { symbolScale } = useSymbolScale();
const shipSymbolScale = symbolScale.ship;
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) * shipSymbolScale;
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, shipSymbolScale]);
// 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]);
}

파일 보기

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { SymbolScaleCtx } from '../contexts/symbolScaleState';
export function useSymbolScale() {
return useContext(SymbolScaleCtx);
}

파일 보기

@ -62,6 +62,7 @@ export interface GroupPolygonDto {
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
groupKey: string; groupKey: string;
groupLabel: string; groupLabel: string;
subClusterId: number; // 0=단일/병합, 1,2,...=서브클러스터
snapshotTime: string; snapshotTime: string;
polygon: GeoJSON.Polygon | null; polygon: GeoJSON.Polygon | null;
centerLat: number; centerLat: number;
@ -72,6 +73,7 @@ export interface GroupPolygonDto {
zoneName: string | null; zoneName: string | null;
members: MemberInfo[]; members: MemberInfo[];
color: string; color: string;
resolution?: '1h' | '6h';
} }
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> { export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
@ -105,6 +107,7 @@ export interface GearCorrelationItem {
streak: number; streak: number;
observations: number; observations: number;
freezeState: string; freezeState: string;
subClusterId: number;
proximityRatio: number | null; proximityRatio: number | null;
visitScore: number | null; visitScore: number | null;
headingCoherence: number | null; headingCoherence: number | null;

파일 보기

@ -1,4 +1,4 @@
import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes';
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis'; import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
@ -235,16 +235,104 @@ export function interpolateMemberPositions(
}); });
} }
/**
* interpolateMemberPositions와 ,
* subClusterId에 .
* subClusterId에 SubFrame이 .
*/
export function interpolateSubFrameMembers(
frames: HistoryFrame[],
frameIdx: number,
timeMs: number,
subClusterId: number,
): MemberPosition[] {
if (frameIdx < 0 || frameIdx >= frames.length) return [];
const frame = frames[frameIdx];
const subFrame: SubFrame | undefined = frame.subFrames.find(sf => sf.subClusterId === subClusterId);
if (!subFrame) return [];
const isStale = !!frame._longGap || !!frame._interp;
const toPosition = (
m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean },
lon: number,
lat: number,
cog: number,
): MemberPosition => ({
mmsi: m.mmsi,
name: m.name,
lon,
lat,
cog,
role: m.role,
isParent: m.isParent,
isGear: m.role === 'GEAR' || !m.isParent,
stale: isStale,
});
// 다음 프레임 없음 — 현재 subFrame 위치 그대로 반환
if (frameIdx >= frames.length - 1) {
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
}
const nextFrame = frames[frameIdx + 1];
const nextSubFrame: SubFrame | undefined = nextFrame.subFrames.find(
sf => sf.subClusterId === subClusterId,
);
// 다음 프레임에 해당 subClusterId 없음 — 현재 위치 그대로 반환
if (!nextSubFrame) {
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
}
const t0 = new Date(frame.snapshotTime).getTime();
const t1 = new Date(nextFrame.snapshotTime).getTime();
const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0;
const nextMap = new Map(nextSubFrame.members.map(m => [m.mmsi, m]));
return subFrame.members.map(m => {
const nm = nextMap.get(m.mmsi);
if (!nm) {
return toPosition(m, m.lon, m.lat, m.cog);
}
return toPosition(
m,
m.lon + (nm.lon - m.lon) * ratio,
m.lat + (nm.lat - m.lat) * ratio,
nm.cog,
);
});
}
/** /**
* . * .
* + ( ) . * + ( ) .
*/ */
export interface ModelCenterTrail { export interface ModelCenterTrail {
modelName: string; modelName: string;
subClusterId: number; // 서브클러스터별 독립 trail
path: [number, number][]; // [lon, lat][] path: [number, number][]; // [lon, lat][]
timestamps: number[]; // relative ms timestamps: number[]; // relative ms
} }
/** 트랙 맵에서 특정 시점의 보간 위치 조회 */
function _interpTrackPos(
track: { ts: number[]; path: [number, number][] },
t: number,
): [number, number] {
if (t <= track.ts[0]) return track.path[0];
if (t >= track.ts[track.ts.length - 1]) return track.path[track.path.length - 1];
let lo = 0, hi = track.ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
return [
track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio,
track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio,
];
}
export function buildModelCenterTrails( export function buildModelCenterTrails(
frames: HistoryFrame[], frames: HistoryFrame[],
corrTracks: CorrelationVesselTrack[], corrTracks: CorrelationVesselTrack[],
@ -252,7 +340,6 @@ export function buildModelCenterTrails(
enabledVessels: Set<string>, enabledVessels: Set<string>,
startTime: number, startTime: number,
): ModelCenterTrail[] { ): ModelCenterTrail[] {
// 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]}
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>(); const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
for (const vt of corrTracks) { for (const vt of corrTracks) {
if (vt.track.length < 1) continue; if (vt.track.length < 1) continue;
@ -268,6 +355,17 @@ export function buildModelCenterTrails(
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi)); const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
if (enabledItems.length === 0) continue; if (enabledItems.length === 0) continue;
// subClusterId별 연관 선박 그룹핑
const subItemsMap = new Map<number, typeof enabledItems>();
for (const c of enabledItems) {
const sid = c.subClusterId ?? 0;
const list = subItemsMap.get(sid) ?? [];
list.push(c);
subItemsMap.set(sid, list);
}
// 서브클러스터별 독립 trail 생성
for (const [sid, subItems] of subItemsMap) {
const path: [number, number][] = []; const path: [number, number][] = [];
const timestamps: number[] = []; const timestamps: number[] = [];
@ -275,31 +373,21 @@ export function buildModelCenterTrails(
const t = new Date(frame.snapshotTime).getTime(); const t = new Date(frame.snapshotTime).getTime();
const relT = t - startTime; const relT = t - startTime;
// 멤버 위치 // 해당 서브클러스터의 멤버 위치
const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]); const sf = frame.subFrames?.find(s => s.subClusterId === sid);
const basePts: [number, number][] = sf
? sf.members.map(m => [m.lon, m.lat])
: frame.members.map(m => [m.lon, m.lat]); // fallback
// 연관 선박 위치 (트랙 보간 or 마지막 점 clamp) const allPts: [number, number][] = [...basePts];
for (const c of enabledItems) {
// 연관 선박 위치 (트랙 보간)
for (const c of subItems) {
const track = trackMap.get(c.targetMmsi); const track = trackMap.get(c.targetMmsi);
if (!track || track.path.length === 0) continue; if (!track || track.path.length === 0) continue;
allPts.push(_interpTrackPos(track, t));
let lon: number, lat: number;
if (t <= track.ts[0]) {
lon = track.path[0][0]; lat = track.path[0][1];
} else if (t >= track.ts[track.ts.length - 1]) {
const last = track.path.length - 1;
lon = track.path[last][0]; lat = track.path[last][1];
} else {
let lo = 0, hi = track.ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio;
lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio;
}
allPts.push([lon, lat]);
} }
// 폴리곤 중심 계산
const poly = buildInterpPolygon(allPts); const poly = buildInterpPolygon(allPts);
if (!poly) continue; if (!poly) continue;
const ring = poly.coordinates[0]; const ring = poly.coordinates[0];
@ -312,7 +400,8 @@ export function buildModelCenterTrails(
} }
if (path.length >= 2) { if (path.length >= 2) {
results.push({ modelName: mn, path, timestamps }); results.push({ modelName: mn, subClusterId: sid, path, timestamps });
}
} }
} }

파일 보기

@ -54,17 +54,29 @@ interface GearReplayState {
endTime: number; endTime: number;
playbackSpeed: number; playbackSpeed: number;
// Source data // Source data (1h = primary identity polygon)
historyFrames: HistoryFrame[]; historyFrames: HistoryFrame[];
frameTimes: number[]; frameTimes: number[];
selectedGroupKey: string | null; selectedGroupKey: string | null;
rawCorrelationTracks: CorrelationVesselTrack[]; rawCorrelationTracks: CorrelationVesselTrack[];
// 6h identity (독립 레이어 — 1h/모델과 무관)
historyFrames6h: HistoryFrame[];
frameTimes6h: number[];
memberTripsData6h: TripsLayerDatum[];
centerTrailSegments6h: CenterTrailSegment[];
centerDotsPositions6h: [number, number][];
subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
snapshotRanges6h: number[];
// Pre-computed layer data // Pre-computed layer data
memberTripsData: TripsLayerDatum[]; memberTripsData: TripsLayerDatum[];
correlationTripsData: TripsLayerDatum[]; correlationTripsData: TripsLayerDatum[];
centerTrailSegments: CenterTrailSegment[]; centerTrailSegments: CenterTrailSegment[];
centerDotsPositions: [number, number][]; centerDotsPositions: [number, number][];
subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
/** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */
allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[];
snapshotRanges: number[]; snapshotRanges: number[];
modelCenterTrails: ModelCenterTrail[]; modelCenterTrails: ModelCenterTrail[];
@ -75,6 +87,13 @@ interface GearReplayState {
correlationByModel: Map<string, GearCorrelationItem[]>; correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean; showTrails: boolean;
showLabels: boolean; showLabels: boolean;
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선)
show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선)
abLoop: boolean; // A-B 구간 반복 활성화
abA: number; // A 지점 (epoch ms, 0 = 미설정)
abB: number; // B 지점 (epoch ms, 0 = 미설정)
pinnedMmsis: Set<string>; // 툴팁 고정 시 강조할 MMSI 세트
// Actions // Actions
loadHistory: ( loadHistory: (
@ -83,6 +102,7 @@ interface GearReplayState {
corrData: GearCorrelationItem[], corrData: GearCorrelationItem[],
enabledModels: Set<string>, enabledModels: Set<string>,
enabledVessels: Set<string>, enabledVessels: Set<string>,
frames6h?: HistoryFrame[],
) => void; ) => void;
play: () => void; play: () => void;
pause: () => void; pause: () => void;
@ -93,6 +113,13 @@ interface GearReplayState {
setHoveredMmsi: (mmsi: string | null) => void; setHoveredMmsi: (mmsi: string | null) => void;
setShowTrails: (show: boolean) => void; setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void; setShowLabels: (show: boolean) => void;
setFocusMode: (focus: boolean) => void;
setShow1hPolygon: (show: boolean) => void;
setShow6hPolygon: (show: boolean) => void;
setAbLoop: (on: boolean) => void;
setAbA: (t: number) => void;
setAbB: (t: number) => void;
setPinnedMmsis: (mmsis: Set<string>) => void;
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
reset: () => void; reset: () => void;
} }
@ -113,7 +140,20 @@ export const useGearReplayStore = create<GearReplayState>()(
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed;
if (newTime >= state.endTime) { // A-B 구간 반복
if (state.abLoop && state.abA > 0 && state.abB > state.abA) {
if (newTime >= state.abB) {
set({ currentTime: state.abA });
animationFrameId = requestAnimationFrame(animate);
return;
}
// A 이전이면 A로 점프
if (newTime < state.abA) {
set({ currentTime: state.abA });
animationFrameId = requestAnimationFrame(animate);
return;
}
} else if (newTime >= state.endTime) {
set({ currentTime: state.startTime }); set({ currentTime: state.startTime });
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
return; return;
@ -136,12 +176,21 @@ export const useGearReplayStore = create<GearReplayState>()(
frameTimes: [], frameTimes: [],
selectedGroupKey: null, selectedGroupKey: null,
rawCorrelationTracks: [], rawCorrelationTracks: [],
historyFrames6h: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
// Pre-computed layer data // Pre-computed layer data
memberTripsData: [], memberTripsData: [],
correlationTripsData: [], correlationTripsData: [],
centerTrailSegments: [], centerTrailSegments: [],
centerDotsPositions: [], centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [], snapshotRanges: [],
modelCenterTrails: [], modelCenterTrails: [],
@ -151,20 +200,34 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null, hoveredMmsi: null,
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ──────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => {
const startTime = Date.now() - 12 * 60 * 60 * 1000; const startTime = Date.now() - 12 * 60 * 60 * 1000;
const endTime = Date.now(); const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime());
const memberTrips = buildMemberTripsData(frames, startTime); const memberTrips = buildMemberTripsData(frames, startTime);
const corrTrips = buildCorrelationTripsData(corrTracks, startTime); const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
const { segments, dots } = buildCenterTrailData(frames); const { segments, dots } = buildCenterTrailData(frames);
const ranges = buildSnapshotRanges(frames, startTime, endTime); const ranges = buildSnapshotRanges(frames, startTime, endTime);
// 6h 전처리 (동일한 빌드 함수)
const f6h = frames6h ?? [];
const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : [];
const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] };
const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : [];
const byModel = new Map<string, GearCorrelationItem[]>(); const byModel = new Map<string, GearCorrelationItem[]>();
for (const c of corrData) { for (const c of corrData) {
const list = byModel.get(c.modelName) ?? []; const list = byModel.get(c.modelName) ?? [];
@ -176,7 +239,13 @@ export const useGearReplayStore = create<GearReplayState>()(
set({ set({
historyFrames: frames, historyFrames: frames,
historyFrames6h: f6h,
frameTimes, frameTimes,
frameTimes6h,
memberTripsData6h: memberTrips6h,
centerTrailSegments6h: seg6h,
centerDotsPositions6h: dots6h,
snapshotRanges6h: ranges6h,
startTime, startTime,
endTime, endTime,
currentTime: startTime, currentTime: startTime,
@ -201,9 +270,9 @@ export const useGearReplayStore = create<GearReplayState>()(
lastFrameTime = null; lastFrameTime = null;
if (state.currentTime >= state.endTime) { if (state.currentTime >= state.endTime) {
set({ isPlaying: true, currentTime: state.startTime }); set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() });
} else { } else {
set({ isPlaying: true }); set({ isPlaying: true, pinnedMmsis: new Set() });
} }
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
@ -238,6 +307,22 @@ export const useGearReplayStore = create<GearReplayState>()(
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setShowTrails: (show) => set({ showTrails: show }), setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }), setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }),
setShow1hPolygon: (show) => set({ show1hPolygon: show }),
setShow6hPolygon: (show) => set({ show6hPolygon: show }),
setAbLoop: (on) => {
const { startTime, endTime } = get();
if (on && startTime > 0) {
// 기본 A-B: 전체 구간의 마지막 4시간
const dur = endTime - startTime;
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime });
} else {
set({ abLoop: false, abA: 0, abB: 0 });
}
},
setAbA: (t) => set({ abA: t }),
setAbB: (t) => set({ abB: t }),
setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }),
updateCorrelation: (corrData, corrTracks) => { updateCorrelation: (corrData, corrTracks) => {
const state = get(); const state = get();
@ -275,13 +360,22 @@ export const useGearReplayStore = create<GearReplayState>()(
endTime: 0, endTime: 0,
playbackSpeed: 1, playbackSpeed: 1,
historyFrames: [], historyFrames: [],
historyFrames6h: [],
frameTimes: [], frameTimes: [],
frameTimes6h: [],
memberTripsData6h: [],
centerTrailSegments6h: [],
centerDotsPositions6h: [],
subClusterCenters6h: [],
snapshotRanges6h: [],
selectedGroupKey: null, selectedGroupKey: null,
rawCorrelationTracks: [], rawCorrelationTracks: [],
memberTripsData: [], memberTripsData: [],
correlationTripsData: [], correlationTripsData: [],
centerTrailSegments: [], centerTrailSegments: [],
centerDotsPositions: [], centerDotsPositions: [],
subClusterCenters: [],
allHistoryMembers: [],
snapshotRanges: [], snapshotRanges: [],
modelCenterTrails: [], modelCenterTrails: [],
enabledModels: new Set<string>(), enabledModels: new Set<string>(),
@ -289,6 +383,13 @@ export const useGearReplayStore = create<GearReplayState>()(
hoveredMmsi: null, hoveredMmsi: null,
showTrails: true, showTrails: true,
showLabels: true, showLabels: true,
focusMode: false,
show1hPolygon: true,
show6hPolygon: false,
abLoop: false,
abA: 0,
abB: 0,
pinnedMmsis: new Set<string>(),
correlationByModel: new Map<string, GearCorrelationItem[]>(), correlationByModel: new Map<string, GearCorrelationItem[]>(),
}); });
}, },

파일 보기

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

파일 보기

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

파일 보기

@ -18,6 +18,8 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from algorithms.polygon_builder import _get_time_bucket_age
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -479,7 +481,7 @@ def _compute_gear_active_ratio(
gear_members: list[dict], gear_members: list[dict],
all_positions: dict[str, dict], all_positions: dict[str, dict],
now: datetime, now: datetime,
stale_sec: float = 21600, stale_sec: float = 3600,
) -> float: ) -> float:
"""어구 그룹의 활성 멤버 비율.""" """어구 그룹의 활성 멤버 비율."""
if not gear_members: if not gear_members:
@ -556,19 +558,34 @@ def run_gear_correlation(
score_batch: list[tuple] = [] score_batch: list[tuple] = []
total_updated = 0 total_updated = 0
total_raw = 0 total_raw = 0
processed_keys: set[tuple] = set() # (model_id, parent_name, sub_cluster_id, target_mmsi)
default_params = models[0] default_params = models[0]
for gear_group in gear_groups: for gear_group in gear_groups:
parent_name = gear_group['parent_name'] parent_name = gear_group['parent_name']
sub_cluster_id = gear_group.get('sub_cluster_id', 0)
members = gear_group['members'] members = gear_group['members']
if not members: if not members:
continue continue
# 그룹 중심 + 반경 # 1h 활성 멤버 필터 (center/radius 계산용)
center_lat = sum(m['lat'] for m in members) / len(members) display_members = [
center_lon = sum(m['lon'] for m in members) / len(members) m for m in members
group_radius = _compute_group_radius(members) if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600
]
# fallback: < 2이면 time_bucket 최신 2개 유지
if len(display_members) < 2 and len(members) >= 2:
display_members = sorted(
members,
key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now),
)[:2]
active_members = display_members if len(display_members) >= 2 else members
# 그룹 중심 + 반경 (1h 활성 멤버 기반)
center_lat = sum(m['lat'] for m in active_members) / len(active_members)
center_lon = sum(m['lon'] for m in active_members) / len(active_members)
group_radius = _compute_group_radius(active_members)
# 어구 활성도 # 어구 활성도
active_ratio = _compute_gear_active_ratio(members, all_positions, now) active_ratio = _compute_gear_active_ratio(members, all_positions, now)
@ -617,7 +634,7 @@ def run_gear_correlation(
# raw 메트릭 배치 수집 # raw 메트릭 배치 수집
raw_batch.append(( raw_batch.append((
now, parent_name, target_mmsi, target_type, target_name, now, parent_name, sub_cluster_id, target_mmsi, target_type, target_name,
metrics.get('proximity_ratio'), metrics.get('visit_score'), metrics.get('proximity_ratio'), metrics.get('visit_score'),
metrics.get('activity_sync'), metrics.get('dtw_similarity'), metrics.get('activity_sync'), metrics.get('dtw_similarity'),
metrics.get('speed_correlation'), metrics.get('heading_coherence'), metrics.get('speed_correlation'), metrics.get('heading_coherence'),
@ -637,7 +654,7 @@ def run_gear_correlation(
) )
# 사전 로드된 점수에서 조회 (DB 쿼리 없음) # 사전 로드된 점수에서 조회 (DB 쿼리 없음)
score_key = (model.model_id, parent_name, target_mmsi) score_key = (model.model_id, parent_name, sub_cluster_id, target_mmsi)
prev = all_scores.get(score_key) prev = all_scores.get(score_key)
prev_score = prev['current_score'] if prev else None prev_score = prev['current_score'] if prev else None
streak = prev['streak_count'] if prev else 0 streak = prev['streak_count'] if prev else 0
@ -649,15 +666,39 @@ def run_gear_correlation(
0.0, model, 0.0, model,
) )
processed_keys.add(score_key)
if new_score >= model.track_threshold or prev is not None: if new_score >= model.track_threshold or prev is not None:
score_batch.append(( score_batch.append((
model.model_id, parent_name, target_mmsi, model.model_id, parent_name, sub_cluster_id, target_mmsi,
target_type, target_name, target_type, target_name,
round(new_score, 6), new_streak, state, round(new_score, 6), new_streak, state,
now, now, now, now, now, now,
)) ))
total_updated += 1 total_updated += 1
# ── 반경 밖 이탈 선박 강제 감쇠 ──────────────────────────────────
# all_scores에 기록이 있지만 이번 사이클 후보에서 빠진 항목:
# 선박이 탐색 반경(group_radius × 3)을 완전히 벗어난 경우.
# Freeze 조건 무시하고 decay_fast 적용 → 빠르게 0으로 수렴.
for score_key, prev in all_scores.items():
if score_key in processed_keys:
continue
prev_score = prev['current_score']
if prev_score is None or prev_score <= 0:
continue
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s = score_key
# 해당 모델의 decay_fast 파라미터 사용
model_params = next((m for m in models if m.model_id == model_id), default_params)
new_score = max(0.0, prev_score - model_params.decay_fast)
score_batch.append((
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s,
prev.get('target_type', 'VESSEL'), prev.get('target_name', ''),
round(new_score, 6), 0, 'OUT_OF_RANGE',
prev.get('last_observed_at', now), now, now,
))
total_updated += 1
# 배치 DB 저장 # 배치 DB 저장
_batch_insert_raw(conn, raw_batch) _batch_insert_raw(conn, raw_batch)
_batch_upsert_scores(conn, score_batch) _batch_upsert_scores(conn, score_batch)
@ -703,21 +744,24 @@ def _load_active_models(conn) -> list[ModelParams]:
def _load_all_scores(conn) -> dict[tuple, dict]: def _load_all_scores(conn) -> dict[tuple, dict]:
"""모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}""" """모든 점수를 사전 로드. {(model_id, group_key, sub_cluster_id, target_mmsi): {...}}"""
cur = conn.cursor() cur = conn.cursor()
try: try:
cur.execute( cur.execute(
"SELECT model_id, group_key, target_mmsi, " "SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
"current_score, streak_count, last_observed_at " "current_score, streak_count, last_observed_at, "
"target_type, target_name "
"FROM kcg.gear_correlation_scores" "FROM kcg.gear_correlation_scores"
) )
result = {} result = {}
for row in cur.fetchall(): for row in cur.fetchall():
key = (row[0], row[1], row[2]) key = (row[0], row[1], row[2], row[3])
result[key] = { result[key] = {
'current_score': row[3], 'current_score': row[4],
'streak_count': row[4], 'streak_count': row[5],
'last_observed_at': row[5], 'last_observed_at': row[6],
'target_type': row[7],
'target_name': row[8],
} }
return result return result
except Exception as e: except Exception as e:
@ -737,7 +781,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_raw_metrics """INSERT INTO kcg.gear_correlation_raw_metrics
(observed_at, group_key, target_mmsi, target_type, target_name, (observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
proximity_ratio, visit_score, activity_sync, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, dtw_similarity, speed_correlation, heading_coherence,
drift_similarity, shadow_stay, shadow_return, drift_similarity, shadow_stay, shadow_return,
@ -762,11 +806,11 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
execute_values( execute_values(
cur, cur,
"""INSERT INTO kcg.gear_correlation_scores """INSERT INTO kcg.gear_correlation_scores
(model_id, group_key, target_mmsi, target_type, target_name, (model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
current_score, streak_count, freeze_state, current_score, streak_count, freeze_state,
first_observed_at, last_observed_at, updated_at) first_observed_at, last_observed_at, updated_at)
VALUES %s VALUES %s
ON CONFLICT (model_id, group_key, target_mmsi) ON CONFLICT (model_id, group_key, sub_cluster_id, target_mmsi)
DO UPDATE SET DO UPDATE SET
target_type = EXCLUDED.target_type, target_type = EXCLUDED.target_type,
target_name = EXCLUDED.target_name, target_name = EXCLUDED.target_name,

파일 보기

@ -11,6 +11,9 @@ import math
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo
import pandas as pd
try: try:
from shapely.geometry import MultiPoint, Point from shapely.geometry import MultiPoint, Point
@ -26,11 +29,30 @@ logger = logging.getLogger(__name__)
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일) # 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
MAX_DIST_DEG = 0.15 # ~10NM MAX_DIST_DEG = 0.15 # ~10NM
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) — 그룹 멤버 탐색용
DISPLAY_STALE_SEC = 3600 # 1시간 — 폴리곤 스냅샷 노출 기준 (프론트엔드 초기 로드 minutes=60과 동기화)
# time_bucket(적재시간) 기반 필터링 — AIS 원본 timestamp는 부표 시계 오류로 부정확할 수 있음
FLEET_BUFFER_DEG = 0.02 FLEET_BUFFER_DEG = 0.02
GEAR_BUFFER_DEG = 0.01 GEAR_BUFFER_DEG = 0.01
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외) MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
_KST = ZoneInfo('Asia/Seoul')
def _get_time_bucket_age(mmsi: str, all_positions: dict, now: datetime) -> float:
"""MMSI의 time_bucket 기반 age(초) 반환. 실패 시 inf."""
pos = all_positions.get(mmsi)
tb = pos.get('time_bucket') if pos else None
if tb is None:
return float('inf')
try:
tb_dt = pd.Timestamp(tb)
if tb_dt.tzinfo is None:
tb_dt = tb_dt.tz_localize(_KST).tz_convert(timezone.utc)
return (now - tb_dt.to_pydatetime()).total_seconds()
except Exception:
return float('inf')
# 수역 내 어구 색상, 수역 외 어구 색상 # 수역 내 어구 색상, 수역 외 어구 색상
_COLOR_GEAR_IN_ZONE = '#ef4444' _COLOR_GEAR_IN_ZONE = '#ef4444'
_COLOR_GEAR_OUT_ZONE = '#f97316' _COLOR_GEAR_OUT_ZONE = '#f97316'
@ -157,7 +179,6 @@ def detect_gear_groups(
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc) last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
else: else:
try: try:
import pandas as pd
last_dt = pd.Timestamp(ts).to_pydatetime() last_dt = pd.Timestamp(ts).to_pydatetime()
if last_dt.tzinfo is None: if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc) last_dt = last_dt.replace(tzinfo=timezone.utc)
@ -171,6 +192,10 @@ def detect_gear_groups(
if not m: if not m:
continue continue
# 한국 국적 선박(MMSI 440/441)은 어구 AIS 미사용 → 제외
if mmsi.startswith('440') or mmsi.startswith('441'):
continue
parent_raw = (m.group(1) or name).strip() parent_raw = (m.group(1) or name).strip()
parent_key = _normalize_parent(parent_raw) parent_key = _normalize_parent(parent_raw)
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
@ -183,6 +208,7 @@ def detect_gear_groups(
'lon': pos['lon'], 'lon': pos['lon'],
'sog': pos.get('sog', 0), 'sog': pos.get('sog', 0),
'cog': pos.get('cog', 0), 'cog': pos.get('cog', 0),
'timestamp': ts,
} }
raw_groups.setdefault(parent_key, []).append(entry) raw_groups.setdefault(parent_key, []).append(entry)
@ -256,14 +282,15 @@ def detect_gear_groups(
for i in idxs for i in idxs
] ]
# 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2 # group_key는 항상 원본명 유지, 서브클러스터는 별도 ID로 구분
sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}' sub_cluster_id = 0 if len(clusters) == 1 else (ci + 1)
sub_mmsi = parent_mmsi if has_seed else None sub_mmsi = parent_mmsi if has_seed else None
results.append({ results.append({
'parent_name': sub_name, 'parent_name': display_name,
'parent_key': parent_key, 'parent_key': parent_key,
'parent_mmsi': sub_mmsi, 'parent_mmsi': sub_mmsi,
'sub_cluster_id': sub_cluster_id,
'members': members, 'members': members,
}) })
@ -294,6 +321,7 @@ def detect_gear_groups(
existing_mmsis.add(m['mmsi']) existing_mmsis.add(m['mmsi'])
if not big['parent_mmsi'] and small['parent_mmsi']: if not big['parent_mmsi'] and small['parent_mmsi']:
big['parent_mmsi'] = small['parent_mmsi'] big['parent_mmsi'] = small['parent_mmsi']
big['sub_cluster_id'] = 0 # 병합됨 → 단일 클러스터
skip.add(j) skip.add(j)
del big['parent_key'] del big['parent_key']
merged.append(big) merged.append(big)
@ -355,8 +383,12 @@ def build_all_group_snapshots(
'isParent': False, 'isParent': False,
}) })
# 2척 미만은 폴리곤 미생성 newest_age = min(
if len(points) < 2: (_get_time_bucket_age(m['mmsi'], all_positions, now) for m in members),
default=float('inf'),
)
# 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성
if len(points) < 2 or newest_age > DISPLAY_STALE_SEC:
continue continue
polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon( polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon(
@ -378,15 +410,46 @@ def build_all_group_snapshots(
'color': _cluster_color(company_id), 'color': _cluster_color(company_id),
}) })
# ── GEAR 타입: detect_gear_groups 결과 순회 ─────────────────── # ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
gear_groups = detect_gear_groups(vessel_store, now=now) gear_groups = detect_gear_groups(vessel_store, now=now)
for group in gear_groups: for group in gear_groups:
parent_name: str = group['parent_name'] parent_name: str = group['parent_name']
parent_mmsi: Optional[str] = group['parent_mmsi'] parent_mmsi: Optional[str] = group['parent_mmsi']
gear_members: list[dict] = group['members'] gear_members: list[dict] = group['members'] # 6h STALE 기반 전체 멤버
# 수역 분류: anchor(모선 or 첫 어구) 위치 기준 if not gear_members:
continue
# ── 1h 활성 멤버 필터 ──
display_members_1h = [
gm for gm in gear_members
if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC
]
# fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존)
if len(display_members_1h) < 2 and len(gear_members) >= 2:
sorted_by_age = sorted(
gear_members,
key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now),
)
display_members_1h = sorted_by_age[:2]
# ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ──
newest_age_6h = min(
(_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members),
default=float('inf'),
)
display_members_6h = gear_members
# ── resolution별 스냅샷 생성 ──
for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]:
if len(members_for_snap) < 2:
continue
# 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵
if resolution == '6h' and newest_age_6h > STALE_SEC:
continue
# 수역 분류: anchor(모선 or 첫 멤버) 위치 기준
anchor_lat: Optional[float] = None anchor_lat: Optional[float] = None
anchor_lon: Optional[float] = None anchor_lon: Optional[float] = None
@ -395,9 +458,9 @@ def build_all_group_snapshots(
anchor_lat = parent_pos['lat'] anchor_lat = parent_pos['lat']
anchor_lon = parent_pos['lon'] anchor_lon = parent_pos['lon']
if anchor_lat is None and gear_members: if anchor_lat is None and members_for_snap:
anchor_lat = gear_members[0]['lat'] anchor_lat = members_for_snap[0]['lat']
anchor_lon = gear_members[0]['lon'] anchor_lon = members_for_snap[0]['lon']
if anchor_lat is None: if anchor_lat is None:
continue continue
@ -408,18 +471,17 @@ def build_all_group_snapshots(
zone_name = zone_info.get('zone_name') if in_zone else None zone_name = zone_info.get('zone_name') if in_zone else None
# 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외 # 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외
if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: if not in_zone and len(members_for_snap) < MIN_GEAR_GROUP_SIZE:
continue continue
# 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만) # 폴리곤 points: 멤버 좌표 + 모선 좌표 (근접 시에만)
points = [(g['lon'], g['lat']) for g in gear_members] points = [(g['lon'], g['lat']) for g in members_for_snap]
parent_nearby = False parent_nearby = False
if parent_mmsi and parent_mmsi in all_positions: if parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi] parent_pos = all_positions[parent_mmsi]
p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] p_lon, p_lat = parent_pos['lon'], parent_pos['lat']
# 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함
if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2
and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members): and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in members_for_snap):
if (p_lon, p_lat) not in points: if (p_lon, p_lat) not in points:
points.append((p_lon, p_lat)) points.append((p_lon, p_lat))
parent_nearby = True parent_nearby = True
@ -430,7 +492,6 @@ def build_all_group_snapshots(
# members JSONB 구성 # members JSONB 구성
members_out: list[dict] = [] members_out: list[dict] = []
# 모선 먼저 (근접 시에만)
if parent_nearby and parent_mmsi and parent_mmsi in all_positions: if parent_nearby and parent_mmsi and parent_mmsi in all_positions:
parent_pos = all_positions[parent_mmsi] parent_pos = all_positions[parent_mmsi]
members_out.append({ members_out.append({
@ -443,8 +504,7 @@ def build_all_group_snapshots(
'role': 'PARENT', 'role': 'PARENT',
'isParent': True, 'isParent': True,
}) })
# 어구 목록 for g in members_for_snap:
for g in gear_members:
members_out.append({ members_out.append({
'mmsi': g['mmsi'], 'mmsi': g['mmsi'],
'name': g['name'], 'name': g['name'],
@ -462,6 +522,8 @@ def build_all_group_snapshots(
'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE', 'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE',
'group_key': parent_name, 'group_key': parent_name,
'group_label': parent_name, 'group_label': parent_name,
'sub_cluster_id': group.get('sub_cluster_id', 0),
'resolution': resolution,
'snapshot_time': now, 'snapshot_time': now,
'polygon_wkt': polygon_wkt, 'polygon_wkt': polygon_wkt,
'center_wkt': center_wkt, 'center_wkt': center_wkt,

파일 보기

@ -345,6 +345,7 @@ class VesselStore:
'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0), 'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0),
'cog': cog, 'cog': cog,
'timestamp': last.get('timestamp'), 'timestamp': last.get('timestamp'),
'time_bucket': last.get('time_bucket'),
'name': info.get('name', ''), 'name': info.get('name', ''),
} }
return result return result

파일 보기

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

파일 보기

@ -60,12 +60,13 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
"""한국 해역 전 선박의 궤적 포인트를 조회한다. """한국 해역 전 선박의 궤적 포인트를 조회한다.
LineStringM 지오메트리에서 개별 포인트를 추출하며, LineStringM 지오메트리에서 개별 포인트를 추출하며,
한국 해역(124-132E, 32-39N) 최근 N시간 데이터를 반환한다. 한국 해역(122-132E, 31-39N) 최근 N시간 데이터를 반환한다.
""" """
query = f""" query = f"""
SELECT SELECT
t.mmsi, t.mmsi,
to_timestamp(ST_M((dp).geom)) as timestamp, to_timestamp(ST_M((dp).geom)) as timestamp,
t.time_bucket,
ST_Y((dp).geom) as lat, ST_Y((dp).geom) as lat,
ST_X((dp).geom) as lon, ST_X((dp).geom) as lon,
CASE CASE
@ -75,7 +76,7 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
FROM signal.t_vessel_tracks_5min t, FROM signal.t_vessel_tracks_5min t,
LATERAL ST_DumpPoints(t.track_geom) dp LATERAL ST_DumpPoints(t.track_geom) dp
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours' WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours'
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
""" """
@ -104,6 +105,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
SELECT SELECT
t.mmsi, t.mmsi,
to_timestamp(ST_M((dp).geom)) as timestamp, to_timestamp(ST_M((dp).geom)) as timestamp,
t.time_bucket,
ST_Y((dp).geom) as lat, ST_Y((dp).geom) as lat,
ST_X((dp).geom) as lon, ST_X((dp).geom) as lon,
CASE CASE
@ -113,7 +115,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
FROM signal.t_vessel_tracks_5min t, FROM signal.t_vessel_tracks_5min t,
LATERAL ST_DumpPoints(t.track_geom) dp LATERAL ST_DumpPoints(t.track_geom) dp
WHERE t.time_bucket > %s WHERE t.time_bucket > %s
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326) AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom)) ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
""" """