Merge pull request 'release: 2026-04-01 (55건 커밋)' (#214) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m41s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m41s
This commit is contained in:
커밋
31f557e54d
@ -14,6 +14,7 @@ public class GroupPolygonDto {
|
||||
private String groupType;
|
||||
private String groupKey;
|
||||
private String groupLabel;
|
||||
private int subClusterId;
|
||||
private String snapshotTime;
|
||||
private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON)
|
||||
private double centerLat;
|
||||
@ -24,4 +25,5 @@ public class GroupPolygonDto {
|
||||
private String zoneName;
|
||||
private List<Map<String, Object>> members;
|
||||
private String color;
|
||||
private String resolution;
|
||||
}
|
||||
|
||||
@ -28,20 +28,21 @@ public class GroupPolygonService {
|
||||
private volatile long lastCacheTime = 0;
|
||||
|
||||
private static final String LATEST_GROUPS_SQL = """
|
||||
SELECT group_type, group_key, group_label, snapshot_time,
|
||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||
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
|
||||
""";
|
||||
|
||||
private static final String GROUP_DETAIL_SQL = """
|
||||
SELECT group_type, group_key, group_label, snapshot_time,
|
||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||
FROM kcg.group_polygon_snapshots
|
||||
WHERE group_key = ?
|
||||
ORDER BY snapshot_time DESC
|
||||
@ -49,39 +50,47 @@ public class GroupPolygonService {
|
||||
""";
|
||||
|
||||
private static final String GROUP_HISTORY_SQL = """
|
||||
SELECT group_type, group_key, group_label, snapshot_time,
|
||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
||||
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||
FROM kcg.group_polygon_snapshots
|
||||
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
|
||||
ORDER BY snapshot_time DESC
|
||||
""";
|
||||
|
||||
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.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
|
||||
FROM kcg.gear_correlation_scores s
|
||||
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
|
||||
ORDER BY m.id, s.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 (
|
||||
SELECT proximity_ratio, visit_score, heading_coherence
|
||||
FROM kcg.gear_correlation_raw_metrics
|
||||
WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi
|
||||
WHERE group_key = ? AND target_mmsi = bs.target_mmsi
|
||||
ORDER BY observed_at DESC LIMIT 1
|
||||
) r ON TRUE
|
||||
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
|
||||
ORDER BY m.is_default DESC, s.current_score DESC
|
||||
ORDER BY bs.model_id, bs.current_score DESC
|
||||
""";
|
||||
|
||||
private static final String GEAR_STATS_SQL = """
|
||||
SELECT COUNT(*) AS gear_groups,
|
||||
COALESCE(SUM(member_count), 0) AS gear_count
|
||||
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 resolution = '1h'
|
||||
""";
|
||||
|
||||
/**
|
||||
@ -114,6 +123,7 @@ public class GroupPolygonService {
|
||||
row.put("observations", rs.getInt("observation_count"));
|
||||
row.put("freezeState", rs.getString("freeze_state"));
|
||||
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("visitScore", rs.getObject("visit_score"));
|
||||
row.put("headingCoherence", rs.getObject("heading_coherence"));
|
||||
@ -121,7 +131,7 @@ public class GroupPolygonService {
|
||||
row.put("modelName", rs.getString("model_name"));
|
||||
row.put("isDefault", rs.getBoolean("is_default"));
|
||||
return row;
|
||||
}, groupKey, minScore);
|
||||
}, groupKey, minScore, groupKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage());
|
||||
return List.of();
|
||||
@ -162,6 +172,7 @@ public class GroupPolygonService {
|
||||
|
||||
/**
|
||||
* 특정 그룹의 시간별 히스토리.
|
||||
* sub_cluster_id 포함하여 raw 반환 — 프론트에서 서브클러스터별 독립 center trail 구성.
|
||||
*/
|
||||
public List<GroupPolygonDto> getGroupHistory(String groupKey, int hours) {
|
||||
return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours));
|
||||
@ -192,6 +203,7 @@ public class GroupPolygonService {
|
||||
.groupType(rs.getString("group_type"))
|
||||
.groupKey(rs.getString("group_key"))
|
||||
.groupLabel(rs.getString("group_label"))
|
||||
.subClusterId(rs.getInt("sub_cluster_id"))
|
||||
.snapshotTime(rs.getString("snapshot_time"))
|
||||
.polygon(polygonObj)
|
||||
.centerLat(rs.getDouble("center_lat"))
|
||||
@ -202,6 +214,7 @@ public class GroupPolygonService {
|
||||
.zoneName(rs.getString("zone_name"))
|
||||
.members(members)
|
||||
.color(rs.getString("color"))
|
||||
.resolution(rs.getString("resolution"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
14
database/migration/011_polygon_resolution.sql
Normal file
14
database/migration/011_polygon_resolution.sql
Normal file
@ -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]
|
||||
|
||||
## [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]
|
||||
|
||||
### 추가
|
||||
|
||||
1
frontend/.gitignore
vendored
Normal file
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 { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||
import { FontScaleProvider } from './contexts/FontScaleContext';
|
||||
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
|
||||
import { IranDashboard } from './components/iran/IranDashboard';
|
||||
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||
import './App.css';
|
||||
@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
return (
|
||||
<FontScaleProvider>
|
||||
<SymbolScaleProvider>
|
||||
<SharedFilterProvider>
|
||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||
<header className="app-header">
|
||||
@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
)}
|
||||
</div>
|
||||
</SharedFilterProvider>
|
||||
</SymbolScaleProvider>
|
||||
</FontScaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||
import { FontScalePanel } from './FontScalePanel';
|
||||
import { SymbolScalePanel } from './SymbolScalePanel';
|
||||
|
||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||
const AC_CAT_COLORS: Record<string, string> = {
|
||||
@ -897,6 +898,7 @@ export function LayerPanel({
|
||||
)}
|
||||
</div>
|
||||
<FontScalePanel />
|
||||
<SymbolScalePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
43
frontend/src/components/common/SymbolScalePanel.tsx
Normal file
@ -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>◆ 심볼 크기</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 { MemberInfo } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
||||
@ -44,80 +46,126 @@ const CorrelationPanel = ({
|
||||
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
|
||||
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
|
||||
|
||||
// Compute identity data from groupPolygons
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
const identityVessels = group?.members.filter(m => m.isParent) ?? [];
|
||||
const identityGear = group?.members.filter(m => !m.isParent) ?? [];
|
||||
// Card expand state
|
||||
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
|
||||
|
||||
// Card ref map for tooltip positioning
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const setCardRef = useCallback((model: string, el: HTMLDivElement | null) => {
|
||||
if (el) cardRefs.current.set(model, el);
|
||||
else cardRefs.current.delete(model);
|
||||
}, []);
|
||||
const toggleCardExpand = (model: string) => {
|
||||
setExpandedCards(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(model)) next.delete(model); else next.add(model);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Identity 목록: 리플레이 활성 시 전체 구간 멤버, 아닐 때 현재 스냅샷 멤버
|
||||
const allHistoryMembers = useGearReplayStore(s => s.allHistoryMembers);
|
||||
const { identityVessels, identityGear } = useMemo(() => {
|
||||
if (historyActive && allHistoryMembers.length > 0) {
|
||||
return {
|
||||
identityVessels: allHistoryMembers.filter(m => m.isParent),
|
||||
identityGear: allHistoryMembers.filter(m => !m.isParent),
|
||||
};
|
||||
}
|
||||
if (!groupPolygons || !selectedGearGroup) return { identityVessels: [] as MemberInfo[], identityGear: [] as MemberInfo[] };
|
||||
const allGear = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const matches = allGear.filter(g => g.groupKey === selectedGearGroup);
|
||||
const seen = new Set<string>();
|
||||
const members: MemberInfo[] = [];
|
||||
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } }
|
||||
return {
|
||||
identityVessels: members.filter(m => m.isParent),
|
||||
identityGear: members.filter(m => !m.isParent),
|
||||
};
|
||||
}, [historyActive, allHistoryMembers, groupPolygons, selectedGearGroup]);
|
||||
|
||||
// Suppress unused MODEL_ORDER warning — used for ordering checks
|
||||
void _MODEL_ORDER;
|
||||
|
||||
// Common card styles
|
||||
const CARD_WIDTH = 180;
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
borderRadius: 6,
|
||||
minWidth: 160,
|
||||
maxWidth: 200,
|
||||
width: CARD_WIDTH,
|
||||
minWidth: CARD_WIDTH,
|
||||
flexShrink: 0,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const cardScrollStyle: React.CSSProperties = {
|
||||
padding: '6px 8px',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
const CARD_COLLAPSED_H = 200;
|
||||
const CARD_EXPANDED_H = 500;
|
||||
|
||||
const cardFooterStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 8px 6px',
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
};
|
||||
|
||||
// Model title tooltip hover/click handlers
|
||||
const getCardBodyStyle = (model: string): React.CSSProperties => ({
|
||||
padding: '6px 8px 4px',
|
||||
maxHeight: expandedCards.has(model) ? CARD_EXPANDED_H : CARD_COLLAPSED_H,
|
||||
overflowY: 'auto',
|
||||
transition: 'max-height 0.2s ease',
|
||||
});
|
||||
|
||||
// Model title tooltip: hover → show, right-click → pin
|
||||
const handleTipHover = (model: string) => {
|
||||
if (!pinnedModelTip) setHoveredModelTip(model);
|
||||
};
|
||||
const handleTipLeave = () => {
|
||||
if (!pinnedModelTip) setHoveredModelTip(null);
|
||||
};
|
||||
const handleTipClick = (model: string) => {
|
||||
const handleTipContextMenu = (e: React.MouseEvent, model: string) => {
|
||||
e.preventDefault();
|
||||
setPinnedModelTip(prev => prev === model ? null : model);
|
||||
setHoveredModelTip(null);
|
||||
};
|
||||
|
||||
const renderModelTip = (model: string, color: string) => {
|
||||
if (activeModelTip !== model) return null;
|
||||
const desc = MODEL_DESC[model];
|
||||
// 툴팁은 카드 밖에서 fixed로 렌더 (overflow 영향 안 받음)
|
||||
const renderFloatingTip = () => {
|
||||
if (!activeModelTip) return null;
|
||||
const desc = MODEL_DESC[activeModelTip];
|
||||
if (!desc) return null;
|
||||
const el = cardRefs.current.get(activeModelTip);
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const color = MODEL_COLORS[activeModelTip] ?? '#94a3b8';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: 4,
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
top: rect.top - 4,
|
||||
transform: 'translateY(-100%)',
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(15,23,42,0.97)',
|
||||
border: `1px solid ${color}66`,
|
||||
borderRadius: 5,
|
||||
fontSize: 9,
|
||||
color: '#e2e8f0',
|
||||
zIndex: 30,
|
||||
zIndex: 50,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
|
||||
pointerEvents: pinnedModelTip === model ? 'auto' : 'none',
|
||||
pointerEvents: pinnedModelTip ? 'auto' : 'none',
|
||||
fontFamily: FONT_MONO,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
|
||||
{desc.details.map((line, i) => (
|
||||
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
|
||||
))}
|
||||
{pinnedModelTip === model && (
|
||||
{pinnedModelTip && (
|
||||
<div style={{
|
||||
color: '#64748b',
|
||||
fontSize: 8,
|
||||
marginTop: 4,
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
paddingTop: 3,
|
||||
color: '#64748b', fontSize: 8, marginTop: 4,
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 3,
|
||||
}}>
|
||||
클릭하여 닫기
|
||||
우클릭하여 닫기
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -142,7 +190,7 @@ const CorrelationPanel = ({
|
||||
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
|
||||
return (
|
||||
<div
|
||||
key={c.targetMmsi}
|
||||
key={`${modelName}-${c.targetMmsi}`}
|
||||
style={{
|
||||
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
|
||||
@ -172,11 +220,11 @@ const CorrelationPanel = ({
|
||||
};
|
||||
|
||||
// Member row renderer (identity model — no score, independent hover)
|
||||
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string) => {
|
||||
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
|
||||
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
|
||||
return (
|
||||
<div
|
||||
key={m.mmsi}
|
||||
key={`${keyPrefix}-${m.mmsi}`}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
marginBottom: 1,
|
||||
@ -203,27 +251,25 @@ const CorrelationPanel = ({
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: historyActive ? 120 : 20,
|
||||
left: 'calc(50% - 210px)',
|
||||
left: 'calc(50% + 100px)',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'calc(100vw - 880px)',
|
||||
maxWidth: 1320,
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'flex-end',
|
||||
zIndex: 21,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
pointerEvents: 'auto',
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
}}>
|
||||
{/* 고정: 토글 패널 */}
|
||||
{/* 고정: 토글 패널 (스크롤 밖) */}
|
||||
<div style={{
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
minWidth: 165,
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
@ -246,42 +292,41 @@ const CorrelationPanel = ({
|
||||
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
|
||||
</label>
|
||||
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>로딩...</div>}
|
||||
{availableModels.map(m => {
|
||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
||||
const modelItems = correlationByModel.get(m.name) ?? [];
|
||||
{_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
const modelItems = correlationByModel.get(mn) ?? [];
|
||||
const hasData = modelItems.length > 0;
|
||||
const vc = 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 (
|
||||
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
||||
<input type="checkbox" checked={enabledModels.has(m.name)}
|
||||
<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(mn)}
|
||||
disabled={!hasData}
|
||||
onChange={() => onEnabledModelsChange(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;
|
||||
})}
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={m.name} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}⛴{gc}◆</span>
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
|
||||
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}⛴${gc}◆` : '—'}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역: 모델 카드들 */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 6, alignItems: 'flex-end',
|
||||
overflowX: 'auto', overflowY: 'visible', flex: 1, minWidth: 0,
|
||||
}}>
|
||||
|
||||
{/* 이름 기반 카드 (체크 시) */}
|
||||
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
|
||||
<div style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)' }}>
|
||||
{renderModelTip('identity', '#f97316')}
|
||||
<div style={cardScrollStyle}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
|
||||
onMouseEnter={() => handleTipHover('identity')}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onClick={() => handleTipClick('identity')}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316' }}>이름 기반</span>
|
||||
</div>
|
||||
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
|
||||
<div style={getCardBodyStyle('identity')}>
|
||||
{identityVessels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}>연관 선박 ({identityVessels.length})</div>
|
||||
@ -291,13 +336,21 @@ const CorrelationPanel = ({
|
||||
{identityGear.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({identityGear.length})</div>
|
||||
{identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))}
|
||||
{identityGear.length > 12 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{identityGear.length - 12}개 더</div>
|
||||
)}
|
||||
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={cardFooterStyle}
|
||||
onClick={() => toggleCardExpand('identity')}
|
||||
onMouseEnter={() => handleTipHover('identity')}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onContextMenu={(e) => handleTipContextMenu(e, 'identity')}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316', flex: 1 }}>이름 기반</span>
|
||||
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has('identity') ? '▾' : '▴'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -309,40 +362,37 @@ const CorrelationPanel = ({
|
||||
const gears = items.filter(c => c.targetType !== 'VESSEL');
|
||||
if (vessels.length === 0 && gears.length === 0) return null;
|
||||
return (
|
||||
<div key={m.name} style={{ ...cardStyle, borderColor: `${color}40` }}>
|
||||
{renderModelTip(m.name, color)}
|
||||
<div style={cardScrollStyle}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
|
||||
onMouseEnter={() => handleTipHover(m.name)}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onClick={() => handleTipClick(m.name)}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
</div>
|
||||
<div key={m.name} ref={(el) => setCardRef(m.name, el)} style={{ ...cardStyle, borderColor: `${color}40`, position: 'relative' }}>
|
||||
<div style={getCardBodyStyle(m.name)}>
|
||||
{vessels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}>연관 선박 ({vessels.length})</div>
|
||||
{vessels.slice(0, 10).map(c => renderRow(c, color, m.name))}
|
||||
{vessels.length > 10 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{vessels.length - 10}건 더</div>
|
||||
)}
|
||||
{vessels.map(c => renderRow(c, color, m.name))}
|
||||
</>
|
||||
)}
|
||||
{gears.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({gears.length})</div>
|
||||
{gears.slice(0, 10).map(c => renderRow(c, color, m.name))}
|
||||
{gears.length > 10 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{gears.length - 10}건 더</div>
|
||||
)}
|
||||
{gears.map(c => renderRow(c, color, m.name))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={cardFooterStyle}
|
||||
onClick={() => toggleCardExpand(m.name)}
|
||||
onMouseEnter={() => handleTipHover(m.name)}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onContextMenu={(e) => handleTipContextMenu(e, m.name)}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color, flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
<span style={{ fontSize: 8, color: '#64748b' }}>{expandedCards.has(m.name) ? '▾' : '▴'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>{/* 스크롤 영역 끝 */}
|
||||
{renderFloatingTip() && createPortal(renderFloatingTip(), document.body)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,14 +1,114 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useMap } from 'react-map-gl/maplibre';
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { Layer as DeckLayer } from '@deck.gl/core';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis';
|
||||
import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useFleetClusterDeckLayers } from '../../hooks/useFleetClusterDeckLayers';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import type { PickedPolygonFeature } from '../../hooks/useFleetClusterDeckLayers';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
|
||||
/** 서브클러스터 center 시계열 (독립 추적용) */
|
||||
export interface SubClusterCenter {
|
||||
subClusterId: number;
|
||||
path: [number, number][]; // [lon, lat] 시간순
|
||||
timestamps: number[]; // epoch ms
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리를 서브클러스터별로 분리하여:
|
||||
* 1. frames: 시간별 멤버 합산 프레임 (리플레이 애니메이션용)
|
||||
* 2. subClusterCenters: 서브클러스터별 독립 center 궤적 (PathLayer용)
|
||||
*/
|
||||
function splitAndMergeHistory(history: GroupPolygonDto[]) {
|
||||
// 시간순 정렬 (오래된 것 먼저)
|
||||
const sorted = [...history].sort((a, b) =>
|
||||
new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(),
|
||||
);
|
||||
|
||||
// 1. 서브클러스터별 center 궤적 수집
|
||||
const centerMap = new Map<number, { path: [number, number][]; timestamps: number[] }>();
|
||||
for (const h of sorted) {
|
||||
const sid = h.subClusterId ?? 0;
|
||||
const entry = centerMap.get(sid) ?? { path: [], timestamps: [] };
|
||||
entry.path.push([h.centerLon, h.centerLat]);
|
||||
entry.timestamps.push(new Date(h.snapshotTime).getTime());
|
||||
centerMap.set(sid, entry);
|
||||
}
|
||||
const subClusterCenters: SubClusterCenter[] = [...centerMap.entries()].map(
|
||||
([subClusterId, data]) => ({ subClusterId, ...data }),
|
||||
);
|
||||
|
||||
// 2. 시간별 그룹핑 후 서브클러스터 보존
|
||||
const byTime = new Map<string, GroupPolygonDto[]>();
|
||||
for (const h of sorted) {
|
||||
const list = byTime.get(h.snapshotTime) ?? [];
|
||||
list.push(h);
|
||||
byTime.set(h.snapshotTime, list);
|
||||
}
|
||||
|
||||
const frames: GroupPolygonDto[] = [];
|
||||
for (const [, items] of byTime) {
|
||||
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 { fillGapFrames } from './fleetClusterUtils';
|
||||
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
|
||||
@ -29,10 +129,13 @@ interface Props {
|
||||
onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void;
|
||||
onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void;
|
||||
groupPolygons?: UseGroupPolygonsResult;
|
||||
zoomScale?: number;
|
||||
onDeckLayersChange?: (layers: DeckLayer[]) => void;
|
||||
}
|
||||
|
||||
export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) {
|
||||
export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons, zoomScale = 1, onDeckLayersChange }: Props) {
|
||||
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
|
||||
const { fontScale } = useFontScale();
|
||||
|
||||
// ── 선단/어구 패널 상태 ──
|
||||
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
|
||||
@ -40,6 +143,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
const [activeSection, setActiveSection] = useState<string | null>('fleet');
|
||||
const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
|
||||
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
|
||||
const [hoveredGearName, setHoveredGearName] = useState<string | null>(null);
|
||||
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
|
||||
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
|
||||
|
||||
@ -60,9 +164,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
|
||||
|
||||
// ── 맵 + ref ──
|
||||
const { current: mapRef } = useMap();
|
||||
const registeredRef = useRef(false);
|
||||
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
|
||||
|
||||
// ── 초기 로드 ──
|
||||
useEffect(() => {
|
||||
@ -78,9 +179,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
|
||||
]);
|
||||
|
||||
// 2. 데이터 전처리
|
||||
const sorted = history.reverse();
|
||||
const filled = fillGapFrames(sorted);
|
||||
// 2. resolution별 분리 → 1h(primary) + 6h(secondary)
|
||||
const history1h = history.filter(h => h.resolution === '1h');
|
||||
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 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;
|
||||
console.log('[loadHistory] fetch 완료:', {
|
||||
history: history.length,
|
||||
'1h': history1h.length,
|
||||
'6h': history6h.length,
|
||||
'filled1h': filled.length,
|
||||
'filled6h': filled6h.length,
|
||||
corrData: corrData.length,
|
||||
corrTracks: corrTracks.length,
|
||||
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));
|
||||
@ -102,15 +212,39 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
setEnabledVessels(vessels);
|
||||
setCorrelationLoading(false);
|
||||
|
||||
// 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작
|
||||
// 4. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작
|
||||
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();
|
||||
};
|
||||
|
||||
const closeHistory = useCallback(() => {
|
||||
useGearReplayStore.getState().reset();
|
||||
setSelectedGearGroup(null);
|
||||
setCorrelationData([]);
|
||||
setCorrelationTracks([]);
|
||||
setEnabledVessels(new Set());
|
||||
}, []);
|
||||
|
||||
// ── cnFishing 탭 off (unmount) 시 재생 상태 + deck layers 전체 초기화 ──
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useGearReplayStore.getState().reset();
|
||||
onDeckLayersChange?.([]);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ──
|
||||
@ -140,141 +274,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [historyActive, closeHistory]);
|
||||
|
||||
// ── 맵 이벤트 등록 ──
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map || registeredRef.current) return;
|
||||
|
||||
const fleetLayers = ['fleet-cluster-fill-layer'];
|
||||
const gearLayers = ['gear-cluster-fill-layer'];
|
||||
const allLayers = [...fleetLayers, ...gearLayers];
|
||||
const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; };
|
||||
|
||||
const onFleetEnter = (e: MapLayerMouseEvent) => {
|
||||
setCursor('pointer');
|
||||
const feat = e.features?.[0];
|
||||
if (!feat) return;
|
||||
const cid = feat.properties?.clusterId as number | undefined;
|
||||
if (cid != null) {
|
||||
setHoveredFleetId(cid);
|
||||
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid });
|
||||
}
|
||||
};
|
||||
const onFleetLeave = () => {
|
||||
setCursor('');
|
||||
setHoveredFleetId(null);
|
||||
setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev);
|
||||
};
|
||||
|
||||
const handleFleetSelect = (cid: number) => {
|
||||
const d = dataRef.current;
|
||||
setExpandedFleet(prev => prev === cid ? null : cid);
|
||||
setActiveSection('fleet');
|
||||
const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
|
||||
if (!group || group.members.length === 0) return;
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||
for (const m of group.members) {
|
||||
if (m.lat < minLat) minLat = m.lat;
|
||||
if (m.lat > maxLat) maxLat = m.lat;
|
||||
if (m.lon < minLng) minLng = m.lon;
|
||||
if (m.lon > maxLng) maxLng = m.lon;
|
||||
}
|
||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||
loadHistory(String(cid));
|
||||
};
|
||||
|
||||
const handleGearGroupZoomFromMap = (name: string) => {
|
||||
const d = dataRef.current;
|
||||
setSelectedGearGroup(prev => prev === name ? null : name);
|
||||
setExpandedGearGroup(name);
|
||||
const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name);
|
||||
setActiveSection(isInZone ? 'inZone' : 'outZone');
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 50);
|
||||
});
|
||||
const allGroups = d.groupPolygons ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] : [];
|
||||
const group = allGroups.find(g => g.groupKey === name);
|
||||
if (!group || group.members.length === 0) return;
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||
for (const m of group.members) {
|
||||
if (m.lat < minLat) minLat = m.lat;
|
||||
if (m.lat > maxLat) maxLat = m.lat;
|
||||
if (m.lon < minLng) minLng = m.lon;
|
||||
if (m.lon > maxLng) maxLng = m.lon;
|
||||
}
|
||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||
loadHistory(name);
|
||||
};
|
||||
|
||||
const onPolygonClick = (e: MapLayerMouseEvent) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: allLayers });
|
||||
if (features.length === 0) return;
|
||||
const seen = new Set<string>();
|
||||
const candidates: PickerCandidate[] = [];
|
||||
for (const f of features) {
|
||||
const cid = f.properties?.clusterId as number | undefined;
|
||||
const gearName = f.properties?.name as string | undefined;
|
||||
if (cid != null) {
|
||||
const key = `fleet-${cid}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const d = dataRef.current;
|
||||
const g = d.groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === cid);
|
||||
candidates.push({ name: g?.groupLabel ?? `선단 #${cid}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: cid });
|
||||
} else if (gearName) {
|
||||
if (seen.has(gearName)) continue;
|
||||
seen.add(gearName);
|
||||
candidates.push({ name: gearName, count: f.properties?.gearCount ?? 0, inZone: f.properties?.inZone === 1, isFleet: false });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
const c = candidates[0];
|
||||
if (c.isFleet && c.clusterId != null) handleFleetSelect(c.clusterId);
|
||||
else handleGearGroupZoomFromMap(c.name);
|
||||
} else if (candidates.length > 1) {
|
||||
setGearPickerPopup({ lng: e.lngLat.lng, lat: e.lngLat.lat, candidates });
|
||||
}
|
||||
};
|
||||
|
||||
const onGearEnter = (e: MapLayerMouseEvent) => {
|
||||
setCursor('pointer');
|
||||
const feat = e.features?.[0];
|
||||
if (!feat) return;
|
||||
const name = feat.properties?.name as string | undefined;
|
||||
if (name) setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name });
|
||||
};
|
||||
const onGearLeave = () => {
|
||||
setCursor('');
|
||||
setHoverTooltip(prev => prev?.type === 'gear' ? null : prev);
|
||||
};
|
||||
|
||||
const register = () => {
|
||||
const ready = allLayers.every(id => map.getLayer(id));
|
||||
if (!ready) return;
|
||||
registeredRef.current = true;
|
||||
for (const id of fleetLayers) {
|
||||
map.on('mouseenter', id, onFleetEnter);
|
||||
map.on('mouseleave', id, onFleetLeave);
|
||||
map.on('click', id, onPolygonClick);
|
||||
}
|
||||
for (const id of gearLayers) {
|
||||
map.on('mouseenter', id, onGearEnter);
|
||||
map.on('mouseleave', id, onGearLeave);
|
||||
map.on('click', id, onPolygonClick);
|
||||
}
|
||||
};
|
||||
|
||||
register();
|
||||
if (!registeredRef.current) {
|
||||
const interval = setInterval(() => {
|
||||
register();
|
||||
if (registeredRef.current) clearInterval(interval);
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [mapRef]);
|
||||
// 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover)
|
||||
|
||||
// ── ships map ──
|
||||
const shipMap = useMemo(() => {
|
||||
@ -283,7 +283,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
return m;
|
||||
}, [ships]);
|
||||
|
||||
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
|
||||
|
||||
|
||||
// ── 부모 콜백 동기화: 어구 그룹 선택 ──
|
||||
useEffect(() => {
|
||||
@ -293,11 +293,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
return;
|
||||
}
|
||||
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group) { onSelectedGearChange?.(null); return; }
|
||||
const parent = group.members.find(m => m.isParent);
|
||||
const gears = group.members.filter(m => !m.isParent);
|
||||
const toShip = (m: typeof group.members[0]): Ship => ({
|
||||
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup);
|
||||
if (matches.length === 0) { onSelectedGearChange?.(null); return; }
|
||||
// 서브클러스터 멤버 합산
|
||||
const seen = new Set<string>();
|
||||
const allMembers: typeof matches[0]['members'] = [];
|
||||
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } }
|
||||
const parent = allMembers.find(m => m.isParent);
|
||||
const gears = allMembers.filter(m => !m.isParent);
|
||||
const toShip = (m: typeof allMembers[0]): Ship => ({
|
||||
mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
|
||||
heading: m.cog, speed: m.sog, course: m.cog,
|
||||
category: 'fishing', lastSeen: Date.now(),
|
||||
@ -360,6 +364,97 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
enabledModels, enabledVessels, hoveredMmsi,
|
||||
});
|
||||
|
||||
// ── deck.gl 이벤트 콜백 ──
|
||||
const handleDeckPolygonClick = useCallback((features: PickedPolygonFeature[], coordinate: [number, number]) => {
|
||||
if (features.length === 0) return;
|
||||
if (features.length === 1) {
|
||||
const c = features[0];
|
||||
if (c.type === 'fleet' && c.clusterId != null) {
|
||||
setExpandedFleet(prev => prev === c.clusterId ? null : c.clusterId!);
|
||||
setActiveSection('fleet');
|
||||
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === c.clusterId);
|
||||
if (group && group.members.length > 0) {
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||
for (const m of group.members) {
|
||||
if (m.lat < minLat) minLat = m.lat;
|
||||
if (m.lat > maxLat) maxLat = m.lat;
|
||||
if (m.lon < minLng) minLng = m.lon;
|
||||
if (m.lon > maxLng) maxLng = m.lon;
|
||||
}
|
||||
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||
}
|
||||
loadHistory(String(c.clusterId));
|
||||
} else if (c.type === 'gear' && c.name) {
|
||||
setSelectedGearGroup(prev => prev === c.name ? null : c.name!);
|
||||
setExpandedGearGroup(c.name);
|
||||
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === c.name);
|
||||
setActiveSection(isInZone ? 'inZone' : 'outZone');
|
||||
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
|
||||
const group = allGroups.find(g => g.groupKey === c.name);
|
||||
if (group && group.members.length > 0) {
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||
for (const m of group.members) {
|
||||
if (m.lat < minLat) minLat = m.lat;
|
||||
if (m.lat > maxLat) maxLat = m.lat;
|
||||
if (m.lon < minLng) minLng = m.lon;
|
||||
if (m.lon > maxLng) maxLng = m.lon;
|
||||
}
|
||||
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||
}
|
||||
loadHistory(c.name);
|
||||
}
|
||||
} else {
|
||||
// 겹친 폴리곤 → 피커 팝업
|
||||
const candidates: PickerCandidate[] = features.map(f => {
|
||||
if (f.type === 'fleet') {
|
||||
const g = groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === f.clusterId);
|
||||
return { name: g?.groupLabel ?? `선단 #${f.clusterId}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: f.clusterId };
|
||||
}
|
||||
return { name: f.name ?? '', count: f.gearCount ?? 0, inZone: !!f.inZone, isFleet: false };
|
||||
});
|
||||
setGearPickerPopup({ lng: coordinate[0], lat: coordinate[1], candidates });
|
||||
}
|
||||
}, [groupPolygons, onFleetZoom, loadHistory]);
|
||||
|
||||
const handleDeckPolygonHover = useCallback((info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => {
|
||||
if (info) {
|
||||
if (info.type === 'fleet') {
|
||||
setHoveredFleetId(info.id as number);
|
||||
setHoveredGearName(null);
|
||||
} else {
|
||||
setHoveredFleetId(null);
|
||||
setHoveredGearName(info.id as string);
|
||||
}
|
||||
setHoverTooltip({ lng: info.lng, lat: info.lat, type: info.type, id: info.id });
|
||||
} else {
|
||||
setHoveredFleetId(null);
|
||||
setHoveredGearName(null);
|
||||
setHoverTooltip(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── deck.gl 레이어 빌드 ──
|
||||
const focusMode = useGearReplayStore(s => s.focusMode);
|
||||
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||
const fleetDeckLayers = useFleetClusterDeckLayers(geo, {
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
hoveredGearGroup: hoveredGearName,
|
||||
enabledModels,
|
||||
historyActive,
|
||||
hasCorrelationTracks: correlationTracks.length > 0,
|
||||
zoomScale,
|
||||
zoomLevel,
|
||||
fontScale: fontScale.analysis,
|
||||
focusMode,
|
||||
onPolygonClick: handleDeckPolygonClick,
|
||||
onPolygonHover: handleDeckPolygonHover,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onDeckLayersChange?.(fleetDeckLayers);
|
||||
}, [fleetDeckLayers, onDeckLayersChange]);
|
||||
|
||||
// ── 어구 그룹 데이터 ──
|
||||
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
|
||||
const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];
|
||||
@ -421,26 +516,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
const selectedGroupMemberCount = useMemo(() => {
|
||||
if (!selectedGearGroup || !groupPolygons) return 0;
|
||||
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
return allGroups.find(g => g.groupKey === selectedGearGroup)?.memberCount ?? 0;
|
||||
return allGroups.filter(g => g.groupKey === selectedGearGroup).reduce((sum, g) => sum + g.memberCount, 0);
|
||||
}, [selectedGearGroup, groupPolygons]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── 맵 레이어 ── */}
|
||||
<FleetClusterMapLayers
|
||||
geo={geo}
|
||||
selectedGearGroup={selectedGearGroup}
|
||||
hoveredMmsi={hoveredMmsi}
|
||||
enabledModels={enabledModels}
|
||||
expandedFleet={expandedFleet}
|
||||
historyActive={historyActive}
|
||||
hoverTooltip={hoverTooltip}
|
||||
gearPickerPopup={gearPickerPopup}
|
||||
pickerHoveredGroup={pickerHoveredGroup}
|
||||
groupPolygons={groupPolygons}
|
||||
companies={companies}
|
||||
analysisMap={analysisMap}
|
||||
hasCorrelationTracks={correlationTracks.length > 0}
|
||||
onPickerHover={setPickerHoveredGroup}
|
||||
onPickerSelect={handlePickerSelect}
|
||||
onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }}
|
||||
@ -470,8 +560,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
onClose={closeHistory}
|
||||
onFilterByScore={(minPct) => {
|
||||
// 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관)
|
||||
// null(전체) = 30% 이상 전부 ON (API minScore=0.3 기준)
|
||||
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 {
|
||||
const threshold = minPct / 100;
|
||||
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 type { FleetCompany } from '../../services/vesselAnalysis';
|
||||
import type { VesselAnalysisDto } from '../../types';
|
||||
@ -8,16 +8,10 @@ import type {
|
||||
GearPickerPopupState,
|
||||
PickerCandidate,
|
||||
} from './fleetClusterTypes';
|
||||
import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
|
||||
|
||||
interface FleetClusterMapLayersProps {
|
||||
geo: FleetClusterGeoJsonResult;
|
||||
selectedGearGroup: string | null;
|
||||
hoveredMmsi: string | null;
|
||||
enabledModels: Set<string>;
|
||||
expandedFleet: number | null;
|
||||
historyActive: boolean;
|
||||
// Popup/tooltip state
|
||||
hoverTooltip: HoverTooltipState | null;
|
||||
gearPickerPopup: GearPickerPopupState | null;
|
||||
@ -26,199 +20,33 @@ interface FleetClusterMapLayersProps {
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
companies: Map<number, FleetCompany>;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
// Whether any correlation trails exist (drives conditional render)
|
||||
hasCorrelationTracks: boolean;
|
||||
// Callbacks
|
||||
onPickerHover: (group: string | null) => void;
|
||||
onPickerSelect: (candidate: PickerCandidate) => void;
|
||||
onPickerClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* FleetCluster overlay popups/tooltips.
|
||||
* All MapLibre Source/Layer rendering has been moved to useFleetClusterDeckLayers (deck.gl).
|
||||
* This component only renders MapLibre Popup-based overlays (tooltips, picker).
|
||||
*/
|
||||
const FleetClusterMapLayers = ({
|
||||
geo,
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
enabledModels,
|
||||
expandedFleet,
|
||||
historyActive,
|
||||
hoverTooltip,
|
||||
gearPickerPopup,
|
||||
pickerHoveredGroup,
|
||||
groupPolygons,
|
||||
companies,
|
||||
analysisMap,
|
||||
hasCorrelationTracks,
|
||||
onPickerHover,
|
||||
onPickerSelect,
|
||||
onPickerClose,
|
||||
}: FleetClusterMapLayersProps) => {
|
||||
const {
|
||||
fleetPolygonGeoJSON,
|
||||
lineGeoJSON,
|
||||
hoveredGeoJSON,
|
||||
gearClusterGeoJson,
|
||||
memberMarkersGeoJson,
|
||||
pickerHighlightGeoJson,
|
||||
operationalPolygons,
|
||||
correlationVesselGeoJson,
|
||||
correlationTrailGeoJson,
|
||||
modelBadgesGeoJson,
|
||||
hoverHighlightGeoJson,
|
||||
hoverHighlightTrailGeoJson,
|
||||
} = geo;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 선단 폴리곤 레이어 */}
|
||||
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-fill-layer"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['get', 'color'],
|
||||
'fill-opacity': 0.1,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="fleet-cluster-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': 0.5,
|
||||
'line-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
|
||||
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-line-only"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': 0.5,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 호버 하이라이트 (별도 Source) */}
|
||||
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-hovered-fill"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['get', 'color'],
|
||||
'fill-opacity': 0.25,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
|
||||
{selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => {
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group?.polygon) return null;
|
||||
const hlGeoJson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
};
|
||||
return (
|
||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
||||
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
|
||||
</Source>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
|
||||
{selectedGearGroup && !historyActive && operationalPolygons.map(op => (
|
||||
<Source key={`op-${op.modelName}`} id={`gear-op-${op.modelName}`} type="geojson" data={op.geojson}>
|
||||
<Layer id={`gear-op-fill-${op.modelName}`} type="fill" paint={{
|
||||
'fill-color': op.color, 'fill-opacity': 0.12,
|
||||
}} />
|
||||
<Layer id={`gear-op-line-${op.modelName}`} type="line" paint={{
|
||||
'line-color': op.color, 'line-width': 2.5, 'line-opacity': 0.8,
|
||||
'line-dasharray': [6, 3],
|
||||
}} />
|
||||
</Source>
|
||||
))}
|
||||
|
||||
{/* 비허가 어구 클러스터 폴리곤 */}
|
||||
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
|
||||
<Layer
|
||||
id="gear-cluster-fill-layer"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['case', ['==', ['get', 'inZone'], 1], 'rgba(220, 38, 38, 0.12)', 'rgba(249, 115, 22, 0.08)'],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="gear-cluster-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
|
||||
'line-opacity': 0.7,
|
||||
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */}
|
||||
<Source id="group-member-markers" type="geojson" data={historyActive ? ({ type: 'FeatureCollection', features: [] } as GeoJSON.FeatureCollection) : memberMarkersGeoJson}>
|
||||
<Layer
|
||||
id="group-member-icon"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'baseSize'], 0.9],
|
||||
6, ['*', ['get', 'baseSize'], 1.2],
|
||||
8, ['*', ['get', 'baseSize'], 1.8],
|
||||
10, ['*', ['get', 'baseSize'], 2.6],
|
||||
12, ['*', ['get', 'baseSize'], 3.2],
|
||||
13, ['*', ['get', 'baseSize'], 4.0],
|
||||
14, ['*', ['get', 'baseSize'], 4.8],
|
||||
],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 8, 8, 12, 10],
|
||||
'text-offset': [0, 1.4],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
}}
|
||||
paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 0.5,
|
||||
'text-color': ['get', 'color'],
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 어구 picker 호버 하이라이트 */}
|
||||
<Source id="gear-picker-highlight" type="geojson" data={pickerHighlightGeoJson}>
|
||||
<Layer id="gear-picker-highlight-fill" type="fill"
|
||||
paint={{ 'fill-color': '#ffffff', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-picker-highlight-line" type="line"
|
||||
paint={{ 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [3, 2] }} />
|
||||
</Source>
|
||||
|
||||
{/* 어구 다중 선택 팝업 */}
|
||||
{gearPickerPopup && (
|
||||
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
|
||||
@ -242,7 +70,7 @@ const FleetClusterMapLayers = ({
|
||||
marginBottom: 2, borderRadius: 2,
|
||||
backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '⚓ ' : ''}{c.name}</span>
|
||||
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '\u2693 ' : ''}{c.name}</span>
|
||||
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
|
||||
</div>
|
||||
))}
|
||||
@ -272,7 +100,7 @@ const FleetClusterMapLayers = ({
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
return (
|
||||
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||
{role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
|
||||
{role === 'LEADER' ? '\u2605' : '\u00B7'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -286,10 +114,13 @@ const FleetClusterMapLayers = ({
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === name);
|
||||
if (!group) return null;
|
||||
const parentMember = group.members.find(m => m.isParent);
|
||||
const gearMembers = group.members.filter(m => !m.isParent);
|
||||
const matches = allGroups.filter(g => g.groupKey === name);
|
||||
if (matches.length === 0) return null;
|
||||
const seen = new Set<string>();
|
||||
const mergedMembers: typeof matches[0]['members'] = [];
|
||||
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); mergedMembers.push(m); } }
|
||||
const parentMember = mergedMembers.find(m => m.isParent);
|
||||
const gearMembers = mergedMembers.filter(m => !m.isParent);
|
||||
return (
|
||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||
@ -314,82 +145,6 @@ const FleetClusterMapLayers = ({
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* ── 연관 대상 트레일 + 마커 (비재생 모드) ── */}
|
||||
{selectedGearGroup && !historyActive && hasCorrelationTracks && (
|
||||
<Source id="correlation-trails" type="geojson" data={correlationTrailGeoJson}>
|
||||
<Layer id="correlation-trails-line" type="line" paint={{
|
||||
'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.6,
|
||||
'line-dasharray': [6, 3],
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{selectedGearGroup && !historyActive && (
|
||||
<Source id="correlation-vessels" type="geojson" data={correlationVesselGeoJson}>
|
||||
<Layer id="correlation-vessels-icon" type="symbol" layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isVessel'], 1], 'ship-triangle', 'gear-diamond'],
|
||||
'icon-size': ['case', ['==', ['get', 'isVessel'], 1], 0.7, 0.5],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isVessel'], 1], ['get', 'cog'], 0],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
}} paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 1,
|
||||
}} />
|
||||
<Layer id="correlation-vessels-label" type="symbol" layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 8,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-allow-overlap': false,
|
||||
}} paint={{
|
||||
'text-color': ['get', 'color'],
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* ── 모델 배지 (비재생 모드) ── */}
|
||||
{selectedGearGroup && !historyActive && (
|
||||
<Source id="model-badges" type="geojson" data={modelBadgesGeoJson}>
|
||||
{MODEL_ORDER.map((model, i) => (
|
||||
enabledModels.has(model) ? (
|
||||
<Layer key={`badge-${model}`} id={`model-badge-${model}`} type="circle"
|
||||
filter={['==', ['get', `m${i}`], 1]}
|
||||
paint={{
|
||||
'circle-radius': 3,
|
||||
'circle-color': MODEL_COLORS[model] ?? '#94a3b8',
|
||||
'circle-stroke-width': 0.5,
|
||||
'circle-stroke-color': 'rgba(0,0,0,0.6)',
|
||||
'circle-translate': [10 + i * 7, -6],
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
))}
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* ── 호버 하이라이트 (비재생 모드) ── */}
|
||||
{hoveredMmsi && !historyActive && (
|
||||
<Source id="hover-highlight-point" type="geojson" data={hoverHighlightGeoJson}>
|
||||
<Layer id="hover-highlight-glow" type="circle" paint={{
|
||||
'circle-radius': 14, 'circle-color': '#ffffff', 'circle-opacity': 0.25,
|
||||
'circle-blur': 0.8,
|
||||
}} />
|
||||
<Layer id="hover-highlight-ring" type="circle" paint={{
|
||||
'circle-radius': 8, 'circle-color': 'transparent',
|
||||
'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff',
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{hoveredMmsi && !historyActive && (
|
||||
<Source id="hover-highlight-trail" type="geojson" data={hoverHighlightTrailGeoJson}>
|
||||
<Layer id="hover-highlight-trail-line" type="line" paint={{
|
||||
'line-color': '#ffffff', 'line-width': 3, 'line-opacity': 0.7,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,31 +1,114 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { MODEL_COLORS } from './fleetClusterConstants';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||
|
||||
interface HistoryReplayControllerProps {
|
||||
onClose: () => 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 isPlaying = useGearReplayStore(s => s.isPlaying);
|
||||
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 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 timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const store = useGearReplayStore;
|
||||
|
||||
// currentTime → 진행 인디케이터
|
||||
useEffect(() => {
|
||||
const unsub = useGearReplayStore.subscribe(
|
||||
const unsub = store.subscribe(
|
||||
s => s.currentTime,
|
||||
(currentTime) => {
|
||||
const { startTime, endTime } = useGearReplayStore.getState();
|
||||
const { startTime, endTime } = store.getState();
|
||||
if (endTime <= startTime) return;
|
||||
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 (timeDisplayRef.current) {
|
||||
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;
|
||||
}, [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 = {
|
||||
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,
|
||||
@ -46,80 +261,238 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||
position: 'absolute', bottom: 20,
|
||||
left: 'calc(50% + 100px)', transform: 'translateX(-50%)',
|
||||
width: 'calc(100vw - 880px)',
|
||||
minWidth: 380, maxWidth: 1320,
|
||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
||||
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420,
|
||||
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
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' }}>
|
||||
{snapshotRanges.map((pos, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
||||
background: 'rgba(251,191,36,0.4)',
|
||||
}} />
|
||||
))}
|
||||
<div ref={progressIndicatorRef} style={{
|
||||
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
||||
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
||||
}} />
|
||||
</div>
|
||||
{/* 프로그레스 트랙 */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
style={{ position: 'relative', height: 18, cursor: 'pointer' }}
|
||||
onClick={handleTrackClick}
|
||||
onMouseMove={handleTrackHover}
|
||||
onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
|
||||
|
||||
{/* 컨트롤 행 1: 재생 + 타임라인 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<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: 40, textAlign: 'center' }}>--:--</span>
|
||||
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
|
||||
onChange={e => {
|
||||
const { startTime, endTime } = store.getState();
|
||||
const progress = Number(e.target.value) / 1000;
|
||||
store.getState().pause();
|
||||
store.getState().seek(startTime + progress * (endTime - startTime));
|
||||
}}
|
||||
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}건</span>
|
||||
<button type="button" onClick={onClose}
|
||||
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 }}>
|
||||
{/* A-B 구간 */}
|
||||
{abLoop && abAPos >= 0 && abBPos >= 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', left: `${abAPos * 100}%`, top: 5,
|
||||
width: `${(abBPos - abAPos) * 100}%`, height: 8,
|
||||
background: 'rgba(34,197,94,0.12)', borderRadius: 4, pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
|
||||
{snapshotRanges6h.map((pos, i) => (
|
||||
<div key={`6h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 9, width: 2, height: 4, background: 'rgba(147,197,253,0.4)' }} />
|
||||
))}
|
||||
{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)' }} />
|
||||
))}
|
||||
|
||||
{/* A-B 마커 */}
|
||||
{abLoop && abAPos >= 0 && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 행 2: 표시 옵션 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
|
||||
<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>
|
||||
<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));
|
||||
}}
|
||||
{/* 멤버 목록 (호버 → 지도 강조) */}
|
||||
{pinnedMembers.map(m => (
|
||||
<div
|
||||
key={m.mmsi}
|
||||
onMouseEnter={() => store.getState().setHoveredMmsi(m.mmsi)}
|
||||
onMouseLeave={() => store.getState().setHoveredMmsi(null)}
|
||||
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',
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 3px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)', 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="60">60%+</option>
|
||||
<option value="70">70%+</option>
|
||||
<option value="80">80%+</option>
|
||||
<option value="90">90%+</option>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -13,7 +13,10 @@ import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
|
||||
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
|
||||
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
|
||||
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
|
||||
import { ShipLayer } from '../layers/ShipLayer';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
@ -215,6 +218,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||
const replayLayerRef = useRef<DeckLayer[]>([]);
|
||||
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
|
||||
fleetClusterLayerRef.current = layers;
|
||||
requestRenderRef.current?.();
|
||||
}, []);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
||||
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||
@ -229,15 +238,18 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
if (z !== zoomRef.current) {
|
||||
zoomRef.current = z;
|
||||
setZoomLevel(z);
|
||||
useShipDeckStore.getState().setZoomLevel(z);
|
||||
}
|
||||
}, []);
|
||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
|
||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
|
||||
const replayFocusMode = useGearReplayStore(s => s.focusMode);
|
||||
|
||||
// ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
|
||||
// ── deck.gl 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
|
||||
const reactLayersRef = useRef<DeckLayer[]>([]);
|
||||
const shipLayerRef = useRef<DeckLayer[]>([]);
|
||||
type ShipPos = { lng: number; lat: number; course?: number };
|
||||
const shipsRef = useRef(new globalThis.Map<string, ShipPos>());
|
||||
// live 선박 위치를 ref에 동기화 (리플레이 fallback용)
|
||||
@ -248,16 +260,41 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
|
||||
const requestRender = useCallback(() => {
|
||||
if (!overlayRef.current) return;
|
||||
const focus = useGearReplayStore.getState().focusMode;
|
||||
overlayRef.current.setProps({
|
||||
layers: [...reactLayersRef.current, ...replayLayerRef.current],
|
||||
layers: focus
|
||||
? [...replayLayerRef.current]
|
||||
: [...reactLayersRef.current, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current],
|
||||
});
|
||||
}, []);
|
||||
requestRenderRef.current = requestRender;
|
||||
useShipDeckLayers(shipLayerRef, requestRender);
|
||||
useGearReplayLayers(replayLayerRef, requestRender, shipsRef);
|
||||
|
||||
useEffect(() => {
|
||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → 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(() => {
|
||||
if (flyToTarget && mapRef.current) {
|
||||
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 }) => {
|
||||
mapRef.current?.fitBounds(
|
||||
[[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
|
||||
{ padding: 60, duration: 1500, maxZoom: 12 },
|
||||
{ padding: 60, duration: 1500, maxZoom: 10 },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing;
|
||||
|
||||
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
|
||||
const zoomScale = useMemo(() => {
|
||||
if (zoomLevel <= 4) return 0.8;
|
||||
@ -429,7 +464,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
|
||||
// 선택된 어구 그룹 — 어구 아이콘 + 모선 강조 (deck.gl)
|
||||
const selectedGearLayers = useMemo(() => {
|
||||
if (!selectedGearData) return [];
|
||||
if (!selectedGearData || replayFocusMode) return [];
|
||||
const { parent, gears, groupName } = selectedGearData;
|
||||
const layers = [];
|
||||
|
||||
@ -507,11 +542,11 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return layers;
|
||||
}, [selectedGearData, zoomScale, fontScale.analysis]);
|
||||
}, [selectedGearData, zoomScale, fontScale.analysis, replayFocusMode]);
|
||||
|
||||
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
|
||||
const selectedFleetLayers = useMemo(() => {
|
||||
if (!selectedFleetData) return [];
|
||||
if (!selectedFleetData || replayFocusMode) return [];
|
||||
const { ships: fleetShips, clusterId } = selectedFleetData;
|
||||
if (fleetShips.length === 0) return [];
|
||||
|
||||
@ -526,7 +561,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
const color: [number, number, number, number] = [r, g, b, 255];
|
||||
const fillColor: [number, number, number, number] = [r, g, b, 80];
|
||||
|
||||
const result: Layer[] = [];
|
||||
const result: DeckLayer[] = [];
|
||||
|
||||
// 소속 선박 — 강조 원형
|
||||
result.push(new ScatterplotLayer({
|
||||
@ -593,7 +628,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis]);
|
||||
}, [selectedFleetData, zoomScale, vesselAnalysis, fontScale.analysis, replayFocusMode]);
|
||||
|
||||
// 분석 결과 deck.gl 레이어
|
||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||
@ -601,28 +636,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
: koreaFilters.cnFishing ? 'cnFishing'
|
||||
: null;
|
||||
|
||||
// AI 분석 가상 선박 마커 GeoJSON (분석 대상 선박을 삼각형으로 표시)
|
||||
const analysisShipMarkersGeoJson = useMemo(() => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!vesselAnalysis || !analysisActiveFilter) return { type: 'FeatureCollection' as const, features };
|
||||
const allS = allShips ?? ships;
|
||||
for (const s of allS) {
|
||||
const dto = vesselAnalysis.analysisMap.get(s.mmsi);
|
||||
if (!dto) continue;
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
const color = level === 'CRITICAL' ? '#ef4444' : level === 'HIGH' ? '#f97316' : level === 'MEDIUM' ? '#eab308' : '#22c55e';
|
||||
const isGear = /^.+?_\d+_\d+_?$/.test(s.name || '') ? 1 : 0;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { mmsi: s.mmsi, name: s.name || s.mmsi, cog: s.heading ?? 0, color, baseSize: 0.16, isGear },
|
||||
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}, [vesselAnalysis, analysisActiveFilter, allShips, ships]);
|
||||
// shipDeckStore에 분석 상태 동기화
|
||||
useEffect(() => {
|
||||
useShipDeckStore.getState().setAnalysis(
|
||||
vesselAnalysis?.analysisMap ?? null,
|
||||
analysisActiveFilter,
|
||||
);
|
||||
}, [vesselAnalysis?.analysisMap, analysisActiveFilter]);
|
||||
|
||||
const analysisDeckLayers = useAnalysisDeckLayers(
|
||||
vesselAnalysis?.analysisMap ?? new Map(),
|
||||
vesselAnalysis?.analysisMap ?? (new globalThis.Map() as globalThis.Map<string, import('../../types').VesselAnalysisDto>),
|
||||
allShips ?? ships,
|
||||
analysisActiveFilter,
|
||||
zoomScale,
|
||||
@ -635,6 +658,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
onZoom={handleZoom}
|
||||
onLoad={handleMapLoad}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -702,13 +726,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer
|
||||
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
|
||||
militaryOnly={layers.militaryOnly}
|
||||
analysisMap={vesselAnalysis?.analysisMap}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
/>}
|
||||
{/* ShipLayer → deck.gl (useShipDeckLayers) 전환 완료 */}
|
||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||
@ -780,13 +798,15 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
||||
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
|
||||
groupPolygons={groupPolygons}
|
||||
zoomScale={zoomScale}
|
||||
onDeckLayersChange={handleFleetDeckLayers}
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onFleetZoom={handleFleetZoom}
|
||||
onSelectedGearChange={setSelectedGearData}
|
||||
onSelectedFleetChange={setSelectedFleetData}
|
||||
/>
|
||||
)}
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
|
||||
<AnalysisOverlay
|
||||
ships={allShips ?? ships}
|
||||
analysisMap={vesselAnalysis.analysisMap}
|
||||
@ -795,42 +815,17 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI 분석 가상 선박 마커 (삼각형 + 방향 + 줌 스케일) */}
|
||||
{analysisActiveFilter && (
|
||||
<Source id="analysis-ship-markers" type="geojson" data={analysisShipMarkersGeoJson}>
|
||||
<Layer
|
||||
id="analysis-ship-icon"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'baseSize'], 1.0],
|
||||
6, ['*', ['get', 'baseSize'], 1.3],
|
||||
8, ['*', ['get', 'baseSize'], 2.0],
|
||||
10, ['*', ['get', 'baseSize'], 2.8],
|
||||
12, ['*', ['get', 'baseSize'], 3.5],
|
||||
13, ['*', ['get', 'baseSize'], 4.2],
|
||||
14, ['*', ['get', 'baseSize'], 5.0],
|
||||
],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
}}
|
||||
paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 0.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
{/* analysisShipMarkers → deck.gl (useShipDeckLayers) 전환 완료 */}
|
||||
|
||||
{/* 선박 호버 툴팁 + 클릭 팝업 — React 오버레이 (MapLibre Popup 대체) */}
|
||||
<ShipHoverTooltip />
|
||||
<ShipPopupOverlay />
|
||||
|
||||
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
|
||||
<DeckGLOverlay
|
||||
overlayRef={overlayRef}
|
||||
layers={(() => {
|
||||
const base = [
|
||||
const base = replayFocusMode ? [] : [
|
||||
...staticDeckLayers,
|
||||
illegalFishingLayer,
|
||||
illegalFishingLabelLayer,
|
||||
@ -838,9 +833,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
...selectedGearLayers,
|
||||
...selectedFleetLayers,
|
||||
...(analysisPanelOpen ? analysisDeckLayers : []),
|
||||
].filter(Boolean);
|
||||
].filter(Boolean) as DeckLayer[];
|
||||
reactLayersRef.current = base;
|
||||
return [...base, ...replayLayerRef.current];
|
||||
return [...base, ...fleetClusterLayerRef.current, ...shipLayerRef.current, ...replayLayerRef.current];
|
||||
})()}
|
||||
/>
|
||||
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
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) ──
|
||||
export interface SelectedGearGroupData {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { GeoJSON } from 'geojson';
|
||||
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';
|
||||
|
||||
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
||||
@ -129,13 +128,20 @@ export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Po
|
||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||
* - 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;
|
||||
const STEP_SHORT_MS = 300_000;
|
||||
const STEP_LONG_MS = 1_800_000;
|
||||
const THRESHOLD_MS = 1_800_000;
|
||||
const result: GroupPolygonDto[] = [];
|
||||
const result: HistoryFrame[] = [];
|
||||
|
||||
for (let i = 0; i < snapshots.length; 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));
|
||||
if (common.length === 0) continue;
|
||||
|
||||
const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf]));
|
||||
|
||||
if (gap <= THRESHOLD_MS) {
|
||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * 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({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||
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) {
|
||||
const nm = nextMap.get(pm.mmsi)!;
|
||||
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 dLat = nm.lat - pm.lat;
|
||||
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 });
|
||||
positions.push([lon, lat]);
|
||||
topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
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;
|
||||
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
|
||||
const polygon = buildInterpPolygon(positions);
|
||||
if (sfPositions.length === 0) {
|
||||
// 공통 멤버 없으면 frozen
|
||||
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({
|
||||
...prev,
|
||||
@ -192,8 +259,9 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
polygon,
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
memberCount: members.length,
|
||||
members,
|
||||
memberCount: topMembers.length,
|
||||
members: topMembers,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
_longGap: true,
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GeoJSON } from 'geojson';
|
||||
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 { FleetListItem } from './fleetClusterTypes';
|
||||
import { buildInterpPolygon } from './fleetClusterUtils';
|
||||
@ -48,6 +48,26 @@ export interface FleetClusterGeoJsonResult {
|
||||
|
||||
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시)
|
||||
const FLEET_PALETTE = [
|
||||
'#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24',
|
||||
'#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc',
|
||||
];
|
||||
|
||||
/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */
|
||||
function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) {
|
||||
const matches = groups.filter(g => g.groupKey === groupKey);
|
||||
if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches };
|
||||
const seen = new Set<string>();
|
||||
const members: GroupPolygonDto['members'] = [];
|
||||
for (const g of matches) {
|
||||
for (const m of g.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); }
|
||||
}
|
||||
}
|
||||
return { members, groups: matches };
|
||||
}
|
||||
|
||||
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
|
||||
const {
|
||||
ships,
|
||||
@ -70,9 +90,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
if (!g.polygon) continue;
|
||||
const cid = Number(g.groupKey);
|
||||
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { clusterId: Number(g.groupKey), color: g.color },
|
||||
properties: { clusterId: cid, color },
|
||||
geometry: g.polygon,
|
||||
});
|
||||
}
|
||||
@ -93,7 +115,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { clusterId: hoveredFleetId, color: g.color },
|
||||
properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
|
||||
geometry: g.polygon,
|
||||
}],
|
||||
};
|
||||
@ -120,43 +142,58 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
return models;
|
||||
}, [correlationByModel]);
|
||||
|
||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
|
||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
|
||||
const operationalPolygons = useMemo(() => {
|
||||
if (!selectedGearGroup || !groupPolygons) return [];
|
||||
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group) return [];
|
||||
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]);
|
||||
// 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지)
|
||||
const rawMatches = groupPolygons.allGroups.filter(
|
||||
g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET',
|
||||
);
|
||||
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 }[] = [];
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
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) {
|
||||
if (c.score < 0.7) continue;
|
||||
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;
|
||||
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
|
||||
|
||||
// 어구 클러스터 GeoJSON (서버 제공)
|
||||
// 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
|
||||
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
|
||||
if (!g.polygon) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
@ -205,7 +242,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
};
|
||||
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color);
|
||||
const cid = Number(g.groupKey);
|
||||
const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length];
|
||||
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor);
|
||||
}
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
|
||||
@ -231,15 +270,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group?.polygon) return null;
|
||||
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
|
||||
if (matches.length === 0) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
features: matches.map(g => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { subClusterId: g.subClusterId },
|
||||
geometry: g.polygon!,
|
||||
})),
|
||||
};
|
||||
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
|
||||
|
||||
@ -303,7 +342,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
|
||||
if (enabledModels.has('identity') && groupPolygons) {
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? [];
|
||||
const { members } = mergeSubClusterMembers(all, selectedGearGroup);
|
||||
for (const m of members) {
|
||||
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
|
||||
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
|
||||
@ -336,7 +375,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
|
||||
if (groupPolygons) {
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi);
|
||||
const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup);
|
||||
const m = allMembers.find(x => x.mmsi === hoveredMmsi);
|
||||
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
|
||||
}
|
||||
const s = ships.find(x => x.mmsi === hoveredMmsi);
|
||||
@ -363,7 +403,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
label: g.groupLabel,
|
||||
memberCount: g.memberCount,
|
||||
areaSqNm: g.areaSqNm,
|
||||
color: g.color,
|
||||
color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
|
||||
members: g.members,
|
||||
})).sort((a, b) => b.memberCount - a.memberCount);
|
||||
}, [groupPolygons]);
|
||||
|
||||
@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Aircraft, AircraftCategory } from '../../types';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import { getZoomScale } from '../../hooks/useShipDeckLayers';
|
||||
import { useSymbolScale } from '../../hooks/useSymbolScale';
|
||||
|
||||
interface Props {
|
||||
aircraft: Aircraft[];
|
||||
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||
const { symbolScale } = useSymbolScale();
|
||||
const color = getAircraftColor(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 strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
||||
|
||||
|
||||
675
frontend/src/components/layers/ShipPopupOverlay.tsx
Normal file
675
frontend/src/components/layers/ShipPopupOverlay.tsx
Normal file
@ -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); }}
|
||||
>
|
||||
<
|
||||
</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); }}
|
||||
>
|
||||
>
|
||||
</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)}°</span>
|
||||
</div>
|
||||
<div className="ship-popup-row">
|
||||
<span className="ship-popup-label">COG</span>
|
||||
<span className="ship-popup-value">{ship.course.toFixed(1)}°</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 →
|
||||
</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) ?? '-'}°
|
||||
</div>
|
||||
<div className="text-white/40">{lastSeen}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
10
frontend/src/contexts/SymbolScaleContext.tsx
Normal file
@ -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>;
|
||||
}
|
||||
12
frontend/src/contexts/symbolScaleState.ts
Normal file
12
frontend/src/contexts/symbolScaleState.ts
Normal file
@ -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: () => {},
|
||||
});
|
||||
552
frontend/src/hooks/useFleetClusterDeckLayers.ts
Normal file
552
frontend/src/hooks/useFleetClusterDeckLayers.ts
Normal file
@ -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 { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
||||
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 { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||
import type { GearCorrelationItem } from '../services/vesselAnalysis';
|
||||
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
|
||||
import { useFontScale } from './useFontScale';
|
||||
import { useShipDeckStore } from '../stores/shipDeckStore';
|
||||
import { clusterLabels } from '../utils/labelCluster';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -60,6 +63,7 @@ export function useGearReplayLayers(
|
||||
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
|
||||
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
|
||||
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
|
||||
const subClusterCenters = useGearReplayStore(s => s.subClusterCenters);
|
||||
const enabledModels = useGearReplayStore(s => s.enabledModels);
|
||||
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||
@ -67,6 +71,17 @@ export function useGearReplayLayers(
|
||||
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
|
||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
||||
@ -96,9 +111,35 @@ export function useGearReplayLayers(
|
||||
|
||||
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++) {
|
||||
const seg = centerTrailSegments[i];
|
||||
if (seg.path.length < 2) continue;
|
||||
@ -112,8 +153,6 @@ export function useGearReplayLayers(
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
// Center dots (real data only) — 항상 ON
|
||||
if (centerDotsPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center-dots',
|
||||
@ -125,15 +164,52 @@ export function useGearReplayLayers(
|
||||
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) ────────────────────────────
|
||||
|
||||
if (frameIdx < 0) {
|
||||
// No valid frame at this time — only show static layers
|
||||
replayLayerRef.current = layers;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
if (frameIdx >= 0) {
|
||||
|
||||
const frame = state.historyFrames[frameIdx];
|
||||
const isStale = !!frame._longGap || !!frame._interp;
|
||||
@ -142,6 +218,9 @@ export function useGearReplayLayers(
|
||||
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
||||
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 (정적 배경) ─────────────
|
||||
if (showTrails) {
|
||||
// 멤버 전체 항적 (identity — 항상 ON)
|
||||
@ -224,18 +303,23 @@ export function useGearReplayLayers(
|
||||
billboard: false,
|
||||
}));
|
||||
|
||||
// Member labels — showLabels 제어
|
||||
if (showLabels) layers.push(new TextLayer<MemberPosition>({
|
||||
// Member labels — showLabels 제어 + 줌 레벨별 클러스터
|
||||
if (showLabels) {
|
||||
const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel);
|
||||
layers.push(new TextLayer<MemberPosition>({
|
||||
id: 'replay-member-labels',
|
||||
data: members,
|
||||
data: clusteredMembers,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getText: d => d.name || d.mmsi,
|
||||
getText: d => {
|
||||
const prefix = d.isParent ? '\u2605 ' : '';
|
||||
return prefix + (d.name || d.mmsi);
|
||||
},
|
||||
getColor: d => d.stale
|
||||
? [148, 163, 184, 200]
|
||||
: d.isGear
|
||||
? [226, 232, 240, 255]
|
||||
: [251, 191, 36, 255],
|
||||
getSize: 10,
|
||||
getSize: 10 * fs,
|
||||
getPixelOffset: [0, 14],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
@ -243,6 +327,7 @@ export function useGearReplayLayers(
|
||||
fontFamily: '"Fira Code Variable", monospace',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
|
||||
const corrPositions: CorrPosition[] = [];
|
||||
@ -368,19 +453,22 @@ export function useGearReplayLayers(
|
||||
billboard: false,
|
||||
}));
|
||||
|
||||
if (showLabels) layers.push(new TextLayer<CorrPosition>({
|
||||
if (showLabels) {
|
||||
const clusteredCorr = clusterLabels(corrPositions, d => [d.lon, d.lat], zoomLevel);
|
||||
layers.push(new TextLayer<CorrPosition>({
|
||||
id: 'replay-corr-labels',
|
||||
data: corrPositions,
|
||||
data: clusteredCorr,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getText: d => d.name,
|
||||
getColor: d => d.color,
|
||||
getSize: 8,
|
||||
getSize: 8 * fs,
|
||||
getPixelOffset: [0, 15],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [2, 1],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Hover highlight
|
||||
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) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
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[]) {
|
||||
// enabledVessels로 개별 on/off 제어 (토글 대응)
|
||||
if (!enabledVessels.has(c.targetMmsi)) continue;
|
||||
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;
|
||||
|
||||
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]);
|
||||
if (!opPolygon) continue;
|
||||
|
||||
if (opPolygon) {
|
||||
layers.push(new PolygonLayer({
|
||||
id: `replay-op-${mn}`,
|
||||
id: `replay-op-${mn}-sub${sid}`,
|
||||
data: [{ polygon: opPolygon.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: [r, g, b, 30],
|
||||
getLineColor: [r, g, b, 200],
|
||||
getLineWidth: 2,
|
||||
lineWidthMinPixels: 2,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
getLineWidth: 2, 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) {
|
||||
if (!enabledModels.has(trail.modelName)) continue;
|
||||
if (trail.path.length < 2) continue;
|
||||
@ -481,7 +651,7 @@ export function useGearReplayLayers(
|
||||
|
||||
// 중심 경로 (PathLayer, 연한 모델 색상)
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-model-trail-${trail.modelName}`,
|
||||
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: [{ path: trail.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [r, g, b, 100],
|
||||
@ -499,7 +669,7 @@ export function useGearReplayLayers(
|
||||
|
||||
const centerData = [{ position: [cx, cy] as [number, number] }];
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: `replay-model-center-${trail.modelName}`,
|
||||
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: [r, g, b, 255],
|
||||
@ -512,12 +682,12 @@ export function useGearReplayLayers(
|
||||
}));
|
||||
if (showLabels) {
|
||||
layers.push(new TextLayer({
|
||||
id: `replay-model-center-label-${trail.modelName}`,
|
||||
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getText: () => trail.modelName,
|
||||
getColor: [r, g, b, 255],
|
||||
getSize: 9,
|
||||
getSize: 9 * fs,
|
||||
getPixelOffset: [0, -12],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
@ -580,22 +750,52 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══
|
||||
// 폴리곤
|
||||
const identityPolygon = buildInterpPolygon(memberPts);
|
||||
if (identityPolygon) {
|
||||
// ══ Identity 레이어 (최상위 z-index — 서브클러스터별 독립 폴리곤) ══
|
||||
const SUB_POLY_COLORS: [number, number, number, number][] = [
|
||||
[251, 191, 36, 40], // sub0 — gold
|
||||
[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({
|
||||
id: 'replay-identity-polygon',
|
||||
data: [{ polygon: identityPolygon.coordinates }],
|
||||
id: `replay-identity-polygon-1h-sub${sf.subClusterId}`,
|
||||
data: [{ polygon: poly.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40],
|
||||
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180],
|
||||
getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci],
|
||||
getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci],
|
||||
getLineWidth: isStale ? 1 : 2,
|
||||
lineWidthMinPixels: 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// TripsLayer (멤버 트레일)
|
||||
if (memberTripsData.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
@ -610,12 +810,26 @@ export function useGearReplayLayers(
|
||||
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({
|
||||
id: 'replay-identity-center',
|
||||
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }],
|
||||
id: `replay-identity-center-sub${sf.subClusterId}`,
|
||||
data: [{ position: [cx, cy] as [number, number] }],
|
||||
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,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 7,
|
||||
@ -623,14 +837,128 @@ export function useGearReplayLayers(
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
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;
|
||||
requestRender();
|
||||
}, [
|
||||
historyFrames, memberTripsData, correlationTripsData,
|
||||
historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData,
|
||||
centerTrailSegments, centerDotsPositions,
|
||||
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
|
||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||
modelCenterTrails, showTrails, showLabels,
|
||||
modelCenterTrails, subClusterCenters, showTrails, showLabels,
|
||||
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
|
||||
replayLayerRef, requestRender,
|
||||
]);
|
||||
|
||||
@ -649,7 +977,12 @@ export function useGearReplayLayers(
|
||||
// ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (historyFrames.length === 0) return;
|
||||
if (historyFrames.length === 0) {
|
||||
// Reset 시 레이어 클리어
|
||||
replayLayerRef.current = [];
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderFrame();
|
||||
@ -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 () => {
|
||||
unsub();
|
||||
unsubPolygonToggle();
|
||||
unsubPinned();
|
||||
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
||||
};
|
||||
}, [historyFrames, renderFrame]);
|
||||
|
||||
@ -4,6 +4,33 @@ import type { GroupPolygonDto } from '../services/vesselAnalysis';
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
|
||||
|
||||
/** 같은 groupKey의 서브클러스터를 하나로 합산 (멤버 합산, 가장 큰 폴리곤 사용) */
|
||||
function mergeByGroupKey(groups: GroupPolygonDto[]): GroupPolygonDto[] {
|
||||
const byKey = new Map<string, GroupPolygonDto[]>();
|
||||
for (const g of groups) {
|
||||
const list = byKey.get(g.groupKey) ?? [];
|
||||
list.push(g);
|
||||
byKey.set(g.groupKey, list);
|
||||
}
|
||||
const result: GroupPolygonDto[] = [];
|
||||
for (const [, items] of byKey) {
|
||||
if (items.length === 1) { result.push(items[0]); continue; }
|
||||
const seen = new Set<string>();
|
||||
const allMembers: GroupPolygonDto['members'] = [];
|
||||
for (const item of items) for (const m of item.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
||||
}
|
||||
const biggest = items.reduce((a, b) => a.memberCount >= b.memberCount ? a : b);
|
||||
result.push({
|
||||
...biggest,
|
||||
subClusterId: 0,
|
||||
members: allMembers,
|
||||
memberCount: allMembers.length,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface UseGroupPolygonsResult {
|
||||
fleetGroups: GroupPolygonDto[];
|
||||
gearInZoneGroups: GroupPolygonDto[];
|
||||
@ -49,17 +76,17 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||
}, [enabled, load]);
|
||||
|
||||
const fleetGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'FLEET'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
const gearInZoneGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
const gearOutZoneGroups = useMemo(
|
||||
() => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'),
|
||||
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')),
|
||||
[allGroups],
|
||||
);
|
||||
|
||||
|
||||
352
frontend/src/hooks/useShipDeckLayers.ts
Normal file
352
frontend/src/hooks/useShipDeckLayers.ts
Normal file
@ -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]);
|
||||
}
|
||||
6
frontend/src/hooks/useSymbolScale.ts
Normal file
6
frontend/src/hooks/useSymbolScale.ts
Normal file
@ -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';
|
||||
groupKey: string;
|
||||
groupLabel: string;
|
||||
subClusterId: number; // 0=단일/병합, 1,2,...=서브클러스터
|
||||
snapshotTime: string;
|
||||
polygon: GeoJSON.Polygon | null;
|
||||
centerLat: number;
|
||||
@ -72,6 +73,7 @@ export interface GroupPolygonDto {
|
||||
zoneName: string | null;
|
||||
members: MemberInfo[];
|
||||
color: string;
|
||||
resolution?: '1h' | '6h';
|
||||
}
|
||||
|
||||
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
||||
@ -105,6 +107,7 @@ export interface GearCorrelationItem {
|
||||
streak: number;
|
||||
observations: number;
|
||||
freezeState: string;
|
||||
subClusterId: number;
|
||||
proximityRatio: number | null;
|
||||
visitScore: 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 { 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 {
|
||||
modelName: string;
|
||||
subClusterId: number; // 서브클러스터별 독립 trail
|
||||
path: [number, number][]; // [lon, lat][]
|
||||
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(
|
||||
frames: HistoryFrame[],
|
||||
corrTracks: CorrelationVesselTrack[],
|
||||
@ -252,7 +340,6 @@ export function buildModelCenterTrails(
|
||||
enabledVessels: Set<string>,
|
||||
startTime: number,
|
||||
): ModelCenterTrail[] {
|
||||
// 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]}
|
||||
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
|
||||
for (const vt of corrTracks) {
|
||||
if (vt.track.length < 1) continue;
|
||||
@ -268,6 +355,17 @@ export function buildModelCenterTrails(
|
||||
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
|
||||
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 timestamps: number[] = [];
|
||||
|
||||
@ -275,31 +373,21 @@ export function buildModelCenterTrails(
|
||||
const t = new Date(frame.snapshotTime).getTime();
|
||||
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)
|
||||
for (const c of enabledItems) {
|
||||
const allPts: [number, number][] = [...basePts];
|
||||
|
||||
// 연관 선박 위치 (트랙 보간)
|
||||
for (const c of subItems) {
|
||||
const track = trackMap.get(c.targetMmsi);
|
||||
if (!track || track.path.length === 0) continue;
|
||||
|
||||
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]);
|
||||
allPts.push(_interpTrackPos(track, t));
|
||||
}
|
||||
|
||||
// 폴리곤 중심 계산
|
||||
const poly = buildInterpPolygon(allPts);
|
||||
if (!poly) continue;
|
||||
const ring = poly.coordinates[0];
|
||||
@ -312,7 +400,8 @@ export function buildModelCenterTrails(
|
||||
}
|
||||
|
||||
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;
|
||||
playbackSpeed: number;
|
||||
|
||||
// Source data
|
||||
// Source data (1h = primary identity polygon)
|
||||
historyFrames: HistoryFrame[];
|
||||
frameTimes: number[];
|
||||
selectedGroupKey: string | null;
|
||||
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
|
||||
memberTripsData: TripsLayerDatum[];
|
||||
correlationTripsData: TripsLayerDatum[];
|
||||
centerTrailSegments: CenterTrailSegment[];
|
||||
centerDotsPositions: [number, number][];
|
||||
subClusterCenters: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
|
||||
/** 리플레이 전체 구간에서 등장한 모든 고유 멤버 (identity 목록용) */
|
||||
allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[];
|
||||
snapshotRanges: number[];
|
||||
modelCenterTrails: ModelCenterTrail[];
|
||||
|
||||
@ -75,6 +87,13 @@ interface GearReplayState {
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
showTrails: 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
|
||||
loadHistory: (
|
||||
@ -83,6 +102,7 @@ interface GearReplayState {
|
||||
corrData: GearCorrelationItem[],
|
||||
enabledModels: Set<string>,
|
||||
enabledVessels: Set<string>,
|
||||
frames6h?: HistoryFrame[],
|
||||
) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
@ -93,6 +113,13 @@ interface GearReplayState {
|
||||
setHoveredMmsi: (mmsi: string | null) => void;
|
||||
setShowTrails: (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;
|
||||
reset: () => void;
|
||||
}
|
||||
@ -113,7 +140,20 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
|
||||
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 });
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
@ -136,12 +176,21 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
frameTimes: [],
|
||||
selectedGroupKey: null,
|
||||
rawCorrelationTracks: [],
|
||||
historyFrames6h: [],
|
||||
frameTimes6h: [],
|
||||
memberTripsData6h: [],
|
||||
centerTrailSegments6h: [],
|
||||
centerDotsPositions6h: [],
|
||||
subClusterCenters6h: [],
|
||||
snapshotRanges6h: [],
|
||||
|
||||
// Pre-computed layer data
|
||||
memberTripsData: [],
|
||||
correlationTripsData: [],
|
||||
centerTrailSegments: [],
|
||||
centerDotsPositions: [],
|
||||
subClusterCenters: [],
|
||||
allHistoryMembers: [],
|
||||
snapshotRanges: [],
|
||||
modelCenterTrails: [],
|
||||
|
||||
@ -151,20 +200,34 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
hoveredMmsi: null,
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
show1hPolygon: true,
|
||||
show6hPolygon: false,
|
||||
abLoop: false,
|
||||
abA: 0,
|
||||
abB: 0,
|
||||
pinnedMmsis: new Set<string>(),
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
|
||||
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => {
|
||||
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => {
|
||||
const startTime = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const endTime = Date.now();
|
||||
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 corrTrips = buildCorrelationTripsData(corrTracks, startTime);
|
||||
const { segments, dots } = buildCenterTrailData(frames);
|
||||
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[]>();
|
||||
for (const c of corrData) {
|
||||
const list = byModel.get(c.modelName) ?? [];
|
||||
@ -176,7 +239,13 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
|
||||
set({
|
||||
historyFrames: frames,
|
||||
historyFrames6h: f6h,
|
||||
frameTimes,
|
||||
frameTimes6h,
|
||||
memberTripsData6h: memberTrips6h,
|
||||
centerTrailSegments6h: seg6h,
|
||||
centerDotsPositions6h: dots6h,
|
||||
snapshotRanges6h: ranges6h,
|
||||
startTime,
|
||||
endTime,
|
||||
currentTime: startTime,
|
||||
@ -201,9 +270,9 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
lastFrameTime = null;
|
||||
|
||||
if (state.currentTime >= state.endTime) {
|
||||
set({ isPlaying: true, currentTime: state.startTime });
|
||||
set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() });
|
||||
} else {
|
||||
set({ isPlaying: true });
|
||||
set({ isPlaying: true, pinnedMmsis: new Set() });
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
@ -238,6 +307,22 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
||||
setShowTrails: (show) => set({ showTrails: show }),
|
||||
setShowLabels: (show) => set({ showLabels: show }),
|
||||
setFocusMode: (focus) => set({ focusMode: focus }),
|
||||
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) => {
|
||||
const state = get();
|
||||
@ -275,13 +360,22 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
endTime: 0,
|
||||
playbackSpeed: 1,
|
||||
historyFrames: [],
|
||||
historyFrames6h: [],
|
||||
frameTimes: [],
|
||||
frameTimes6h: [],
|
||||
memberTripsData6h: [],
|
||||
centerTrailSegments6h: [],
|
||||
centerDotsPositions6h: [],
|
||||
subClusterCenters6h: [],
|
||||
snapshotRanges6h: [],
|
||||
selectedGroupKey: null,
|
||||
rawCorrelationTracks: [],
|
||||
memberTripsData: [],
|
||||
correlationTripsData: [],
|
||||
centerTrailSegments: [],
|
||||
centerDotsPositions: [],
|
||||
subClusterCenters: [],
|
||||
allHistoryMembers: [],
|
||||
snapshotRanges: [],
|
||||
modelCenterTrails: [],
|
||||
enabledModels: new Set<string>(),
|
||||
@ -289,6 +383,13 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
hoveredMmsi: null,
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
show1hPolygon: true,
|
||||
show6hPolygon: false,
|
||||
abLoop: false,
|
||||
abA: 0,
|
||||
abB: 0,
|
||||
pinnedMmsis: new Set<string>(),
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
});
|
||||
},
|
||||
|
||||
108
frontend/src/stores/shipDeckStore.ts
Normal file
108
frontend/src/stores/shipDeckStore.ts
Normal file
@ -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 }),
|
||||
})),
|
||||
);
|
||||
51
frontend/src/utils/labelCluster.ts
Normal file
51
frontend/src/utils/labelCluster.ts
Normal file
@ -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 typing import Optional
|
||||
|
||||
from algorithms.polygon_builder import _get_time_bucket_age
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -479,7 +481,7 @@ def _compute_gear_active_ratio(
|
||||
gear_members: list[dict],
|
||||
all_positions: dict[str, dict],
|
||||
now: datetime,
|
||||
stale_sec: float = 21600,
|
||||
stale_sec: float = 3600,
|
||||
) -> float:
|
||||
"""어구 그룹의 활성 멤버 비율."""
|
||||
if not gear_members:
|
||||
@ -556,19 +558,34 @@ def run_gear_correlation(
|
||||
score_batch: list[tuple] = []
|
||||
total_updated = 0
|
||||
total_raw = 0
|
||||
processed_keys: set[tuple] = set() # (model_id, parent_name, sub_cluster_id, target_mmsi)
|
||||
|
||||
default_params = models[0]
|
||||
|
||||
for gear_group in gear_groups:
|
||||
parent_name = gear_group['parent_name']
|
||||
sub_cluster_id = gear_group.get('sub_cluster_id', 0)
|
||||
members = gear_group['members']
|
||||
if not members:
|
||||
continue
|
||||
|
||||
# 그룹 중심 + 반경
|
||||
center_lat = sum(m['lat'] for m in members) / len(members)
|
||||
center_lon = sum(m['lon'] for m in members) / len(members)
|
||||
group_radius = _compute_group_radius(members)
|
||||
# 1h 활성 멤버 필터 (center/radius 계산용)
|
||||
display_members = [
|
||||
m for m in 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)
|
||||
@ -617,7 +634,7 @@ def run_gear_correlation(
|
||||
|
||||
# raw 메트릭 배치 수집
|
||||
raw_batch.append((
|
||||
now, parent_name, target_mmsi, target_type, target_name,
|
||||
now, parent_name, sub_cluster_id, target_mmsi, target_type, target_name,
|
||||
metrics.get('proximity_ratio'), metrics.get('visit_score'),
|
||||
metrics.get('activity_sync'), metrics.get('dtw_similarity'),
|
||||
metrics.get('speed_correlation'), metrics.get('heading_coherence'),
|
||||
@ -637,7 +654,7 @@ def run_gear_correlation(
|
||||
)
|
||||
|
||||
# 사전 로드된 점수에서 조회 (DB 쿼리 없음)
|
||||
score_key = (model.model_id, parent_name, target_mmsi)
|
||||
score_key = (model.model_id, parent_name, sub_cluster_id, target_mmsi)
|
||||
prev = all_scores.get(score_key)
|
||||
prev_score = prev['current_score'] if prev else None
|
||||
streak = prev['streak_count'] if prev else 0
|
||||
@ -649,15 +666,39 @@ def run_gear_correlation(
|
||||
0.0, model,
|
||||
)
|
||||
|
||||
processed_keys.add(score_key)
|
||||
|
||||
if new_score >= model.track_threshold or prev is not None:
|
||||
score_batch.append((
|
||||
model.model_id, parent_name, target_mmsi,
|
||||
model.model_id, parent_name, sub_cluster_id, target_mmsi,
|
||||
target_type, target_name,
|
||||
round(new_score, 6), new_streak, state,
|
||||
now, now, now,
|
||||
))
|
||||
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 저장
|
||||
_batch_insert_raw(conn, raw_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]:
|
||||
"""모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}"""
|
||||
"""모든 점수를 사전 로드. {(model_id, group_key, sub_cluster_id, target_mmsi): {...}}"""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT model_id, group_key, target_mmsi, "
|
||||
"current_score, streak_count, last_observed_at "
|
||||
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
|
||||
"current_score, streak_count, last_observed_at, "
|
||||
"target_type, target_name "
|
||||
"FROM kcg.gear_correlation_scores"
|
||||
)
|
||||
result = {}
|
||||
for row in cur.fetchall():
|
||||
key = (row[0], row[1], row[2])
|
||||
key = (row[0], row[1], row[2], row[3])
|
||||
result[key] = {
|
||||
'current_score': row[3],
|
||||
'streak_count': row[4],
|
||||
'last_observed_at': row[5],
|
||||
'current_score': row[4],
|
||||
'streak_count': row[5],
|
||||
'last_observed_at': row[6],
|
||||
'target_type': row[7],
|
||||
'target_name': row[8],
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
@ -737,7 +781,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
|
||||
execute_values(
|
||||
cur,
|
||||
"""INSERT INTO kcg.gear_correlation_raw_metrics
|
||||
(observed_at, group_key, target_mmsi, target_type, target_name,
|
||||
(observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
|
||||
proximity_ratio, visit_score, activity_sync,
|
||||
dtw_similarity, speed_correlation, heading_coherence,
|
||||
drift_similarity, shadow_stay, shadow_return,
|
||||
@ -762,11 +806,11 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
|
||||
execute_values(
|
||||
cur,
|
||||
"""INSERT INTO kcg.gear_correlation_scores
|
||||
(model_id, group_key, target_mmsi, target_type, target_name,
|
||||
(model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
|
||||
current_score, streak_count, freeze_state,
|
||||
first_observed_at, last_observed_at, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (model_id, group_key, target_mmsi)
|
||||
ON CONFLICT (model_id, group_key, sub_cluster_id, target_mmsi)
|
||||
DO UPDATE SET
|
||||
target_type = EXCLUDED.target_type,
|
||||
target_name = EXCLUDED.target_name,
|
||||
|
||||
@ -11,6 +11,9 @@ import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
from shapely.geometry import MultiPoint, Point
|
||||
@ -26,11 +29,30 @@ logger = logging.getLogger(__name__)
|
||||
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||
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
|
||||
GEAR_BUFFER_DEG = 0.01
|
||||
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_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)
|
||||
else:
|
||||
try:
|
||||
import pandas as pd
|
||||
last_dt = pd.Timestamp(ts).to_pydatetime()
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||
@ -171,6 +192,10 @@ def detect_gear_groups(
|
||||
if not m:
|
||||
continue
|
||||
|
||||
# 한국 국적 선박(MMSI 440/441)은 어구 AIS 미사용 → 제외
|
||||
if mmsi.startswith('440') or mmsi.startswith('441'):
|
||||
continue
|
||||
|
||||
parent_raw = (m.group(1) or name).strip()
|
||||
parent_key = _normalize_parent(parent_raw)
|
||||
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
|
||||
@ -183,6 +208,7 @@ def detect_gear_groups(
|
||||
'lon': pos['lon'],
|
||||
'sog': pos.get('sog', 0),
|
||||
'cog': pos.get('cog', 0),
|
||||
'timestamp': ts,
|
||||
}
|
||||
raw_groups.setdefault(parent_key, []).append(entry)
|
||||
|
||||
@ -256,14 +282,15 @@ def detect_gear_groups(
|
||||
for i in idxs
|
||||
]
|
||||
|
||||
# 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2
|
||||
sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}'
|
||||
# group_key는 항상 원본명 유지, 서브클러스터는 별도 ID로 구분
|
||||
sub_cluster_id = 0 if len(clusters) == 1 else (ci + 1)
|
||||
sub_mmsi = parent_mmsi if has_seed else None
|
||||
|
||||
results.append({
|
||||
'parent_name': sub_name,
|
||||
'parent_name': display_name,
|
||||
'parent_key': parent_key,
|
||||
'parent_mmsi': sub_mmsi,
|
||||
'sub_cluster_id': sub_cluster_id,
|
||||
'members': members,
|
||||
})
|
||||
|
||||
@ -294,6 +321,7 @@ def detect_gear_groups(
|
||||
existing_mmsis.add(m['mmsi'])
|
||||
if not big['parent_mmsi'] and small['parent_mmsi']:
|
||||
big['parent_mmsi'] = small['parent_mmsi']
|
||||
big['sub_cluster_id'] = 0 # 병합됨 → 단일 클러스터
|
||||
skip.add(j)
|
||||
del big['parent_key']
|
||||
merged.append(big)
|
||||
@ -355,8 +383,12 @@ def build_all_group_snapshots(
|
||||
'isParent': False,
|
||||
})
|
||||
|
||||
# 2척 미만은 폴리곤 미생성
|
||||
if len(points) < 2:
|
||||
newest_age = min(
|
||||
(_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
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
# ── GEAR 타입: detect_gear_groups 결과 순회 ───────────────────
|
||||
# ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
|
||||
gear_groups = detect_gear_groups(vessel_store, now=now)
|
||||
|
||||
for group in gear_groups:
|
||||
parent_name: str = group['parent_name']
|
||||
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_lon: Optional[float] = None
|
||||
|
||||
@ -395,9 +458,9 @@ def build_all_group_snapshots(
|
||||
anchor_lat = parent_pos['lat']
|
||||
anchor_lon = parent_pos['lon']
|
||||
|
||||
if anchor_lat is None and gear_members:
|
||||
anchor_lat = gear_members[0]['lat']
|
||||
anchor_lon = gear_members[0]['lon']
|
||||
if anchor_lat is None and members_for_snap:
|
||||
anchor_lat = members_for_snap[0]['lat']
|
||||
anchor_lon = members_for_snap[0]['lon']
|
||||
|
||||
if anchor_lat is None:
|
||||
continue
|
||||
@ -408,18 +471,17 @@ def build_all_group_snapshots(
|
||||
zone_name = zone_info.get('zone_name') if in_zone else None
|
||||
|
||||
# 비허가(수역 외) 어구: 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
|
||||
|
||||
# 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만)
|
||||
points = [(g['lon'], g['lat']) for g in gear_members]
|
||||
# 폴리곤 points: 멤버 좌표 + 모선 좌표 (근접 시에만)
|
||||
points = [(g['lon'], g['lat']) for g in members_for_snap]
|
||||
parent_nearby = False
|
||||
if parent_mmsi and parent_mmsi in all_positions:
|
||||
parent_pos = all_positions[parent_mmsi]
|
||||
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
|
||||
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:
|
||||
points.append((p_lon, p_lat))
|
||||
parent_nearby = True
|
||||
@ -430,7 +492,6 @@ def build_all_group_snapshots(
|
||||
|
||||
# members JSONB 구성
|
||||
members_out: list[dict] = []
|
||||
# 모선 먼저 (근접 시에만)
|
||||
if parent_nearby and parent_mmsi and parent_mmsi in all_positions:
|
||||
parent_pos = all_positions[parent_mmsi]
|
||||
members_out.append({
|
||||
@ -443,8 +504,7 @@ def build_all_group_snapshots(
|
||||
'role': 'PARENT',
|
||||
'isParent': True,
|
||||
})
|
||||
# 어구 목록
|
||||
for g in gear_members:
|
||||
for g in members_for_snap:
|
||||
members_out.append({
|
||||
'mmsi': g['mmsi'],
|
||||
'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_key': parent_name,
|
||||
'group_label': parent_name,
|
||||
'sub_cluster_id': group.get('sub_cluster_id', 0),
|
||||
'resolution': resolution,
|
||||
'snapshot_time': now,
|
||||
'polygon_wkt': polygon_wkt,
|
||||
'center_wkt': center_wkt,
|
||||
|
||||
1
prediction/cache/vessel_store.py
vendored
1
prediction/cache/vessel_store.py
vendored
@ -345,6 +345,7 @@ class VesselStore:
|
||||
'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0),
|
||||
'cog': cog,
|
||||
'timestamp': last.get('timestamp'),
|
||||
'time_bucket': last.get('time_bucket'),
|
||||
'name': info.get('name', ''),
|
||||
}
|
||||
return result
|
||||
|
||||
@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO kcg.group_polygon_snapshots (
|
||||
group_type, group_key, group_label, snapshot_time,
|
||||
group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time,
|
||||
polygon, center_point, area_sq_nm, member_count,
|
||||
zone_id, zone_name, members, color
|
||||
) VALUES (
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s,
|
||||
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
|
||||
%s, %s, %s, %s, %s::jsonb, %s
|
||||
)
|
||||
@ -175,6 +175,8 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
|
||||
s['group_type'],
|
||||
s['group_key'],
|
||||
s['group_label'],
|
||||
s.get('sub_cluster_id', 0),
|
||||
s.get('resolution', '6h'),
|
||||
s['snapshot_time'],
|
||||
s.get('polygon_wkt'),
|
||||
s.get('center_wkt'),
|
||||
|
||||
@ -60,12 +60,13 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
"""한국 해역 전 선박의 궤적 포인트를 조회한다.
|
||||
|
||||
LineStringM 지오메트리에서 개별 포인트를 추출하며,
|
||||
한국 해역(124-132E, 32-39N) 내 최근 N시간 데이터를 반환한다.
|
||||
한국 해역(122-132E, 31-39N) 내 최근 N시간 데이터를 반환한다.
|
||||
"""
|
||||
query = f"""
|
||||
SELECT
|
||||
t.mmsi,
|
||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||
t.time_bucket,
|
||||
ST_Y((dp).geom) as lat,
|
||||
ST_X((dp).geom) as lon,
|
||||
CASE
|
||||
@ -75,7 +76,7 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
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))
|
||||
"""
|
||||
|
||||
@ -104,6 +105,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
SELECT
|
||||
t.mmsi,
|
||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||
t.time_bucket,
|
||||
ST_Y((dp).geom) as lat,
|
||||
ST_X((dp).geom) as lon,
|
||||
CASE
|
||||
@ -113,7 +115,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
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))
|
||||
"""
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user