Merge pull request 'feat: 어구 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선 + 심볼 스케일' (#212) from feature/gear-replay-cleanup into develop
This commit is contained in:
커밋
c59b38f913
@ -25,4 +25,5 @@ public class GroupPolygonDto {
|
|||||||
private String zoneName;
|
private String zoneName;
|
||||||
private List<Map<String, Object>> members;
|
private List<Map<String, Object>> members;
|
||||||
private String color;
|
private String color;
|
||||||
|
private String resolution;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,9 +31,10 @@ public class GroupPolygonService {
|
|||||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||||
FROM kcg.group_polygon_snapshots
|
FROM kcg.group_polygon_snapshots
|
||||||
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots)
|
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h')
|
||||||
|
AND resolution = '1h'
|
||||||
ORDER BY group_type, member_count DESC
|
ORDER BY group_type, member_count DESC
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ public class GroupPolygonService {
|
|||||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||||
FROM kcg.group_polygon_snapshots
|
FROM kcg.group_polygon_snapshots
|
||||||
WHERE group_key = ?
|
WHERE group_key = ?
|
||||||
ORDER BY snapshot_time DESC
|
ORDER BY snapshot_time DESC
|
||||||
@ -52,7 +53,7 @@ public class GroupPolygonService {
|
|||||||
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
||||||
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
ST_AsGeoJSON(polygon) AS polygon_geojson,
|
||||||
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
|
||||||
area_sq_nm, member_count, zone_id, zone_name, members, color
|
area_sq_nm, member_count, zone_id, zone_name, members, color, resolution
|
||||||
FROM kcg.group_polygon_snapshots
|
FROM kcg.group_polygon_snapshots
|
||||||
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
|
WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL)
|
||||||
ORDER BY snapshot_time DESC
|
ORDER BY snapshot_time DESC
|
||||||
@ -60,15 +61,16 @@ public class GroupPolygonService {
|
|||||||
|
|
||||||
private static final String GROUP_CORRELATIONS_SQL = """
|
private static final String GROUP_CORRELATIONS_SQL = """
|
||||||
WITH best_scores AS (
|
WITH best_scores AS (
|
||||||
SELECT DISTINCT ON (m.id, s.target_mmsi)
|
SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi)
|
||||||
s.target_mmsi, s.target_type, s.target_name,
|
s.target_mmsi, s.target_type, s.target_name,
|
||||||
s.current_score, s.streak_count, s.observation_count,
|
s.current_score, s.streak_count, s.observation_count,
|
||||||
s.freeze_state, s.shadow_bonus_total,
|
s.freeze_state, s.shadow_bonus_total,
|
||||||
|
s.sub_cluster_id,
|
||||||
m.id AS model_id, m.name AS model_name, m.is_default
|
m.id AS model_id, m.name AS model_name, m.is_default
|
||||||
FROM kcg.gear_correlation_scores s
|
FROM kcg.gear_correlation_scores s
|
||||||
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||||
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
|
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
|
||||||
ORDER BY m.id, s.target_mmsi, s.current_score DESC
|
ORDER BY m.id, s.sub_cluster_id, s.target_mmsi, s.current_score DESC
|
||||||
)
|
)
|
||||||
SELECT bs.*,
|
SELECT bs.*,
|
||||||
r.proximity_ratio, r.visit_score, r.heading_coherence
|
r.proximity_ratio, r.visit_score, r.heading_coherence
|
||||||
@ -86,8 +88,9 @@ public class GroupPolygonService {
|
|||||||
SELECT COUNT(*) AS gear_groups,
|
SELECT COUNT(*) AS gear_groups,
|
||||||
COALESCE(SUM(member_count), 0) AS gear_count
|
COALESCE(SUM(member_count), 0) AS gear_count
|
||||||
FROM kcg.group_polygon_snapshots
|
FROM kcg.group_polygon_snapshots
|
||||||
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots)
|
WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots WHERE resolution = '1h')
|
||||||
AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
|
AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
|
||||||
|
AND resolution = '1h'
|
||||||
""";
|
""";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,6 +123,7 @@ public class GroupPolygonService {
|
|||||||
row.put("observations", rs.getInt("observation_count"));
|
row.put("observations", rs.getInt("observation_count"));
|
||||||
row.put("freezeState", rs.getString("freeze_state"));
|
row.put("freezeState", rs.getString("freeze_state"));
|
||||||
row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
|
row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
|
||||||
|
row.put("subClusterId", rs.getInt("sub_cluster_id"));
|
||||||
row.put("proximityRatio", rs.getObject("proximity_ratio"));
|
row.put("proximityRatio", rs.getObject("proximity_ratio"));
|
||||||
row.put("visitScore", rs.getObject("visit_score"));
|
row.put("visitScore", rs.getObject("visit_score"));
|
||||||
row.put("headingCoherence", rs.getObject("heading_coherence"));
|
row.put("headingCoherence", rs.getObject("heading_coherence"));
|
||||||
@ -210,6 +214,7 @@ public class GroupPolygonService {
|
|||||||
.zoneName(rs.getString("zone_name"))
|
.zoneName(rs.getString("zone_name"))
|
||||||
.members(members)
|
.members(members)
|
||||||
.color(rs.getString("color"))
|
.color(rs.getString("color"))
|
||||||
|
.resolution(rs.getString("resolution"))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,19 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
|
||||||
|
- 리플레이 컨트롤러 A-B 구간 반복 기능
|
||||||
|
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
|
||||||
|
- 리치 툴팁: 선박/어구 구분 + 모델 소속 컬러 표시 + 멤버 호버 강조
|
||||||
|
- 항공기 아이콘 줌레벨 기반 스케일 적용
|
||||||
|
- 심볼 크기 조정 패널 (선박/항공기 개별 0.5~2.0x)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 일치율 후보 탐색: 6h 멤버 → 1h 활성 멤버 기반 center/radius
|
||||||
|
- 일치율 반경 밖 이탈 선박 OUT_OF_RANGE 감쇠 적용
|
||||||
|
- 폴리곤 노출: DISPLAY_STALE_SEC=1h time_bucket 기반 필터링
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
|
- 실시간 선박 13K MapLibre → deck.gl IconLayer 전환 (useShipDeckLayers + shipDeckStore)
|
||||||
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
|
- 선단/어구 폴리곤 MapLibre → deck.gl GeoJsonLayer 전환 (useFleetClusterDeckLayers)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import LoginPage from './components/auth/LoginPage';
|
|||||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||||
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
import { SharedFilterProvider } from './contexts/SharedFilterContext';
|
||||||
import { FontScaleProvider } from './contexts/FontScaleContext';
|
import { FontScaleProvider } from './contexts/FontScaleContext';
|
||||||
|
import { SymbolScaleProvider } from './contexts/SymbolScaleContext';
|
||||||
import { IranDashboard } from './components/iran/IranDashboard';
|
import { IranDashboard } from './components/iran/IranDashboard';
|
||||||
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
import { KoreaDashboard } from './components/korea/KoreaDashboard';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@ -67,6 +68,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FontScaleProvider>
|
<FontScaleProvider>
|
||||||
|
<SymbolScaleProvider>
|
||||||
<SharedFilterProvider>
|
<SharedFilterProvider>
|
||||||
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
<div className={`app ${isLive ? 'app-live' : ''}`}>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
@ -160,6 +162,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SharedFilterProvider>
|
</SharedFilterProvider>
|
||||||
|
</SymbolScaleProvider>
|
||||||
</FontScaleProvider>
|
</FontScaleProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
import { useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||||
import { FontScalePanel } from './FontScalePanel';
|
import { FontScalePanel } from './FontScalePanel';
|
||||||
|
import { SymbolScalePanel } from './SymbolScalePanel';
|
||||||
|
|
||||||
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
// Aircraft category colors (matches AircraftLayer military fixed colors)
|
||||||
const AC_CAT_COLORS: Record<string, string> = {
|
const AC_CAT_COLORS: Record<string, string> = {
|
||||||
@ -897,6 +898,7 @@ export function LayerPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FontScalePanel />
|
<FontScalePanel />
|
||||||
|
<SymbolScalePanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -292,23 +292,26 @@ const CorrelationPanel = ({
|
|||||||
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
|
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
|
||||||
</label>
|
</label>
|
||||||
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>로딩...</div>}
|
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>로딩...</div>}
|
||||||
{availableModels.map(m => {
|
{_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
|
||||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||||
const modelItems = correlationByModel.get(m.name) ?? [];
|
const modelItems = correlationByModel.get(mn) ?? [];
|
||||||
|
const hasData = modelItems.length > 0;
|
||||||
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
|
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
|
||||||
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
|
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
|
||||||
|
const am = availableModels.find(m => m.name === mn);
|
||||||
return (
|
return (
|
||||||
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
|
||||||
<input type="checkbox" checked={enabledModels.has(m.name)}
|
<input type="checkbox" checked={enabledModels.has(mn)}
|
||||||
|
disabled={!hasData}
|
||||||
onChange={() => onEnabledModelsChange(prev => {
|
onChange={() => onEnabledModelsChange(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(m.name)) next.delete(m.name); else next.add(m.name);
|
if (next.has(mn)) next.delete(mn); else next.add(mn);
|
||||||
return next;
|
return next;
|
||||||
})}
|
})}
|
||||||
style={{ accentColor: color, width: 11, height: 11 }} title={m.name} />
|
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
|
||||||
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
|
||||||
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}⛴{gc}◆</span>
|
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}⛴${gc}◆` : '—'}</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
|
|||||||
([subClusterId, data]) => ({ subClusterId, ...data }),
|
([subClusterId, data]) => ({ subClusterId, ...data }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환)
|
// 2. 시간별 그룹핑 후 서브클러스터 보존
|
||||||
const byTime = new Map<string, GroupPolygonDto[]>();
|
const byTime = new Map<string, GroupPolygonDto[]>();
|
||||||
for (const h of sorted) {
|
for (const h of sorted) {
|
||||||
const list = byTime.get(h.snapshotTime) ?? [];
|
const list = byTime.get(h.snapshotTime) ?? [];
|
||||||
@ -52,34 +52,63 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
|
|||||||
|
|
||||||
const frames: GroupPolygonDto[] = [];
|
const frames: GroupPolygonDto[] = [];
|
||||||
for (const [, items] of byTime) {
|
for (const [, items] of byTime) {
|
||||||
if (items.length === 1) {
|
const allSameId = items.every(item => (item.subClusterId ?? 0) === 0);
|
||||||
frames.push(items[0]);
|
|
||||||
continue;
|
if (items.length === 1 || allSameId) {
|
||||||
}
|
// 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개
|
||||||
const seen = new Set<string>();
|
const base = items.length === 1 ? items[0] : (() => {
|
||||||
const allMembers: GroupPolygonDto['members'] = [];
|
const seen = new Set<string>();
|
||||||
for (const item of items) {
|
const allMembers: GroupPolygonDto['members'] = [];
|
||||||
for (const m of item.members) {
|
for (const item of items) {
|
||||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
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[] });
|
||||||
}
|
}
|
||||||
const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
|
|
||||||
frames.push({
|
|
||||||
...biggest,
|
|
||||||
subClusterId: 0,
|
|
||||||
members: allMembers,
|
|
||||||
memberCount: allMembers.length,
|
|
||||||
// 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center)
|
|
||||||
centerLat: biggest.centerLat,
|
|
||||||
centerLon: biggest.centerLon,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { frames: fillGapFrames(frames), subClusterCenters };
|
return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 분리된 모듈 ──
|
// ── 분리된 모듈 ──
|
||||||
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
|
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes';
|
||||||
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
|
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
|
||||||
import { fillGapFrames } from './fleetClusterUtils';
|
import { fillGapFrames } from './fleetClusterUtils';
|
||||||
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
|
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
|
||||||
@ -150,8 +179,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
|
fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 서브클러스터별 분리 → 멤버 합산 프레임 + 서브클러스터별 독립 center
|
// 2. resolution별 분리 → 1h(primary) + 6h(secondary)
|
||||||
const { frames: filled, subClusterCenters } = splitAndMergeHistory(history);
|
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 corrData = corrRes.items;
|
||||||
const corrTracks = trackRes.vessels;
|
const corrTracks = trackRes.vessels;
|
||||||
|
|
||||||
@ -159,10 +195,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length;
|
const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length;
|
||||||
console.log('[loadHistory] fetch 완료:', {
|
console.log('[loadHistory] fetch 완료:', {
|
||||||
history: history.length,
|
history: history.length,
|
||||||
|
'1h': history1h.length,
|
||||||
|
'6h': history6h.length,
|
||||||
|
'filled1h': filled.length,
|
||||||
|
'filled6h': filled6h.length,
|
||||||
corrData: corrData.length,
|
corrData: corrData.length,
|
||||||
corrTracks: corrTracks.length,
|
corrTracks: corrTracks.length,
|
||||||
withTrack,
|
withTrack,
|
||||||
sampleTrack: corrTracks[0] ? { mmsi: corrTracks[0].mmsi, trackPts: corrTracks[0].track?.length, score: corrTracks[0].score } : 'none',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi));
|
const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi));
|
||||||
@ -173,9 +212,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
setEnabledVessels(vessels);
|
setEnabledVessels(vessels);
|
||||||
setCorrelationLoading(false);
|
setCorrelationLoading(false);
|
||||||
|
|
||||||
// 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작
|
// 4. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작
|
||||||
const store = useGearReplayStore.getState();
|
const store = useGearReplayStore.getState();
|
||||||
store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels);
|
store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels, filled6h);
|
||||||
// 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
|
// 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = [];
|
const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = [];
|
||||||
@ -187,7 +226,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useGearReplayStore.setState({ subClusterCenters, allHistoryMembers });
|
useGearReplayStore.setState({ subClusterCenters, subClusterCenters6h, allHistoryMembers });
|
||||||
store.play();
|
store.play();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -521,8 +560,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
onClose={closeHistory}
|
onClose={closeHistory}
|
||||||
onFilterByScore={(minPct) => {
|
onFilterByScore={(minPct) => {
|
||||||
// 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관)
|
// 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관)
|
||||||
|
// null(전체) = 30% 이상 전부 ON (API minScore=0.3 기준)
|
||||||
if (minPct === null) {
|
if (minPct === null) {
|
||||||
setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi)));
|
setEnabledVessels(new Set(correlationTracks.filter(v => v.score >= 0.3).map(v => v.mmsi)));
|
||||||
} else {
|
} else {
|
||||||
const threshold = minPct / 100;
|
const threshold = minPct / 100;
|
||||||
const filtered = new Set<string>();
|
const filtered = new Set<string>();
|
||||||
|
|||||||
@ -1,32 +1,114 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { FONT_MONO } from '../../styles/fonts';
|
import { FONT_MONO } from '../../styles/fonts';
|
||||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||||
|
import { MODEL_COLORS } from './fleetClusterConstants';
|
||||||
|
import type { HistoryFrame } from './fleetClusterTypes';
|
||||||
|
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||||
|
|
||||||
interface HistoryReplayControllerProps {
|
interface HistoryReplayControllerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onFilterByScore: (minPct: number | null) => void;
|
onFilterByScore: (minPct: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_AB_GAP_MS = 2 * 3600_000;
|
||||||
|
|
||||||
|
// 멤버 정보 + 소속 모델 매핑
|
||||||
|
interface TooltipMember {
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
isGear: boolean;
|
||||||
|
isParent: boolean;
|
||||||
|
sources: { label: string; color: string }[]; // 소속 (1h, 6h, 모델명)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTooltipMembers(
|
||||||
|
frame1h: HistoryFrame | null,
|
||||||
|
frame6h: HistoryFrame | null,
|
||||||
|
correlationByModel: Map<string, GearCorrelationItem[]>,
|
||||||
|
enabledModels: Set<string>,
|
||||||
|
enabledVessels: Set<string>,
|
||||||
|
): TooltipMember[] {
|
||||||
|
const map = new Map<string, TooltipMember>();
|
||||||
|
|
||||||
|
const addSource = (mmsi: string, name: string, isGear: boolean, isParent: boolean, label: string, color: string) => {
|
||||||
|
const existing = map.get(mmsi);
|
||||||
|
if (existing) {
|
||||||
|
existing.sources.push({ label, color });
|
||||||
|
} else {
|
||||||
|
map.set(mmsi, { mmsi, name, isGear, isParent, sources: [{ label, color }] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1h 멤버
|
||||||
|
if (frame1h) {
|
||||||
|
for (const m of frame1h.members) {
|
||||||
|
const isGear = m.role === 'GEAR';
|
||||||
|
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '1h', '#fbbf24');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6h 멤버
|
||||||
|
if (frame6h) {
|
||||||
|
for (const m of frame6h.members) {
|
||||||
|
const isGear = m.role === 'GEAR';
|
||||||
|
addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '6h', '#93c5fd');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 모델의 일치율 대상
|
||||||
|
for (const [modelName, items] of correlationByModel) {
|
||||||
|
if (modelName === 'identity') continue;
|
||||||
|
if (!enabledModels.has(modelName)) continue;
|
||||||
|
const color = MODEL_COLORS[modelName] ?? '#94a3b8';
|
||||||
|
for (const c of items) {
|
||||||
|
if (!enabledVessels.has(c.targetMmsi)) continue;
|
||||||
|
const isGear = c.targetType === 'GEAR_BUOY';
|
||||||
|
addSource(c.targetMmsi, c.targetName || c.targetMmsi, isGear, false, modelName, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
||||||
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
||||||
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
||||||
const frameCount = useGearReplayStore(s => s.historyFrames.length);
|
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
|
||||||
|
const historyFrames = useGearReplayStore(s => s.historyFrames);
|
||||||
|
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
|
||||||
|
const frameCount = historyFrames.length;
|
||||||
|
const frameCount6h = historyFrames6h.length;
|
||||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||||
const focusMode = useGearReplayStore(s => s.focusMode);
|
const focusMode = useGearReplayStore(s => s.focusMode);
|
||||||
|
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
|
||||||
|
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
|
||||||
|
const abLoop = useGearReplayStore(s => s.abLoop);
|
||||||
|
const abA = useGearReplayStore(s => s.abA);
|
||||||
|
const abB = useGearReplayStore(s => s.abB);
|
||||||
|
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
||||||
|
const enabledModels = useGearReplayStore(s => s.enabledModels);
|
||||||
|
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||||
|
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||||
|
const has6hData = frameCount6h > 0;
|
||||||
|
|
||||||
const progressBarRef = useRef<HTMLInputElement>(null);
|
const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
|
||||||
|
const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<'A' | 'B' | null>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
||||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const store = useGearReplayStore;
|
||||||
|
|
||||||
|
// currentTime → 진행 인디케이터
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useGearReplayStore.subscribe(
|
const unsub = store.subscribe(
|
||||||
s => s.currentTime,
|
s => s.currentTime,
|
||||||
(currentTime) => {
|
(currentTime) => {
|
||||||
const { startTime, endTime } = useGearReplayStore.getState();
|
const { startTime, endTime } = store.getState();
|
||||||
if (endTime <= startTime) return;
|
if (endTime <= startTime) return;
|
||||||
const progress = (currentTime - startTime) / (endTime - startTime);
|
const progress = (currentTime - startTime) / (endTime - startTime);
|
||||||
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000));
|
|
||||||
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
|
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
|
||||||
if (timeDisplayRef.current) {
|
if (timeDisplayRef.current) {
|
||||||
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
|
||||||
@ -34,9 +116,141 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
// 재생 시작 시 고정 툴팁 해제
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying) setPinnedTooltip(null);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const posToProgress = useCallback((clientX: number) => {
|
||||||
|
const rect = trackRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return 0;
|
||||||
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const store = useGearReplayStore;
|
const progressToTime = useCallback((p: number) => {
|
||||||
|
const { startTime, endTime } = store.getState();
|
||||||
|
return startTime + p * (endTime - startTime);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
// 특정 시간에 가장 가까운 1h/6h 프레임 찾기
|
||||||
|
const findClosestFrames = useCallback((t: number) => {
|
||||||
|
const { startTime, endTime } = store.getState();
|
||||||
|
const threshold = (endTime - startTime) * 0.01;
|
||||||
|
let f1h: HistoryFrame | null = null;
|
||||||
|
let f6h: HistoryFrame | null = null;
|
||||||
|
let minD1h = Infinity;
|
||||||
|
let minD6h = Infinity;
|
||||||
|
|
||||||
|
for (const f of historyFrames) {
|
||||||
|
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
|
||||||
|
if (d < minD1h && d < threshold) { minD1h = d; f1h = f; }
|
||||||
|
}
|
||||||
|
for (const f of historyFrames6h) {
|
||||||
|
const d = Math.abs(new Date(f.snapshotTime).getTime() - t);
|
||||||
|
if (d < minD6h && d < threshold) { minD6h = d; f6h = f; }
|
||||||
|
}
|
||||||
|
return { f1h, f6h };
|
||||||
|
}, [store, historyFrames, historyFrames6h]);
|
||||||
|
|
||||||
|
// 트랙 클릭 → seek + 일시정지 + 툴팁 고정/갱신
|
||||||
|
const handleTrackClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (dragging) return;
|
||||||
|
const progress = posToProgress(e.clientX);
|
||||||
|
const t = progressToTime(progress);
|
||||||
|
store.getState().pause();
|
||||||
|
store.getState().seek(t);
|
||||||
|
|
||||||
|
// 가까운 프레임이 있으면 툴팁 고정
|
||||||
|
const { f1h, f6h } = findClosestFrames(t);
|
||||||
|
if (f1h || f6h) {
|
||||||
|
setPinnedTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
|
||||||
|
const mmsis = new Set<string>();
|
||||||
|
if (f1h) f1h.members.forEach(m => mmsis.add(m.mmsi));
|
||||||
|
if (f6h) f6h.members.forEach(m => mmsis.add(m.mmsi));
|
||||||
|
for (const [mn, items] of correlationByModel) {
|
||||||
|
if (mn === 'identity' || !enabledModels.has(mn)) continue;
|
||||||
|
for (const c of items) {
|
||||||
|
if (enabledVessels.has(c.targetMmsi)) mmsis.add(c.targetMmsi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.getState().setPinnedMmsis(mmsis);
|
||||||
|
} else {
|
||||||
|
setPinnedTooltip(null);
|
||||||
|
store.getState().setPinnedMmsis(new Set());
|
||||||
|
}
|
||||||
|
}, [store, posToProgress, progressToTime, findClosestFrames, dragging, correlationByModel, enabledModels, enabledVessels]);
|
||||||
|
|
||||||
|
// 호버 → 1h+6h 프레임 동시 검색
|
||||||
|
const handleTrackHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (dragging || pinnedTooltip) return;
|
||||||
|
const progress = posToProgress(e.clientX);
|
||||||
|
const t = progressToTime(progress);
|
||||||
|
const { f1h, f6h } = findClosestFrames(t);
|
||||||
|
if (f1h || f6h) {
|
||||||
|
setHoveredTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h });
|
||||||
|
} else {
|
||||||
|
setHoveredTooltip(null);
|
||||||
|
}
|
||||||
|
}, [posToProgress, progressToTime, findClosestFrames, dragging, pinnedTooltip]);
|
||||||
|
|
||||||
|
// A-B 드래그
|
||||||
|
const handleAbDown = useCallback((marker: 'A' | 'B') => (e: React.MouseEvent) => {
|
||||||
|
if (isPlaying) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragging(marker);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const t = progressToTime(posToProgress(e.clientX));
|
||||||
|
const { startTime, endTime } = store.getState();
|
||||||
|
const s = store.getState();
|
||||||
|
if (dragging === 'A') {
|
||||||
|
store.getState().setAbA(Math.max(startTime, Math.min(s.abB - MIN_AB_GAP_MS, t)));
|
||||||
|
} else {
|
||||||
|
store.getState().setAbB(Math.min(endTime, Math.max(s.abA + MIN_AB_GAP_MS, t)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleUp = () => setDragging(null);
|
||||||
|
window.addEventListener('mousemove', handleMove);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); };
|
||||||
|
}, [dragging, store, posToProgress, progressToTime]);
|
||||||
|
|
||||||
|
const abAPos = useMemo(() => {
|
||||||
|
if (!abLoop || abA <= 0) return -1;
|
||||||
|
const { startTime, endTime } = store.getState();
|
||||||
|
return endTime > startTime ? (abA - startTime) / (endTime - startTime) : -1;
|
||||||
|
}, [abLoop, abA, store]);
|
||||||
|
|
||||||
|
const abBPos = useMemo(() => {
|
||||||
|
if (!abLoop || abB <= 0) return -1;
|
||||||
|
const { startTime, endTime } = store.getState();
|
||||||
|
return endTime > startTime ? (abB - startTime) / (endTime - startTime) : -1;
|
||||||
|
}, [abLoop, abB, store]);
|
||||||
|
|
||||||
|
// 고정 툴팁 멤버 빌드
|
||||||
|
const pinnedMembers = useMemo(() => {
|
||||||
|
if (!pinnedTooltip) return [];
|
||||||
|
return buildTooltipMembers(pinnedTooltip.frame1h, pinnedTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
|
||||||
|
}, [pinnedTooltip, correlationByModel, enabledModels, enabledVessels]);
|
||||||
|
|
||||||
|
// 호버 리치 멤버 목록 (고정 툴팁과 동일 형식)
|
||||||
|
const hoveredMembers = useMemo(() => {
|
||||||
|
if (!hoveredTooltip) return [];
|
||||||
|
return buildTooltipMembers(hoveredTooltip.frame1h, hoveredTooltip.frame6h, correlationByModel, enabledModels, enabledVessels);
|
||||||
|
}, [hoveredTooltip, correlationByModel, enabledModels, enabledVessels]);
|
||||||
|
|
||||||
|
// 닫기 핸들러 (고정 해제 포함)
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setPinnedTooltip(null);
|
||||||
|
store.getState().setPinnedMmsis(new Set());
|
||||||
|
onClose();
|
||||||
|
}, [store, onClose]);
|
||||||
|
|
||||||
const btnStyle: React.CSSProperties = {
|
const btnStyle: React.CSSProperties = {
|
||||||
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
|
background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4,
|
||||||
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
|
color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO,
|
||||||
@ -53,82 +267,232 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
|||||||
minWidth: 380, maxWidth: 1320,
|
minWidth: 380, maxWidth: 1320,
|
||||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||||
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
|
||||||
}}>
|
}}>
|
||||||
{/* 프로그레스 바 */}
|
{/* 프로그레스 트랙 */}
|
||||||
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
|
<div
|
||||||
{snapshotRanges.map((pos, i) => (
|
ref={trackRef}
|
||||||
<div key={i} style={{
|
style={{ position: 'relative', height: 18, cursor: 'pointer' }}
|
||||||
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
onClick={handleTrackClick}
|
||||||
background: 'rgba(251,191,36,0.4)',
|
onMouseMove={handleTrackHover}
|
||||||
|
onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
|
||||||
|
|
||||||
|
{/* 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={{
|
<div ref={progressIndicatorRef} style={{
|
||||||
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
|
||||||
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
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>
|
||||||
|
|
||||||
|
{/* 멤버 목록 (호버 → 지도 강조) */}
|
||||||
|
{pinnedMembers.map(m => (
|
||||||
|
<div
|
||||||
|
key={m.mmsi}
|
||||||
|
onMouseEnter={() => store.getState().setHoveredMmsi(m.mmsi)}
|
||||||
|
onMouseLeave={() => store.getState().setHoveredMmsi(null)}
|
||||||
|
style={{
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* 컨트롤 행 1: 재생 + 타임라인 */}
|
{/* 컨트롤 행 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
||||||
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
||||||
style={{ ...btnStyle, fontSize: 12 }}>
|
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
|
||||||
{isPlaying ? '⏸' : '▶'}
|
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
|
||||||
</button>
|
<span style={{ color: '#475569' }}>|</span>
|
||||||
<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 }}>
|
|
||||||
✕
|
|
||||||
</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)}
|
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
|
||||||
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
|
style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적</button>
|
||||||
항적
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
||||||
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
|
style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름</button>
|
||||||
이름
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
|
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
|
||||||
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171', borderColor: 'rgba(239,68,68,0.4)' } : btnStyle}
|
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
|
||||||
title="집중 모드 — 주변 라이브 정보 숨김">
|
title="집중 모드">집중</button>
|
||||||
집중
|
<span style={{ color: '#475569' }}>|</span>
|
||||||
</button>
|
<button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)}
|
||||||
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
|
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>
|
<span style={{ color: '#64748b', fontSize: 9 }}>일치율</span>
|
||||||
<select
|
<select defaultValue="70"
|
||||||
onChange={e => {
|
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }}
|
||||||
const val = 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' }}
|
||||||
onFilterByScore(val === '' ? null : Number(val));
|
title="일치율 필터" aria-label="일치율 필터">
|
||||||
}}
|
<option value="">전체 (30%+)</option>
|
||||||
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="">전체</option>
|
|
||||||
<option value="50">50%+</option>
|
<option value="50">50%+</option>
|
||||||
<option value="60">60%+</option>
|
<option value="60">60%+</option>
|
||||||
<option value="70">70%+</option>
|
<option value="70">70%+</option>
|
||||||
<option value="80">80%+</option>
|
<option value="80">80%+</option>
|
||||||
<option value="90">90%+</option>
|
<option value="90">90%+</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span style={{ color: '#64748b', fontSize: 9 }}>
|
||||||
|
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
|
||||||
|
{has6hData && <> / <span style={{ color: '#93c5fd' }}>{frameCount6h}</span></>} 건
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={handleClose}
|
||||||
|
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import { useGearReplayStore } from '../../stores/gearReplayStore';
|
|||||||
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
|
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
|
||||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||||
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
|
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
|
||||||
import maplibregl from 'maplibre-gl';
|
|
||||||
import { InfraLayer } from './InfraLayer';
|
import { InfraLayer } from './InfraLayer';
|
||||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||||
@ -276,29 +275,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// MapLibre에 ship-triangle/gear-diamond SDF 이미지 등록 (FleetClusterMapLayers에서 사용)
|
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
|
||||||
const handleMapLoad = useCallback(() => {
|
const handleMapLoad = useCallback(() => {}, []);
|
||||||
const m = mapRef.current?.getMap() as maplibregl.Map | undefined;
|
|
||||||
if (!m) return;
|
|
||||||
if (!m.hasImage('ship-triangle')) {
|
|
||||||
const s = 64;
|
|
||||||
const c = document.createElement('canvas'); c.width = s; c.height = s;
|
|
||||||
const ctx = c.getContext('2d')!;
|
|
||||||
ctx.beginPath(); ctx.moveTo(s/2,2); ctx.lineTo(s*0.12,s-2); ctx.lineTo(s/2,s*0.62); ctx.lineTo(s*0.88,s-2); ctx.closePath();
|
|
||||||
ctx.fillStyle = '#fff'; ctx.fill();
|
|
||||||
const d = ctx.getImageData(0,0,s,s);
|
|
||||||
m.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
|
|
||||||
}
|
|
||||||
if (!m.hasImage('gear-diamond')) {
|
|
||||||
const s = 64;
|
|
||||||
const c = document.createElement('canvas'); c.width = s; c.height = s;
|
|
||||||
const ctx = c.getContext('2d')!;
|
|
||||||
ctx.beginPath(); ctx.moveTo(s/2,4); ctx.lineTo(s-4,s/2); ctx.lineTo(s/2,s-4); ctx.lineTo(4,s/2); ctx.closePath();
|
|
||||||
ctx.fillStyle = '#fff'; ctx.fill();
|
|
||||||
const d = ctx.getImageData(0,0,s,s);
|
|
||||||
m.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── shipDeckStore 동기화 ──
|
// ── shipDeckStore 동기화 ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,8 +1,21 @@
|
|||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import type { MemberInfo } from '../../services/vesselAnalysis';
|
import type { MemberInfo, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||||
|
|
||||||
|
// ── 서브클러스터 프레임 ──
|
||||||
|
export interface SubFrame {
|
||||||
|
subClusterId: number; // 0=통합, 1,2,...=분리
|
||||||
|
centerLon: number;
|
||||||
|
centerLat: number;
|
||||||
|
members: MemberInfo[];
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 히스토리 스냅샷 + 보간 플래그 ──
|
// ── 히스토리 스냅샷 + 보간 플래그 ──
|
||||||
export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
|
export type HistoryFrame = GroupPolygonDto & {
|
||||||
|
_interp?: boolean;
|
||||||
|
_longGap?: boolean;
|
||||||
|
subFrames: SubFrame[]; // 항상 1개 이상
|
||||||
|
};
|
||||||
|
|
||||||
// ── 외부 노출 타입 (KoreaMap에서 import) ──
|
// ── 외부 노출 타입 (KoreaMap에서 import) ──
|
||||||
export interface SelectedGearGroupData {
|
export interface SelectedGearGroupData {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import type { GeoJSON } from 'geojson';
|
|
||||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||||
import type { HistoryFrame } from './fleetClusterTypes';
|
import type { HistoryFrame, SubFrame } from './fleetClusterTypes';
|
||||||
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
|
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
|
||||||
|
|
||||||
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
||||||
@ -129,13 +128,20 @@ export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Po
|
|||||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||||
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||||
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
||||||
|
*
|
||||||
|
* subFrames 보간 규칙:
|
||||||
|
* - prev/next 양쪽에 동일 subClusterId 존재: 멤버/center 보간
|
||||||
|
* - prev에만 존재: 마지막 위치 그대로 frozen
|
||||||
|
* - next에만 존재: 갭 프레임에서 생략
|
||||||
|
*
|
||||||
|
* top-level members/centerLon/Lat: 전체 subFrames의 union (하위 호환)
|
||||||
*/
|
*/
|
||||||
export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
export function fillGapFrames(snapshots: HistoryFrame[]): HistoryFrame[] {
|
||||||
if (snapshots.length < 2) return snapshots;
|
if (snapshots.length < 2) return snapshots;
|
||||||
const STEP_SHORT_MS = 300_000;
|
const STEP_SHORT_MS = 300_000;
|
||||||
const STEP_LONG_MS = 1_800_000;
|
const STEP_LONG_MS = 1_800_000;
|
||||||
const THRESHOLD_MS = 1_800_000;
|
const THRESHOLD_MS = 1_800_000;
|
||||||
const result: GroupPolygonDto[] = [];
|
const result: HistoryFrame[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < snapshots.length; i++) {
|
for (let i = 0; i < snapshots.length; i++) {
|
||||||
result.push(snapshots[i]);
|
result.push(snapshots[i]);
|
||||||
@ -152,25 +158,46 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
|||||||
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
||||||
if (common.length === 0) continue;
|
if (common.length === 0) continue;
|
||||||
|
|
||||||
|
const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf]));
|
||||||
|
|
||||||
if (gap <= THRESHOLD_MS) {
|
if (gap <= THRESHOLD_MS) {
|
||||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||||
const ratio = (t - t0) / gap;
|
const ratio = (t - t0) / gap;
|
||||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
||||||
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
||||||
|
|
||||||
|
// prev 기준으로 순회: prev에만 존재(frozen) + 양쪽 존재(center 보간)
|
||||||
|
// next에만 존재하는 subClusterId는 prev.subFrames에 없으므로 자동 생략
|
||||||
|
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||||
|
const nsf = nextSubMap.get(psf.subClusterId);
|
||||||
|
if (!nsf) {
|
||||||
|
// prev에만 존재 → frozen
|
||||||
|
return { ...psf };
|
||||||
|
}
|
||||||
|
// 양쪽 존재 → center 보간
|
||||||
|
return {
|
||||||
|
...psf,
|
||||||
|
centerLon: psf.centerLon + (nsf.centerLon - psf.centerLon) * ratio,
|
||||||
|
centerLat: psf.centerLat + (nsf.centerLat - psf.centerLat) * ratio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
...prev,
|
...prev,
|
||||||
snapshotTime: new Date(t).toISOString(),
|
snapshotTime: new Date(t).toISOString(),
|
||||||
centerLon: cLon,
|
centerLon: cLon,
|
||||||
centerLat: cLat,
|
centerLat: cLat,
|
||||||
|
subFrames,
|
||||||
_interp: true,
|
_interp: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||||
const ratio = (t - t0) / gap;
|
const ratio = (t - t0) / gap;
|
||||||
const positions: [number, number][] = [];
|
|
||||||
const members: typeof prev.members = [];
|
|
||||||
|
|
||||||
|
// top-level members 보간 (하위 호환)
|
||||||
|
const topPositions: [number, number][] = [];
|
||||||
|
const topMembers: GroupPolygonDto['members'] = [];
|
||||||
for (const pm of common) {
|
for (const pm of common) {
|
||||||
const nm = nextMap.get(pm.mmsi)!;
|
const nm = nextMap.get(pm.mmsi)!;
|
||||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||||
@ -178,13 +205,53 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
|||||||
const dLon = nm.lon - pm.lon;
|
const dLon = nm.lon - pm.lon;
|
||||||
const dLat = nm.lat - pm.lat;
|
const dLat = nm.lat - pm.lat;
|
||||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||||
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||||
positions.push([lon, lat]);
|
topPositions.push([lon, lat]);
|
||||||
}
|
}
|
||||||
|
const cLon = topPositions.reduce((s, p) => s + p[0], 0) / topPositions.length;
|
||||||
|
const cLat = topPositions.reduce((s, p) => s + p[1], 0) / topPositions.length;
|
||||||
|
const polygon = buildInterpPolygon(topPositions);
|
||||||
|
|
||||||
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
|
// subFrames 보간
|
||||||
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
|
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||||
const polygon = buildInterpPolygon(positions);
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
result.push({
|
||||||
...prev,
|
...prev,
|
||||||
@ -192,8 +259,9 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
|||||||
polygon,
|
polygon,
|
||||||
centerLon: cLon,
|
centerLon: cLon,
|
||||||
centerLat: cLat,
|
centerLat: cLat,
|
||||||
memberCount: members.length,
|
memberCount: topMembers.length,
|
||||||
members,
|
members: topMembers,
|
||||||
|
subFrames,
|
||||||
_interp: true,
|
_interp: true,
|
||||||
_longGap: true,
|
_longGap: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { GeoJSON } from 'geojson';
|
import type { GeoJSON } from 'geojson';
|
||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||||
import type { FleetListItem } from './fleetClusterTypes';
|
import type { FleetListItem } from './fleetClusterTypes';
|
||||||
import { buildInterpPolygon } from './fleetClusterUtils';
|
import { buildInterpPolygon } from './fleetClusterUtils';
|
||||||
@ -142,34 +142,49 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
|||||||
return models;
|
return models;
|
||||||
}, [correlationByModel]);
|
}, [correlationByModel]);
|
||||||
|
|
||||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
|
// 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
|
||||||
const operationalPolygons = useMemo(() => {
|
const operationalPolygons = useMemo(() => {
|
||||||
if (!selectedGearGroup || !groupPolygons) return [];
|
if (!selectedGearGroup || !groupPolygons) return [];
|
||||||
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
// 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지)
|
||||||
const { members: mergedMembers } = mergeSubClusterMembers(allGroups, selectedGearGroup);
|
const rawMatches = groupPolygons.allGroups.filter(
|
||||||
if (mergedMembers.length === 0) return [];
|
g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET',
|
||||||
const basePts: [number, number][] = mergedMembers.map(m => [m.lon, m.lat]);
|
);
|
||||||
|
if (rawMatches.length === 0) return [];
|
||||||
|
|
||||||
|
// 서브클러스터별 basePts
|
||||||
|
const subMap = new Map<number, [number, number][]>();
|
||||||
|
for (const g of rawMatches) {
|
||||||
|
const sid = g.subClusterId ?? 0;
|
||||||
|
subMap.set(sid, g.members.map(m => [m.lon, m.lat]));
|
||||||
|
}
|
||||||
|
|
||||||
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
|
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
|
||||||
for (const [mn, items] of correlationByModel) {
|
for (const [mn, items] of correlationByModel) {
|
||||||
if (!enabledModels.has(mn)) continue;
|
if (!enabledModels.has(mn)) continue;
|
||||||
const extra: [number, number][] = [];
|
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||||
|
|
||||||
|
// 연관 선박을 subClusterId로 그룹핑
|
||||||
|
const subExtras = new Map<number, [number, number][]>();
|
||||||
for (const c of items) {
|
for (const c of items) {
|
||||||
if (c.score < 0.7) continue;
|
if (c.score < 0.7) continue;
|
||||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||||
if (s) extra.push([s.lng, s.lat]);
|
if (!s) continue;
|
||||||
|
const sid = c.subClusterId ?? 0;
|
||||||
|
const list = subExtras.get(sid) ?? [];
|
||||||
|
list.push([s.lng, s.lat]);
|
||||||
|
subExtras.set(sid, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const features: GeoJSON.Feature[] = [];
|
||||||
|
for (const [sid, extraPts] of subExtras) {
|
||||||
|
if (extraPts.length === 0) continue;
|
||||||
|
const basePts = subMap.get(sid) ?? subMap.get(0) ?? [];
|
||||||
|
const polygon = buildInterpPolygon([...basePts, ...extraPts]);
|
||||||
|
if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon });
|
||||||
|
}
|
||||||
|
if (features.length > 0) {
|
||||||
|
result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } });
|
||||||
}
|
}
|
||||||
if (extra.length === 0) continue;
|
|
||||||
const polygon = buildInterpPolygon([...basePts, ...extra]);
|
|
||||||
if (!polygon) continue;
|
|
||||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
|
||||||
result.push({
|
|
||||||
modelName: mn,
|
|
||||||
color,
|
|
||||||
geojson: {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
|
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { memo, useMemo, useState, useEffect } from 'react';
|
|||||||
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Aircraft, AircraftCategory } from '../../types';
|
import type { Aircraft, AircraftCategory } from '../../types';
|
||||||
|
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||||
|
import { getZoomScale } from '../../hooks/useShipDeckLayers';
|
||||||
|
import { useSymbolScale } from '../../hooks/useSymbolScale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
aircraft: Aircraft[];
|
aircraft: Aircraft[];
|
||||||
@ -187,9 +190,12 @@ export function AircraftLayer({ aircraft, militaryOnly }: Props) {
|
|||||||
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) {
|
||||||
const { t } = useTranslation('ships');
|
const { t } = useTranslation('ships');
|
||||||
const [showPopup, setShowPopup] = useState(false);
|
const [showPopup, setShowPopup] = useState(false);
|
||||||
|
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||||
|
const { symbolScale } = useSymbolScale();
|
||||||
const color = getAircraftColor(ac);
|
const color = getAircraftColor(ac);
|
||||||
const shape = getShape(ac);
|
const shape = getShape(ac);
|
||||||
const size = shape.w;
|
const zs = getZoomScale(zoomLevel);
|
||||||
|
const size = Math.round(shape.w * zs * symbolScale.aircraft / 0.8);
|
||||||
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
|
const showLabel = ac.category === 'fighter' || ac.category === 'surveillance';
|
||||||
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8;
|
||||||
|
|
||||||
|
|||||||
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: () => {},
|
||||||
|
});
|
||||||
@ -371,8 +371,8 @@ export function useFleetClusterDeckLayers(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Correlation layers (only when gear group selected) ────────────────────
|
// ── Correlation layers (only when gear group selected, skip during replay) ─
|
||||||
if (selectedGearGroup) {
|
if (selectedGearGroup && !historyActive) {
|
||||||
|
|
||||||
// ── 8. Operational polygons (per model) ────────────────────────────────
|
// ── 8. Operational polygons (per model) ────────────────────────────────
|
||||||
for (const op of geo.operationalPolygons) {
|
for (const op of geo.operationalPolygons) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Layer } from '@deck.gl/core';
|
|||||||
import { TripsLayer } from '@deck.gl/geo-layers';
|
import { TripsLayer } from '@deck.gl/geo-layers';
|
||||||
import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import { useGearReplayStore } from '../stores/gearReplayStore';
|
import { useGearReplayStore } from '../stores/gearReplayStore';
|
||||||
import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess';
|
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
|
||||||
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
||||||
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||||
@ -71,6 +71,14 @@ export function useGearReplayLayers(
|
|||||||
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
|
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
|
||||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||||
|
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
|
||||||
|
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
|
||||||
|
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
|
||||||
|
const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h);
|
||||||
|
const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h);
|
||||||
|
const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h);
|
||||||
|
const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h);
|
||||||
|
const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis);
|
||||||
const { fontScale } = useFontScale();
|
const { fontScale } = useFontScale();
|
||||||
const fs = fontScale.analysis;
|
const fs = fontScale.analysis;
|
||||||
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
||||||
@ -103,64 +111,105 @@ export function useGearReplayLayers(
|
|||||||
|
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
|
|
||||||
// ── 항상 표시: 센터 트레일 + 도트 ──────────────────────────────────
|
// ── 항상 표시: 센터 트레일 ──────────────────────────────────
|
||||||
|
// 서브클러스터가 존재하면 서브클러스터별 독립 trail만 표시 (전체 trail 숨김)
|
||||||
|
const hasSubClusters = subClusterCenters.length > 0 &&
|
||||||
|
subClusterCenters.some(sc => sc.subClusterId > 0);
|
||||||
|
|
||||||
// Center trail segments (PathLayer) — 항상 ON
|
const SUB_TRAIL_COLORS: [number, number, number, number][] = [
|
||||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
[251, 191, 36, 200], // sub=0 (unified) — gold
|
||||||
const seg = centerTrailSegments[i];
|
|
||||||
if (seg.path.length < 2) continue;
|
|
||||||
layers.push(new PathLayer({
|
|
||||||
id: `replay-center-trail-${i}`,
|
|
||||||
data: [{ path: seg.path }],
|
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
|
||||||
getColor: seg.isInterpolated
|
|
||||||
? [249, 115, 22, 200]
|
|
||||||
: [251, 191, 36, 180],
|
|
||||||
widthMinPixels: 2,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center dots (real data only) — 항상 ON
|
|
||||||
if (centerDotsPositions.length > 0) {
|
|
||||||
layers.push(new ScatterplotLayer({
|
|
||||||
id: 'replay-center-dots',
|
|
||||||
data: centerDotsPositions,
|
|
||||||
getPosition: (d: [number, number]) => d,
|
|
||||||
getFillColor: [251, 191, 36, 150],
|
|
||||||
getRadius: 80,
|
|
||||||
radiusUnits: 'meters',
|
|
||||||
radiusMinPixels: 2.5,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 서브클러스터별 독립 center trail (PathLayer) ─────────────────────
|
|
||||||
const SUB_COLORS: [number, number, number, number][] = [
|
|
||||||
[251, 191, 36, 200], // sub=0 (unified) — 기존 gold
|
|
||||||
[96, 165, 250, 200], // sub=1 — blue
|
[96, 165, 250, 200], // sub=1 — blue
|
||||||
[74, 222, 128, 200], // sub=2 — green
|
[74, 222, 128, 200], // sub=2 — green
|
||||||
[251, 146, 60, 200], // sub=3 — orange
|
[251, 146, 60, 200], // sub=3 — orange
|
||||||
[167, 139, 250, 200], // sub=4 — purple
|
[167, 139, 250, 200], // sub=4 — purple
|
||||||
];
|
];
|
||||||
for (const sc of subClusterCenters) {
|
|
||||||
if (sc.path.length < 2) continue;
|
if (hasSubClusters) {
|
||||||
const color = SUB_COLORS[sc.subClusterId % SUB_COLORS.length];
|
// 서브클러스터별 독립 center trail (sub=0 합산 trail 제외)
|
||||||
layers.push(new PathLayer({
|
for (const sc of subClusterCenters) {
|
||||||
id: `replay-sub-center-${sc.subClusterId}`,
|
if (sc.subClusterId === 0) continue; // 합산 center는 점프 유발 → 제외
|
||||||
data: [{ path: sc.path }],
|
if (sc.path.length < 2) continue;
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
const color = SUB_TRAIL_COLORS[sc.subClusterId % SUB_TRAIL_COLORS.length];
|
||||||
getColor: color,
|
layers.push(new PathLayer({
|
||||||
widthMinPixels: 2,
|
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;
|
||||||
|
layers.push(new PathLayer({
|
||||||
|
id: `replay-center-trail-${i}`,
|
||||||
|
data: [{ path: seg.path }],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: seg.isInterpolated
|
||||||
|
? [249, 115, 22, 200]
|
||||||
|
: [251, 191, 36, 180],
|
||||||
|
widthMinPixels: 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (centerDotsPositions.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer({
|
||||||
|
id: 'replay-center-dots',
|
||||||
|
data: centerDotsPositions,
|
||||||
|
getPosition: (d: [number, number]) => d,
|
||||||
|
getFillColor: [251, 191, 36, 150],
|
||||||
|
getRadius: 80,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
radiusMinPixels: 2.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6h 센터 트레일 (정적, frameIdx와 무관) ───────────────────────────
|
||||||
|
if (state.show6hPolygon) {
|
||||||
|
const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0);
|
||||||
|
if (hasSub6h) {
|
||||||
|
for (const sc of subClusterCenters6h) {
|
||||||
|
if (sc.subClusterId === 0) continue;
|
||||||
|
if (sc.path.length < 2) continue;
|
||||||
|
layers.push(new PathLayer({
|
||||||
|
id: `replay-6h-sub-center-${sc.subClusterId}`,
|
||||||
|
data: [{ path: sc.path }],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: [147, 197, 253, 120] as [number, number, number, number],
|
||||||
|
widthMinPixels: 1.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < centerTrailSegments6h.length; i++) {
|
||||||
|
const seg = centerTrailSegments6h[i];
|
||||||
|
if (seg.path.length < 2) continue;
|
||||||
|
layers.push(new PathLayer({
|
||||||
|
id: `replay-6h-center-trail-${i}`,
|
||||||
|
data: [{ path: seg.path }],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number],
|
||||||
|
widthMinPixels: 1.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (centerDotsPositions6h.length > 0) {
|
||||||
|
layers.push(new ScatterplotLayer({
|
||||||
|
id: 'replay-6h-center-dots',
|
||||||
|
data: centerDotsPositions6h,
|
||||||
|
getPosition: (d: [number, number]) => d,
|
||||||
|
getFillColor: [147, 197, 253, 120] as [number, number, number, number],
|
||||||
|
getRadius: 80,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
radiusMinPixels: 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dynamic layers (depend on currentTime) ────────────────────────────
|
// ── Dynamic layers (depend on currentTime) ────────────────────────────
|
||||||
|
|
||||||
if (frameIdx < 0) {
|
if (frameIdx >= 0) {
|
||||||
// No valid frame at this time — only show static layers
|
|
||||||
replayLayerRef.current = layers;
|
|
||||||
requestRender();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frame = state.historyFrames[frameIdx];
|
const frame = state.historyFrames[frameIdx];
|
||||||
const isStale = !!frame._longGap || !!frame._interp;
|
const isStale = !!frame._longGap || !!frame._interp;
|
||||||
@ -169,6 +218,9 @@ export function useGearReplayLayers(
|
|||||||
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
||||||
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
||||||
|
|
||||||
|
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
|
||||||
|
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
|
||||||
|
|
||||||
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
|
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
|
||||||
if (showTrails) {
|
if (showTrails) {
|
||||||
// 멤버 전체 항적 (identity — 항상 ON)
|
// 멤버 전체 항적 (identity — 항상 ON)
|
||||||
@ -476,39 +528,121 @@ export function useGearReplayLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Operational polygons (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성)
|
// 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조)
|
||||||
|
if (state.pinnedMmsis.size > 0) {
|
||||||
|
const pinnedPositions: { position: [number, number] }[] = [];
|
||||||
|
for (const m of members) {
|
||||||
|
if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] });
|
||||||
|
}
|
||||||
|
for (const c of corrPositions) {
|
||||||
|
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
|
||||||
|
}
|
||||||
|
if (pinnedPositions.length > 0) {
|
||||||
|
// glow
|
||||||
|
layers.push(new ScatterplotLayer({
|
||||||
|
id: 'replay-pinned-glow',
|
||||||
|
data: pinnedPositions,
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getFillColor: [255, 255, 255, 40],
|
||||||
|
getRadius: 350,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
radiusMinPixels: 12,
|
||||||
|
}));
|
||||||
|
// ring
|
||||||
|
layers.push(new ScatterplotLayer({
|
||||||
|
id: 'replay-pinned-ring',
|
||||||
|
data: pinnedPositions,
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getFillColor: [0, 0, 0, 0],
|
||||||
|
getRadius: 200,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
radiusMinPixels: 6,
|
||||||
|
stroked: true,
|
||||||
|
getLineColor: [255, 255, 255, 200],
|
||||||
|
lineWidthMinPixels: 1.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinned trails (correlation tracks)
|
||||||
|
const relTime = ct - st;
|
||||||
|
for (const trip of correlationTripsData) {
|
||||||
|
if (!state.pinnedMmsis.has(trip.id)) continue;
|
||||||
|
let clipIdx = trip.timestamps.length;
|
||||||
|
for (let i = 0; i < trip.timestamps.length; i++) {
|
||||||
|
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
|
||||||
|
}
|
||||||
|
const clippedPath = trip.path.slice(0, clipIdx);
|
||||||
|
if (clippedPath.length >= 2) {
|
||||||
|
layers.push(new PathLayer({
|
||||||
|
id: `replay-pinned-trail-${trip.id}`,
|
||||||
|
data: [{ path: clippedPath }],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: [255, 255, 255, 150],
|
||||||
|
widthMinPixels: 2.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinned member trails (identity tracks)
|
||||||
|
for (const trip of memberTripsData) {
|
||||||
|
if (!state.pinnedMmsis.has(trip.id)) continue;
|
||||||
|
let clipIdx = trip.timestamps.length;
|
||||||
|
for (let i = 0; i < trip.timestamps.length; i++) {
|
||||||
|
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
|
||||||
|
}
|
||||||
|
const clippedPath = trip.path.slice(0, clipIdx);
|
||||||
|
if (clippedPath.length >= 2) {
|
||||||
|
layers.push(new PathLayer({
|
||||||
|
id: `replay-pinned-mtrail-${trip.id}`,
|
||||||
|
data: [{ path: clippedPath }],
|
||||||
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
|
getColor: [255, 200, 60, 180],
|
||||||
|
widthMinPixels: 2.5,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반)
|
||||||
for (const [mn, items] of correlationByModel) {
|
for (const [mn, items] of correlationByModel) {
|
||||||
if (!enabledModels.has(mn)) continue;
|
if (!enabledModels.has(mn)) continue;
|
||||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||||
const [r, g, b] = hexToRgb(color);
|
const [r, g, b] = hexToRgb(color);
|
||||||
|
|
||||||
const extraPts: [number, number][] = [];
|
// 연관 선박을 subClusterId로 그룹핑
|
||||||
|
const subExtras = new Map<number, [number, number][]>();
|
||||||
for (const c of items as GearCorrelationItem[]) {
|
for (const c of items as GearCorrelationItem[]) {
|
||||||
// enabledVessels로 개별 on/off 제어 (토글 대응)
|
|
||||||
if (!enabledVessels.has(c.targetMmsi)) continue;
|
if (!enabledVessels.has(c.targetMmsi)) continue;
|
||||||
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
||||||
if (cp) extraPts.push([cp.lon, cp.lat]);
|
if (!cp) continue;
|
||||||
|
const sid = c.subClusterId ?? 0;
|
||||||
|
const list = subExtras.get(sid) ?? [];
|
||||||
|
list.push([cp.lon, cp.lat]);
|
||||||
|
subExtras.set(sid, list);
|
||||||
}
|
}
|
||||||
if (extraPts.length === 0) continue;
|
|
||||||
|
|
||||||
const basePts = memberPts; // identity 항상 ON
|
for (const [sid, extraPts] of subExtras) {
|
||||||
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
|
if (extraPts.length === 0) continue;
|
||||||
if (!opPolygon) continue;
|
// 해당 서브클러스터의 멤버 포인트
|
||||||
|
const sf = subFrames.find(s => s.subClusterId === sid);
|
||||||
layers.push(new PolygonLayer({
|
const basePts: [number, number][] = sf
|
||||||
id: `replay-op-${mn}`,
|
? interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sid).map(m => [m.lon, m.lat])
|
||||||
data: [{ polygon: opPolygon.coordinates }],
|
: memberPts; // fallback: 전체 멤버
|
||||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
|
||||||
getFillColor: [r, g, b, 30],
|
if (opPolygon) {
|
||||||
getLineColor: [r, g, b, 200],
|
layers.push(new PolygonLayer({
|
||||||
getLineWidth: 2,
|
id: `replay-op-${mn}-sub${sid}`,
|
||||||
lineWidthMinPixels: 2,
|
data: [{ polygon: opPolygon.coordinates }],
|
||||||
filled: true,
|
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||||
stroked: true,
|
getFillColor: [r, g, b, 30],
|
||||||
}));
|
getLineColor: [r, g, b, 200],
|
||||||
|
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) {
|
for (const trail of modelCenterTrails) {
|
||||||
if (!enabledModels.has(trail.modelName)) continue;
|
if (!enabledModels.has(trail.modelName)) continue;
|
||||||
if (trail.path.length < 2) continue;
|
if (trail.path.length < 2) continue;
|
||||||
@ -517,7 +651,7 @@ export function useGearReplayLayers(
|
|||||||
|
|
||||||
// 중심 경로 (PathLayer, 연한 모델 색상)
|
// 중심 경로 (PathLayer, 연한 모델 색상)
|
||||||
layers.push(new PathLayer({
|
layers.push(new PathLayer({
|
||||||
id: `replay-model-trail-${trail.modelName}`,
|
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
|
||||||
data: [{ path: trail.path }],
|
data: [{ path: trail.path }],
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
getColor: [r, g, b, 100],
|
getColor: [r, g, b, 100],
|
||||||
@ -535,7 +669,7 @@ export function useGearReplayLayers(
|
|||||||
|
|
||||||
const centerData = [{ position: [cx, cy] as [number, number] }];
|
const centerData = [{ position: [cx, cy] as [number, number] }];
|
||||||
layers.push(new ScatterplotLayer({
|
layers.push(new ScatterplotLayer({
|
||||||
id: `replay-model-center-${trail.modelName}`,
|
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
|
||||||
data: centerData,
|
data: centerData,
|
||||||
getPosition: (d: { position: [number, number] }) => d.position,
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
getFillColor: [r, g, b, 255],
|
getFillColor: [r, g, b, 255],
|
||||||
@ -548,7 +682,7 @@ export function useGearReplayLayers(
|
|||||||
}));
|
}));
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
layers.push(new TextLayer({
|
layers.push(new TextLayer({
|
||||||
id: `replay-model-center-label-${trail.modelName}`,
|
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
|
||||||
data: centerData,
|
data: centerData,
|
||||||
getPosition: (d: { position: [number, number] }) => d.position,
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
getText: () => trail.modelName,
|
getText: () => trail.modelName,
|
||||||
@ -616,22 +750,52 @@ export function useGearReplayLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══
|
// ══ Identity 레이어 (최상위 z-index — 서브클러스터별 독립 폴리곤) ══
|
||||||
// 폴리곤
|
const SUB_POLY_COLORS: [number, number, number, number][] = [
|
||||||
const identityPolygon = buildInterpPolygon(memberPts);
|
[251, 191, 36, 40], // sub0 — gold
|
||||||
if (identityPolygon) {
|
[96, 165, 250, 30], // sub1 — blue
|
||||||
layers.push(new PolygonLayer({
|
[74, 222, 128, 30], // sub2 — green
|
||||||
id: 'replay-identity-polygon',
|
[251, 146, 60, 30], // sub3 — orange
|
||||||
data: [{ polygon: identityPolygon.coordinates }],
|
[167, 139, 250, 30], // sub4 — purple
|
||||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
];
|
||||||
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40],
|
const SUB_STROKE_COLORS: [number, number, number, number][] = [
|
||||||
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180],
|
[251, 191, 36, 180],
|
||||||
getLineWidth: isStale ? 1 : 2,
|
[96, 165, 250, 180],
|
||||||
lineWidthMinPixels: 1,
|
[74, 222, 128, 180],
|
||||||
filled: true,
|
[251, 146, 60, 180],
|
||||||
stroked: true,
|
[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-1h-sub${sf.subClusterId}`,
|
||||||
|
data: [{ polygon: poly.coordinates }],
|
||||||
|
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||||
|
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 (멤버 트레일)
|
// TripsLayer (멤버 트레일)
|
||||||
if (memberTripsData.length > 0) {
|
if (memberTripsData.length > 0) {
|
||||||
layers.push(new TripsLayer({
|
layers.push(new TripsLayer({
|
||||||
@ -646,27 +810,155 @@ export function useGearReplayLayers(
|
|||||||
currentTime: ct - st,
|
currentTime: ct - st,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// 센터 포인트
|
|
||||||
layers.push(new ScatterplotLayer({
|
// 센터 포인트 (서브클러스터별 독립)
|
||||||
id: 'replay-identity-center',
|
for (const sf of subFrames) {
|
||||||
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }],
|
// 다음 프레임의 같은 서브클러스터 센터와 보간
|
||||||
getPosition: (d: { position: [number, number] }) => d.position,
|
const nextFrame = frameIdx < state.historyFrames.length - 1 ? state.historyFrames[frameIdx + 1] : null;
|
||||||
getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255],
|
const nextSf = nextFrame?.subFrames?.find(s => s.subClusterId === sf.subClusterId);
|
||||||
getRadius: 200,
|
let cx = sf.centerLon, cy = sf.centerLat;
|
||||||
radiusUnits: 'meters',
|
if (nextSf && nextFrame) {
|
||||||
radiusMinPixels: 7,
|
const t0 = new Date(frame.snapshotTime).getTime();
|
||||||
stroked: true,
|
const t1 = new Date(nextFrame.snapshotTime).getTime();
|
||||||
getLineColor: [255, 255, 255, 255],
|
const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0;
|
||||||
lineWidthMinPixels: 2,
|
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-sub${sf.subClusterId}`,
|
||||||
|
data: [{ position: [cx, cy] as [number, number] }],
|
||||||
|
getPosition: (d: { position: [number, number] }) => d.position,
|
||||||
|
getFillColor: isStale ? [249, 115, 22, 255] : SUB_CENTER_COLORS[ci],
|
||||||
|
getRadius: 200,
|
||||||
|
radiusUnits: 'meters',
|
||||||
|
radiusMinPixels: 7,
|
||||||
|
stroked: true,
|
||||||
|
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;
|
replayLayerRef.current = layers;
|
||||||
requestRender();
|
requestRender();
|
||||||
}, [
|
}, [
|
||||||
historyFrames, memberTripsData, correlationTripsData,
|
historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData,
|
||||||
centerTrailSegments, centerDotsPositions,
|
centerTrailSegments, centerDotsPositions,
|
||||||
|
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
|
||||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||||
modelCenterTrails, subClusterCenters, showTrails, showLabels, fs, zoomLevel,
|
modelCenterTrails, subClusterCenters, showTrails, showLabels,
|
||||||
|
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
|
||||||
replayLayerRef, requestRender,
|
replayLayerRef, requestRender,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -721,8 +1013,20 @@ export function useGearReplayLayers(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더
|
||||||
|
const unsubPolygonToggle = useGearReplayStore.subscribe(
|
||||||
|
s => [s.show1hPolygon, s.show6hPolygon] as const,
|
||||||
|
() => { debugLoggedRef.current = false; renderFrame(); },
|
||||||
|
);
|
||||||
|
const unsubPinned = useGearReplayStore.subscribe(
|
||||||
|
s => s.pinnedMmsis,
|
||||||
|
() => renderFrame(),
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
|
unsubPolygonToggle();
|
||||||
|
unsubPinned();
|
||||||
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
||||||
};
|
};
|
||||||
}, [historyFrames, renderFrame]);
|
}, [historyFrames, renderFrame]);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
|||||||
import { getNationalityGroup } from './useKoreaData';
|
import { getNationalityGroup } from './useKoreaData';
|
||||||
import { FONT_MONO } from '../styles/fonts';
|
import { FONT_MONO } from '../styles/fonts';
|
||||||
import type { Ship, VesselAnalysisDto } from '../types';
|
import type { Ship, VesselAnalysisDto } from '../types';
|
||||||
|
import { useSymbolScale } from './useSymbolScale';
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ const ZOOM_SCALE: Record<number, number> = {
|
|||||||
};
|
};
|
||||||
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
|
const ZOOM_SCALE_DEFAULT = 4.2; // z14+
|
||||||
|
|
||||||
function getZoomScale(zoom: number): number {
|
export function getZoomScale(zoom: number): number {
|
||||||
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
|
if (zoom >= 14) return ZOOM_SCALE_DEFAULT;
|
||||||
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
|
return ZOOM_SCALE[zoom] ?? ZOOM_SCALE_DEFAULT;
|
||||||
}
|
}
|
||||||
@ -156,6 +157,8 @@ export function useShipDeckLayers(
|
|||||||
shipLayerRef: React.MutableRefObject<Layer[]>,
|
shipLayerRef: React.MutableRefObject<Layer[]>,
|
||||||
requestRender: () => void,
|
requestRender: () => void,
|
||||||
): void {
|
): void {
|
||||||
|
const { symbolScale } = useSymbolScale();
|
||||||
|
const shipSymbolScale = symbolScale.ship;
|
||||||
|
|
||||||
const renderFrame = useCallback(() => {
|
const renderFrame = useCallback(() => {
|
||||||
const state = useShipDeckStore.getState();
|
const state = useShipDeckStore.getState();
|
||||||
@ -170,7 +173,7 @@ export function useShipDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomScale = getZoomScale(zoomLevel);
|
const zoomScale = getZoomScale(zoomLevel) * shipSymbolScale;
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
|
|
||||||
// 1. Build filtered ship render data (~3K ships, <1ms)
|
// 1. Build filtered ship render data (~3K ships, <1ms)
|
||||||
@ -316,7 +319,7 @@ export function useShipDeckLayers(
|
|||||||
|
|
||||||
shipLayerRef.current = layers;
|
shipLayerRef.current = layers;
|
||||||
requestRender();
|
requestRender();
|
||||||
}, [shipLayerRef, requestRender]);
|
}, [shipLayerRef, requestRender, shipSymbolScale]);
|
||||||
|
|
||||||
// Subscribe to all relevant state changes
|
// Subscribe to all relevant state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@ -73,6 +73,7 @@ export interface GroupPolygonDto {
|
|||||||
zoneName: string | null;
|
zoneName: string | null;
|
||||||
members: MemberInfo[];
|
members: MemberInfo[];
|
||||||
color: string;
|
color: string;
|
||||||
|
resolution?: '1h' | '6h';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
||||||
@ -106,6 +107,7 @@ export interface GearCorrelationItem {
|
|||||||
streak: number;
|
streak: number;
|
||||||
observations: number;
|
observations: number;
|
||||||
freezeState: string;
|
freezeState: string;
|
||||||
|
subClusterId: number;
|
||||||
proximityRatio: number | null;
|
proximityRatio: number | null;
|
||||||
visitScore: number | null;
|
visitScore: number | null;
|
||||||
headingCoherence: number | null;
|
headingCoherence: number | null;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
|
import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes';
|
||||||
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
|
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
|
||||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||||
|
|
||||||
@ -235,16 +235,104 @@ export function interpolateMemberPositions(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* interpolateMemberPositions와 동일한 보간 로직이지만,
|
||||||
|
* 특정 subClusterId에 속한 멤버만 스코프한다.
|
||||||
|
* subClusterId에 해당하는 SubFrame이 없으면 빈 배열을 반환한다.
|
||||||
|
*/
|
||||||
|
export function interpolateSubFrameMembers(
|
||||||
|
frames: HistoryFrame[],
|
||||||
|
frameIdx: number,
|
||||||
|
timeMs: number,
|
||||||
|
subClusterId: number,
|
||||||
|
): MemberPosition[] {
|
||||||
|
if (frameIdx < 0 || frameIdx >= frames.length) return [];
|
||||||
|
|
||||||
|
const frame = frames[frameIdx];
|
||||||
|
const subFrame: SubFrame | undefined = frame.subFrames.find(sf => sf.subClusterId === subClusterId);
|
||||||
|
if (!subFrame) return [];
|
||||||
|
|
||||||
|
const isStale = !!frame._longGap || !!frame._interp;
|
||||||
|
|
||||||
|
const toPosition = (
|
||||||
|
m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean },
|
||||||
|
lon: number,
|
||||||
|
lat: number,
|
||||||
|
cog: number,
|
||||||
|
): MemberPosition => ({
|
||||||
|
mmsi: m.mmsi,
|
||||||
|
name: m.name,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
cog,
|
||||||
|
role: m.role,
|
||||||
|
isParent: m.isParent,
|
||||||
|
isGear: m.role === 'GEAR' || !m.isParent,
|
||||||
|
stale: isStale,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 프레임 없음 — 현재 subFrame 위치 그대로 반환
|
||||||
|
if (frameIdx >= frames.length - 1) {
|
||||||
|
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFrame = frames[frameIdx + 1];
|
||||||
|
const nextSubFrame: SubFrame | undefined = nextFrame.subFrames.find(
|
||||||
|
sf => sf.subClusterId === subClusterId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 다음 프레임에 해당 subClusterId 없음 — 현재 위치 그대로 반환
|
||||||
|
if (!nextSubFrame) {
|
||||||
|
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = new Date(frame.snapshotTime).getTime();
|
||||||
|
const t1 = new Date(nextFrame.snapshotTime).getTime();
|
||||||
|
const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0;
|
||||||
|
|
||||||
|
const nextMap = new Map(nextSubFrame.members.map(m => [m.mmsi, m]));
|
||||||
|
|
||||||
|
return subFrame.members.map(m => {
|
||||||
|
const nm = nextMap.get(m.mmsi);
|
||||||
|
if (!nm) {
|
||||||
|
return toPosition(m, m.lon, m.lat, m.cog);
|
||||||
|
}
|
||||||
|
return toPosition(
|
||||||
|
m,
|
||||||
|
m.lon + (nm.lon - m.lon) * ratio,
|
||||||
|
m.lat + (nm.lat - m.lat) * ratio,
|
||||||
|
nm.cog,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산.
|
* 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산.
|
||||||
* 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록.
|
* 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록.
|
||||||
*/
|
*/
|
||||||
export interface ModelCenterTrail {
|
export interface ModelCenterTrail {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
subClusterId: number; // 서브클러스터별 독립 trail
|
||||||
path: [number, number][]; // [lon, lat][]
|
path: [number, number][]; // [lon, lat][]
|
||||||
timestamps: number[]; // relative ms
|
timestamps: number[]; // relative ms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 트랙 맵에서 특정 시점의 보간 위치 조회 */
|
||||||
|
function _interpTrackPos(
|
||||||
|
track: { ts: number[]; path: [number, number][] },
|
||||||
|
t: number,
|
||||||
|
): [number, number] {
|
||||||
|
if (t <= track.ts[0]) return track.path[0];
|
||||||
|
if (t >= track.ts[track.ts.length - 1]) return track.path[track.path.length - 1];
|
||||||
|
let lo = 0, hi = track.ts.length - 1;
|
||||||
|
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
|
||||||
|
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
|
||||||
|
return [
|
||||||
|
track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio,
|
||||||
|
track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildModelCenterTrails(
|
export function buildModelCenterTrails(
|
||||||
frames: HistoryFrame[],
|
frames: HistoryFrame[],
|
||||||
corrTracks: CorrelationVesselTrack[],
|
corrTracks: CorrelationVesselTrack[],
|
||||||
@ -252,7 +340,6 @@ export function buildModelCenterTrails(
|
|||||||
enabledVessels: Set<string>,
|
enabledVessels: Set<string>,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
): ModelCenterTrail[] {
|
): ModelCenterTrail[] {
|
||||||
// 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]}
|
|
||||||
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
|
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
|
||||||
for (const vt of corrTracks) {
|
for (const vt of corrTracks) {
|
||||||
if (vt.track.length < 1) continue;
|
if (vt.track.length < 1) continue;
|
||||||
@ -268,51 +355,53 @@ export function buildModelCenterTrails(
|
|||||||
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
|
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
|
||||||
if (enabledItems.length === 0) continue;
|
if (enabledItems.length === 0) continue;
|
||||||
|
|
||||||
const path: [number, number][] = [];
|
// subClusterId별 연관 선박 그룹핑
|
||||||
const timestamps: number[] = [];
|
const subItemsMap = new Map<number, typeof enabledItems>();
|
||||||
|
for (const c of enabledItems) {
|
||||||
for (const frame of frames) {
|
const sid = c.subClusterId ?? 0;
|
||||||
const t = new Date(frame.snapshotTime).getTime();
|
const list = subItemsMap.get(sid) ?? [];
|
||||||
const relT = t - startTime;
|
list.push(c);
|
||||||
|
subItemsMap.set(sid, list);
|
||||||
// 멤버 위치
|
|
||||||
const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]);
|
|
||||||
|
|
||||||
// 연관 선박 위치 (트랙 보간 or 마지막 점 clamp)
|
|
||||||
for (const c of enabledItems) {
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 폴리곤 중심 계산
|
|
||||||
const poly = buildInterpPolygon(allPts);
|
|
||||||
if (!poly) continue;
|
|
||||||
const ring = poly.coordinates[0];
|
|
||||||
let cx = 0, cy = 0;
|
|
||||||
for (const pt of ring) { cx += pt[0]; cy += pt[1]; }
|
|
||||||
cx /= ring.length; cy /= ring.length;
|
|
||||||
|
|
||||||
path.push([cx, cy]);
|
|
||||||
timestamps.push(relT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.length >= 2) {
|
// 서브클러스터별 독립 trail 생성
|
||||||
results.push({ modelName: mn, path, timestamps });
|
for (const [sid, subItems] of subItemsMap) {
|
||||||
|
const path: [number, number][] = [];
|
||||||
|
const timestamps: number[] = [];
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
const t = new Date(frame.snapshotTime).getTime();
|
||||||
|
const relT = t - startTime;
|
||||||
|
|
||||||
|
// 해당 서브클러스터의 멤버 위치
|
||||||
|
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
|
||||||
|
|
||||||
|
const allPts: [number, number][] = [...basePts];
|
||||||
|
|
||||||
|
// 연관 선박 위치 (트랙 보간)
|
||||||
|
for (const c of subItems) {
|
||||||
|
const track = trackMap.get(c.targetMmsi);
|
||||||
|
if (!track || track.path.length === 0) continue;
|
||||||
|
allPts.push(_interpTrackPos(track, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
const poly = buildInterpPolygon(allPts);
|
||||||
|
if (!poly) continue;
|
||||||
|
const ring = poly.coordinates[0];
|
||||||
|
let cx = 0, cy = 0;
|
||||||
|
for (const pt of ring) { cx += pt[0]; cy += pt[1]; }
|
||||||
|
cx /= ring.length; cy /= ring.length;
|
||||||
|
|
||||||
|
path.push([cx, cy]);
|
||||||
|
timestamps.push(relT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length >= 2) {
|
||||||
|
results.push({ modelName: mn, subClusterId: sid, path, timestamps });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,12 +54,21 @@ interface GearReplayState {
|
|||||||
endTime: number;
|
endTime: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
|
|
||||||
// Source data
|
// Source data (1h = primary identity polygon)
|
||||||
historyFrames: HistoryFrame[];
|
historyFrames: HistoryFrame[];
|
||||||
frameTimes: number[];
|
frameTimes: number[];
|
||||||
selectedGroupKey: string | null;
|
selectedGroupKey: string | null;
|
||||||
rawCorrelationTracks: CorrelationVesselTrack[];
|
rawCorrelationTracks: CorrelationVesselTrack[];
|
||||||
|
|
||||||
|
// 6h identity (독립 레이어 — 1h/모델과 무관)
|
||||||
|
historyFrames6h: HistoryFrame[];
|
||||||
|
frameTimes6h: number[];
|
||||||
|
memberTripsData6h: TripsLayerDatum[];
|
||||||
|
centerTrailSegments6h: CenterTrailSegment[];
|
||||||
|
centerDotsPositions6h: [number, number][];
|
||||||
|
subClusterCenters6h: { subClusterId: number; path: [number, number][]; timestamps: number[] }[];
|
||||||
|
snapshotRanges6h: number[];
|
||||||
|
|
||||||
// Pre-computed layer data
|
// Pre-computed layer data
|
||||||
memberTripsData: TripsLayerDatum[];
|
memberTripsData: TripsLayerDatum[];
|
||||||
correlationTripsData: TripsLayerDatum[];
|
correlationTripsData: TripsLayerDatum[];
|
||||||
@ -79,6 +88,12 @@ interface GearReplayState {
|
|||||||
showTrails: boolean;
|
showTrails: boolean;
|
||||||
showLabels: boolean;
|
showLabels: boolean;
|
||||||
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
|
focusMode: boolean; // 리플레이 집중 모드 — 주변 라이브 정보 숨김
|
||||||
|
show1hPolygon: boolean; // 1h 폴리곤 표시 (진한색/실선)
|
||||||
|
show6hPolygon: boolean; // 6h 폴리곤 표시 (옅은색/점선)
|
||||||
|
abLoop: boolean; // A-B 구간 반복 활성화
|
||||||
|
abA: number; // A 지점 (epoch ms, 0 = 미설정)
|
||||||
|
abB: number; // B 지점 (epoch ms, 0 = 미설정)
|
||||||
|
pinnedMmsis: Set<string>; // 툴팁 고정 시 강조할 MMSI 세트
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadHistory: (
|
loadHistory: (
|
||||||
@ -87,6 +102,7 @@ interface GearReplayState {
|
|||||||
corrData: GearCorrelationItem[],
|
corrData: GearCorrelationItem[],
|
||||||
enabledModels: Set<string>,
|
enabledModels: Set<string>,
|
||||||
enabledVessels: Set<string>,
|
enabledVessels: Set<string>,
|
||||||
|
frames6h?: HistoryFrame[],
|
||||||
) => void;
|
) => void;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@ -98,6 +114,12 @@ interface GearReplayState {
|
|||||||
setShowTrails: (show: boolean) => void;
|
setShowTrails: (show: boolean) => void;
|
||||||
setShowLabels: (show: boolean) => void;
|
setShowLabels: (show: boolean) => void;
|
||||||
setFocusMode: (focus: boolean) => void;
|
setFocusMode: (focus: boolean) => void;
|
||||||
|
setShow1hPolygon: (show: boolean) => void;
|
||||||
|
setShow6hPolygon: (show: boolean) => void;
|
||||||
|
setAbLoop: (on: boolean) => void;
|
||||||
|
setAbA: (t: number) => void;
|
||||||
|
setAbB: (t: number) => void;
|
||||||
|
setPinnedMmsis: (mmsis: Set<string>) => void;
|
||||||
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@ -118,7 +140,20 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
|
|
||||||
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed;
|
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed;
|
||||||
|
|
||||||
if (newTime >= state.endTime) {
|
// A-B 구간 반복
|
||||||
|
if (state.abLoop && state.abA > 0 && state.abB > state.abA) {
|
||||||
|
if (newTime >= state.abB) {
|
||||||
|
set({ currentTime: state.abA });
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A 이전이면 A로 점프
|
||||||
|
if (newTime < state.abA) {
|
||||||
|
set({ currentTime: state.abA });
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (newTime >= state.endTime) {
|
||||||
set({ currentTime: state.startTime });
|
set({ currentTime: state.startTime });
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
return;
|
return;
|
||||||
@ -141,6 +176,13 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
frameTimes: [],
|
frameTimes: [],
|
||||||
selectedGroupKey: null,
|
selectedGroupKey: null,
|
||||||
rawCorrelationTracks: [],
|
rawCorrelationTracks: [],
|
||||||
|
historyFrames6h: [],
|
||||||
|
frameTimes6h: [],
|
||||||
|
memberTripsData6h: [],
|
||||||
|
centerTrailSegments6h: [],
|
||||||
|
centerDotsPositions6h: [],
|
||||||
|
subClusterCenters6h: [],
|
||||||
|
snapshotRanges6h: [],
|
||||||
|
|
||||||
// Pre-computed layer data
|
// Pre-computed layer data
|
||||||
memberTripsData: [],
|
memberTripsData: [],
|
||||||
@ -159,20 +201,33 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
showTrails: true,
|
showTrails: true,
|
||||||
showLabels: true,
|
showLabels: true,
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
|
show1hPolygon: true,
|
||||||
|
show6hPolygon: false,
|
||||||
|
abLoop: false,
|
||||||
|
abA: 0,
|
||||||
|
abB: 0,
|
||||||
|
pinnedMmsis: new Set<string>(),
|
||||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||||
|
|
||||||
// ── Actions ────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => {
|
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels, frames6h) => {
|
||||||
const startTime = Date.now() - 12 * 60 * 60 * 1000;
|
const startTime = Date.now() - 12 * 60 * 60 * 1000;
|
||||||
const endTime = Date.now();
|
const endTime = Date.now();
|
||||||
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
|
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
|
||||||
|
const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime());
|
||||||
|
|
||||||
const memberTrips = buildMemberTripsData(frames, startTime);
|
const memberTrips = buildMemberTripsData(frames, startTime);
|
||||||
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
|
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
|
||||||
const { segments, dots } = buildCenterTrailData(frames);
|
const { segments, dots } = buildCenterTrailData(frames);
|
||||||
const ranges = buildSnapshotRanges(frames, startTime, endTime);
|
const ranges = buildSnapshotRanges(frames, startTime, endTime);
|
||||||
|
|
||||||
|
// 6h 전처리 (동일한 빌드 함수)
|
||||||
|
const f6h = frames6h ?? [];
|
||||||
|
const memberTrips6h = f6h.length > 0 ? buildMemberTripsData(f6h, startTime) : [];
|
||||||
|
const { segments: seg6h, dots: dots6h } = f6h.length > 0 ? buildCenterTrailData(f6h) : { segments: [], dots: [] };
|
||||||
|
const ranges6h = f6h.length > 0 ? buildSnapshotRanges(f6h, startTime, endTime) : [];
|
||||||
|
|
||||||
const byModel = new Map<string, GearCorrelationItem[]>();
|
const byModel = new Map<string, GearCorrelationItem[]>();
|
||||||
for (const c of corrData) {
|
for (const c of corrData) {
|
||||||
const list = byModel.get(c.modelName) ?? [];
|
const list = byModel.get(c.modelName) ?? [];
|
||||||
@ -184,7 +239,13 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
|
|
||||||
set({
|
set({
|
||||||
historyFrames: frames,
|
historyFrames: frames,
|
||||||
|
historyFrames6h: f6h,
|
||||||
frameTimes,
|
frameTimes,
|
||||||
|
frameTimes6h,
|
||||||
|
memberTripsData6h: memberTrips6h,
|
||||||
|
centerTrailSegments6h: seg6h,
|
||||||
|
centerDotsPositions6h: dots6h,
|
||||||
|
snapshotRanges6h: ranges6h,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
currentTime: startTime,
|
currentTime: startTime,
|
||||||
@ -209,9 +270,9 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
lastFrameTime = null;
|
lastFrameTime = null;
|
||||||
|
|
||||||
if (state.currentTime >= state.endTime) {
|
if (state.currentTime >= state.endTime) {
|
||||||
set({ isPlaying: true, currentTime: state.startTime });
|
set({ isPlaying: true, currentTime: state.startTime, pinnedMmsis: new Set() });
|
||||||
} else {
|
} else {
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true, pinnedMmsis: new Set() });
|
||||||
}
|
}
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
@ -247,6 +308,21 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
setShowTrails: (show) => set({ showTrails: show }),
|
setShowTrails: (show) => set({ showTrails: show }),
|
||||||
setShowLabels: (show) => set({ showLabels: show }),
|
setShowLabels: (show) => set({ showLabels: show }),
|
||||||
setFocusMode: (focus) => set({ focusMode: focus }),
|
setFocusMode: (focus) => set({ focusMode: focus }),
|
||||||
|
setShow1hPolygon: (show) => set({ show1hPolygon: show }),
|
||||||
|
setShow6hPolygon: (show) => set({ show6hPolygon: show }),
|
||||||
|
setAbLoop: (on) => {
|
||||||
|
const { startTime, endTime } = get();
|
||||||
|
if (on && startTime > 0) {
|
||||||
|
// 기본 A-B: 전체 구간의 마지막 4시간
|
||||||
|
const dur = endTime - startTime;
|
||||||
|
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime });
|
||||||
|
} else {
|
||||||
|
set({ abLoop: false, abA: 0, abB: 0 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAbA: (t) => set({ abA: t }),
|
||||||
|
setAbB: (t) => set({ abB: t }),
|
||||||
|
setPinnedMmsis: (mmsis) => set({ pinnedMmsis: mmsis }),
|
||||||
|
|
||||||
updateCorrelation: (corrData, corrTracks) => {
|
updateCorrelation: (corrData, corrTracks) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
@ -284,7 +360,14 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
endTime: 0,
|
endTime: 0,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
historyFrames: [],
|
historyFrames: [],
|
||||||
|
historyFrames6h: [],
|
||||||
frameTimes: [],
|
frameTimes: [],
|
||||||
|
frameTimes6h: [],
|
||||||
|
memberTripsData6h: [],
|
||||||
|
centerTrailSegments6h: [],
|
||||||
|
centerDotsPositions6h: [],
|
||||||
|
subClusterCenters6h: [],
|
||||||
|
snapshotRanges6h: [],
|
||||||
selectedGroupKey: null,
|
selectedGroupKey: null,
|
||||||
rawCorrelationTracks: [],
|
rawCorrelationTracks: [],
|
||||||
memberTripsData: [],
|
memberTripsData: [],
|
||||||
@ -301,6 +384,12 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
showTrails: true,
|
showTrails: true,
|
||||||
showLabels: true,
|
showLabels: true,
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
|
show1hPolygon: true,
|
||||||
|
show6hPolygon: false,
|
||||||
|
abLoop: false,
|
||||||
|
abA: 0,
|
||||||
|
abB: 0,
|
||||||
|
pinnedMmsis: new Set<string>(),
|
||||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from algorithms.polygon_builder import _get_time_bucket_age
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -479,7 +481,7 @@ def _compute_gear_active_ratio(
|
|||||||
gear_members: list[dict],
|
gear_members: list[dict],
|
||||||
all_positions: dict[str, dict],
|
all_positions: dict[str, dict],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
stale_sec: float = 21600,
|
stale_sec: float = 3600,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""어구 그룹의 활성 멤버 비율."""
|
"""어구 그룹의 활성 멤버 비율."""
|
||||||
if not gear_members:
|
if not gear_members:
|
||||||
@ -556,6 +558,7 @@ def run_gear_correlation(
|
|||||||
score_batch: list[tuple] = []
|
score_batch: list[tuple] = []
|
||||||
total_updated = 0
|
total_updated = 0
|
||||||
total_raw = 0
|
total_raw = 0
|
||||||
|
processed_keys: set[tuple] = set() # (model_id, parent_name, sub_cluster_id, target_mmsi)
|
||||||
|
|
||||||
default_params = models[0]
|
default_params = models[0]
|
||||||
|
|
||||||
@ -566,10 +569,23 @@ def run_gear_correlation(
|
|||||||
if not members:
|
if not members:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 그룹 중심 + 반경
|
# 1h 활성 멤버 필터 (center/radius 계산용)
|
||||||
center_lat = sum(m['lat'] for m in members) / len(members)
|
display_members = [
|
||||||
center_lon = sum(m['lon'] for m in members) / len(members)
|
m for m in members
|
||||||
group_radius = _compute_group_radius(members)
|
if _get_time_bucket_age(m.get('mmsi'), all_positions, now) <= 3600
|
||||||
|
]
|
||||||
|
# fallback: < 2이면 time_bucket 최신 2개 유지
|
||||||
|
if len(display_members) < 2 and len(members) >= 2:
|
||||||
|
display_members = sorted(
|
||||||
|
members,
|
||||||
|
key=lambda m: _get_time_bucket_age(m.get('mmsi'), all_positions, now),
|
||||||
|
)[:2]
|
||||||
|
active_members = display_members if len(display_members) >= 2 else members
|
||||||
|
|
||||||
|
# 그룹 중심 + 반경 (1h 활성 멤버 기반)
|
||||||
|
center_lat = sum(m['lat'] for m in active_members) / len(active_members)
|
||||||
|
center_lon = sum(m['lon'] for m in active_members) / len(active_members)
|
||||||
|
group_radius = _compute_group_radius(active_members)
|
||||||
|
|
||||||
# 어구 활성도
|
# 어구 활성도
|
||||||
active_ratio = _compute_gear_active_ratio(members, all_positions, now)
|
active_ratio = _compute_gear_active_ratio(members, all_positions, now)
|
||||||
@ -650,6 +666,8 @@ def run_gear_correlation(
|
|||||||
0.0, model,
|
0.0, model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
processed_keys.add(score_key)
|
||||||
|
|
||||||
if new_score >= model.track_threshold or prev is not None:
|
if new_score >= model.track_threshold or prev is not None:
|
||||||
score_batch.append((
|
score_batch.append((
|
||||||
model.model_id, parent_name, sub_cluster_id, target_mmsi,
|
model.model_id, parent_name, sub_cluster_id, target_mmsi,
|
||||||
@ -659,6 +677,28 @@ def run_gear_correlation(
|
|||||||
))
|
))
|
||||||
total_updated += 1
|
total_updated += 1
|
||||||
|
|
||||||
|
# ── 반경 밖 이탈 선박 강제 감쇠 ──────────────────────────────────
|
||||||
|
# all_scores에 기록이 있지만 이번 사이클 후보에서 빠진 항목:
|
||||||
|
# 선박이 탐색 반경(group_radius × 3)을 완전히 벗어난 경우.
|
||||||
|
# Freeze 조건 무시하고 decay_fast 적용 → 빠르게 0으로 수렴.
|
||||||
|
for score_key, prev in all_scores.items():
|
||||||
|
if score_key in processed_keys:
|
||||||
|
continue
|
||||||
|
prev_score = prev['current_score']
|
||||||
|
if prev_score is None or prev_score <= 0:
|
||||||
|
continue
|
||||||
|
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s = score_key
|
||||||
|
# 해당 모델의 decay_fast 파라미터 사용
|
||||||
|
model_params = next((m for m in models if m.model_id == model_id), default_params)
|
||||||
|
new_score = max(0.0, prev_score - model_params.decay_fast)
|
||||||
|
score_batch.append((
|
||||||
|
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s,
|
||||||
|
prev.get('target_type', 'VESSEL'), prev.get('target_name', ''),
|
||||||
|
round(new_score, 6), 0, 'OUT_OF_RANGE',
|
||||||
|
prev.get('last_observed_at', now), now, now,
|
||||||
|
))
|
||||||
|
total_updated += 1
|
||||||
|
|
||||||
# 배치 DB 저장
|
# 배치 DB 저장
|
||||||
_batch_insert_raw(conn, raw_batch)
|
_batch_insert_raw(conn, raw_batch)
|
||||||
_batch_upsert_scores(conn, score_batch)
|
_batch_upsert_scores(conn, score_batch)
|
||||||
@ -709,7 +749,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
|
|||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
|
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
|
||||||
"current_score, streak_count, last_observed_at "
|
"current_score, streak_count, last_observed_at, "
|
||||||
|
"target_type, target_name "
|
||||||
"FROM kcg.gear_correlation_scores"
|
"FROM kcg.gear_correlation_scores"
|
||||||
)
|
)
|
||||||
result = {}
|
result = {}
|
||||||
@ -719,6 +760,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
|
|||||||
'current_score': row[4],
|
'current_score': row[4],
|
||||||
'streak_count': row[5],
|
'streak_count': row[5],
|
||||||
'last_observed_at': row[6],
|
'last_observed_at': row[6],
|
||||||
|
'target_type': row[7],
|
||||||
|
'target_name': row[8],
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import math
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from shapely.geometry import MultiPoint, Point
|
from shapely.geometry import MultiPoint, Point
|
||||||
@ -26,11 +29,30 @@ logger = logging.getLogger(__name__)
|
|||||||
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
|
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
|
||||||
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||||
MAX_DIST_DEG = 0.15 # ~10NM
|
MAX_DIST_DEG = 0.15 # ~10NM
|
||||||
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
|
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) — 그룹 멤버 탐색용
|
||||||
|
DISPLAY_STALE_SEC = 3600 # 1시간 — 폴리곤 스냅샷 노출 기준 (프론트엔드 초기 로드 minutes=60과 동기화)
|
||||||
|
# time_bucket(적재시간) 기반 필터링 — AIS 원본 timestamp는 부표 시계 오류로 부정확할 수 있음
|
||||||
FLEET_BUFFER_DEG = 0.02
|
FLEET_BUFFER_DEG = 0.02
|
||||||
GEAR_BUFFER_DEG = 0.01
|
GEAR_BUFFER_DEG = 0.01
|
||||||
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
||||||
|
|
||||||
|
_KST = ZoneInfo('Asia/Seoul')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_time_bucket_age(mmsi: str, all_positions: dict, now: datetime) -> float:
|
||||||
|
"""MMSI의 time_bucket 기반 age(초) 반환. 실패 시 inf."""
|
||||||
|
pos = all_positions.get(mmsi)
|
||||||
|
tb = pos.get('time_bucket') if pos else None
|
||||||
|
if tb is None:
|
||||||
|
return float('inf')
|
||||||
|
try:
|
||||||
|
tb_dt = pd.Timestamp(tb)
|
||||||
|
if tb_dt.tzinfo is None:
|
||||||
|
tb_dt = tb_dt.tz_localize(_KST).tz_convert(timezone.utc)
|
||||||
|
return (now - tb_dt.to_pydatetime()).total_seconds()
|
||||||
|
except Exception:
|
||||||
|
return float('inf')
|
||||||
|
|
||||||
# 수역 내 어구 색상, 수역 외 어구 색상
|
# 수역 내 어구 색상, 수역 외 어구 색상
|
||||||
_COLOR_GEAR_IN_ZONE = '#ef4444'
|
_COLOR_GEAR_IN_ZONE = '#ef4444'
|
||||||
_COLOR_GEAR_OUT_ZONE = '#f97316'
|
_COLOR_GEAR_OUT_ZONE = '#f97316'
|
||||||
@ -157,7 +179,6 @@ def detect_gear_groups(
|
|||||||
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
|
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
import pandas as pd
|
|
||||||
last_dt = pd.Timestamp(ts).to_pydatetime()
|
last_dt = pd.Timestamp(ts).to_pydatetime()
|
||||||
if last_dt.tzinfo is None:
|
if last_dt.tzinfo is None:
|
||||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||||
@ -187,6 +208,7 @@ def detect_gear_groups(
|
|||||||
'lon': pos['lon'],
|
'lon': pos['lon'],
|
||||||
'sog': pos.get('sog', 0),
|
'sog': pos.get('sog', 0),
|
||||||
'cog': pos.get('cog', 0),
|
'cog': pos.get('cog', 0),
|
||||||
|
'timestamp': ts,
|
||||||
}
|
}
|
||||||
raw_groups.setdefault(parent_key, []).append(entry)
|
raw_groups.setdefault(parent_key, []).append(entry)
|
||||||
|
|
||||||
@ -361,8 +383,12 @@ def build_all_group_snapshots(
|
|||||||
'isParent': False,
|
'isParent': False,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2척 미만은 폴리곤 미생성
|
newest_age = min(
|
||||||
if len(points) < 2:
|
(_get_time_bucket_age(m['mmsi'], all_positions, now) for m in members),
|
||||||
|
default=float('inf'),
|
||||||
|
)
|
||||||
|
# 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성
|
||||||
|
if len(points) < 2 or newest_age > DISPLAY_STALE_SEC:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon(
|
polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon(
|
||||||
@ -384,100 +410,129 @@ def build_all_group_snapshots(
|
|||||||
'color': _cluster_color(company_id),
|
'color': _cluster_color(company_id),
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── GEAR 타입: detect_gear_groups 결과 순회 ───────────────────
|
# ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
|
||||||
gear_groups = detect_gear_groups(vessel_store, now=now)
|
gear_groups = detect_gear_groups(vessel_store, now=now)
|
||||||
|
|
||||||
for group in gear_groups:
|
for group in gear_groups:
|
||||||
parent_name: str = group['parent_name']
|
parent_name: str = group['parent_name']
|
||||||
parent_mmsi: Optional[str] = group['parent_mmsi']
|
parent_mmsi: Optional[str] = group['parent_mmsi']
|
||||||
gear_members: list[dict] = group['members']
|
gear_members: list[dict] = group['members'] # 6h STALE 기반 전체 멤버
|
||||||
|
|
||||||
# 수역 분류: anchor(모선 or 첫 어구) 위치 기준
|
if not gear_members:
|
||||||
anchor_lat: Optional[float] = None
|
|
||||||
anchor_lon: Optional[float] = None
|
|
||||||
|
|
||||||
if parent_mmsi and parent_mmsi in all_positions:
|
|
||||||
parent_pos = all_positions[parent_mmsi]
|
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
zone_info = classify_zone(float(anchor_lat), float(anchor_lon))
|
# ── 1h 활성 멤버 필터 ──
|
||||||
in_zone = _is_in_zone(zone_info)
|
display_members_1h = [
|
||||||
zone_id = zone_info.get('zone') if in_zone else None
|
gm for gm in gear_members
|
||||||
zone_name = zone_info.get('zone_name') if in_zone else None
|
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]
|
||||||
|
|
||||||
# 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외
|
# ── 6h 전체 멤버 노출 조건: 최신 적재가 STALE_SEC 이내 ──
|
||||||
if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE:
|
newest_age_6h = min(
|
||||||
continue
|
(_get_time_bucket_age(gm.get('mmsi'), all_positions, now) for gm in gear_members),
|
||||||
|
default=float('inf'),
|
||||||
# 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만)
|
|
||||||
points = [(g['lon'], g['lat']) for g in gear_members]
|
|
||||||
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):
|
|
||||||
if (p_lon, p_lat) not in points:
|
|
||||||
points.append((p_lon, p_lat))
|
|
||||||
parent_nearby = True
|
|
||||||
|
|
||||||
polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon(
|
|
||||||
points, GEAR_BUFFER_DEG
|
|
||||||
)
|
)
|
||||||
|
display_members_6h = gear_members
|
||||||
|
|
||||||
# members JSONB 구성
|
# ── resolution별 스냅샷 생성 ──
|
||||||
members_out: list[dict] = []
|
for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]:
|
||||||
# 모선 먼저 (근접 시에만)
|
if len(members_for_snap) < 2:
|
||||||
if parent_nearby and parent_mmsi and parent_mmsi in all_positions:
|
continue
|
||||||
parent_pos = all_positions[parent_mmsi]
|
# 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵
|
||||||
members_out.append({
|
if resolution == '6h' and newest_age_6h > STALE_SEC:
|
||||||
'mmsi': parent_mmsi,
|
continue
|
||||||
'name': parent_name,
|
|
||||||
'lat': parent_pos['lat'],
|
# 수역 분류: anchor(모선 or 첫 멤버) 위치 기준
|
||||||
'lon': parent_pos['lon'],
|
anchor_lat: Optional[float] = None
|
||||||
'sog': parent_pos.get('sog', 0),
|
anchor_lon: Optional[float] = None
|
||||||
'cog': parent_pos.get('cog', 0),
|
|
||||||
'role': 'PARENT',
|
if parent_mmsi and parent_mmsi in all_positions:
|
||||||
'isParent': True,
|
parent_pos = all_positions[parent_mmsi]
|
||||||
|
anchor_lat = parent_pos['lat']
|
||||||
|
anchor_lon = parent_pos['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
|
||||||
|
|
||||||
|
zone_info = classify_zone(float(anchor_lat), float(anchor_lon))
|
||||||
|
in_zone = _is_in_zone(zone_info)
|
||||||
|
zone_id = zone_info.get('zone') if in_zone else None
|
||||||
|
zone_name = zone_info.get('zone_name') if in_zone else None
|
||||||
|
|
||||||
|
# 비허가(수역 외) 어구: 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 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']
|
||||||
|
if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2
|
||||||
|
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
|
||||||
|
|
||||||
|
polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon(
|
||||||
|
points, GEAR_BUFFER_DEG
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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({
|
||||||
|
'mmsi': parent_mmsi,
|
||||||
|
'name': parent_name,
|
||||||
|
'lat': parent_pos['lat'],
|
||||||
|
'lon': parent_pos['lon'],
|
||||||
|
'sog': parent_pos.get('sog', 0),
|
||||||
|
'cog': parent_pos.get('cog', 0),
|
||||||
|
'role': 'PARENT',
|
||||||
|
'isParent': True,
|
||||||
|
})
|
||||||
|
for g in members_for_snap:
|
||||||
|
members_out.append({
|
||||||
|
'mmsi': g['mmsi'],
|
||||||
|
'name': g['name'],
|
||||||
|
'lat': g['lat'],
|
||||||
|
'lon': g['lon'],
|
||||||
|
'sog': g['sog'],
|
||||||
|
'cog': g['cog'],
|
||||||
|
'role': 'GEAR',
|
||||||
|
'isParent': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE
|
||||||
|
|
||||||
|
snapshots.append({
|
||||||
|
'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,
|
||||||
|
'area_sq_nm': area_sq_nm,
|
||||||
|
'member_count': len(members_out),
|
||||||
|
'zone_id': zone_id,
|
||||||
|
'zone_name': zone_name,
|
||||||
|
'members': members_out,
|
||||||
|
'color': color,
|
||||||
})
|
})
|
||||||
# 어구 목록
|
|
||||||
for g in gear_members:
|
|
||||||
members_out.append({
|
|
||||||
'mmsi': g['mmsi'],
|
|
||||||
'name': g['name'],
|
|
||||||
'lat': g['lat'],
|
|
||||||
'lon': g['lon'],
|
|
||||||
'sog': g['sog'],
|
|
||||||
'cog': g['cog'],
|
|
||||||
'role': 'GEAR',
|
|
||||||
'isParent': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE
|
|
||||||
|
|
||||||
snapshots.append({
|
|
||||||
'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE',
|
|
||||||
'group_key': parent_name,
|
|
||||||
'group_label': parent_name,
|
|
||||||
'sub_cluster_id': group.get('sub_cluster_id', 0),
|
|
||||||
'snapshot_time': now,
|
|
||||||
'polygon_wkt': polygon_wkt,
|
|
||||||
'center_wkt': center_wkt,
|
|
||||||
'area_sq_nm': area_sq_nm,
|
|
||||||
'member_count': len(members_out),
|
|
||||||
'zone_id': zone_id,
|
|
||||||
'zone_name': zone_name,
|
|
||||||
'members': members_out,
|
|
||||||
'color': color,
|
|
||||||
})
|
|
||||||
|
|
||||||
return snapshots
|
return snapshots
|
||||||
|
|||||||
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),
|
'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0),
|
||||||
'cog': cog,
|
'cog': cog,
|
||||||
'timestamp': last.get('timestamp'),
|
'timestamp': last.get('timestamp'),
|
||||||
|
'time_bucket': last.get('time_bucket'),
|
||||||
'name': info.get('name', ''),
|
'name': info.get('name', ''),
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -154,11 +154,11 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
|
|||||||
|
|
||||||
insert_sql = """
|
insert_sql = """
|
||||||
INSERT INTO kcg.group_polygon_snapshots (
|
INSERT INTO kcg.group_polygon_snapshots (
|
||||||
group_type, group_key, group_label, sub_cluster_id, snapshot_time,
|
group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time,
|
||||||
polygon, center_point, area_sq_nm, member_count,
|
polygon, center_point, area_sq_nm, member_count,
|
||||||
zone_id, zone_name, members, color
|
zone_id, zone_name, members, color
|
||||||
) VALUES (
|
) VALUES (
|
||||||
%s, %s, %s, %s, %s,
|
%s, %s, %s, %s, %s, %s,
|
||||||
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
|
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
|
||||||
%s, %s, %s, %s, %s::jsonb, %s
|
%s, %s, %s, %s, %s::jsonb, %s
|
||||||
)
|
)
|
||||||
@ -176,6 +176,7 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
|
|||||||
s['group_key'],
|
s['group_key'],
|
||||||
s['group_label'],
|
s['group_label'],
|
||||||
s.get('sub_cluster_id', 0),
|
s.get('sub_cluster_id', 0),
|
||||||
|
s.get('resolution', '6h'),
|
||||||
s['snapshot_time'],
|
s['snapshot_time'],
|
||||||
s.get('polygon_wkt'),
|
s.get('polygon_wkt'),
|
||||||
s.get('center_wkt'),
|
s.get('center_wkt'),
|
||||||
|
|||||||
@ -60,12 +60,13 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
|||||||
"""한국 해역 전 선박의 궤적 포인트를 조회한다.
|
"""한국 해역 전 선박의 궤적 포인트를 조회한다.
|
||||||
|
|
||||||
LineStringM 지오메트리에서 개별 포인트를 추출하며,
|
LineStringM 지오메트리에서 개별 포인트를 추출하며,
|
||||||
한국 해역(124-132E, 32-39N) 내 최근 N시간 데이터를 반환한다.
|
한국 해역(122-132E, 31-39N) 내 최근 N시간 데이터를 반환한다.
|
||||||
"""
|
"""
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
t.mmsi,
|
t.mmsi,
|
||||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||||
|
t.time_bucket,
|
||||||
ST_Y((dp).geom) as lat,
|
ST_Y((dp).geom) as lat,
|
||||||
ST_X((dp).geom) as lon,
|
ST_X((dp).geom) as lon,
|
||||||
CASE
|
CASE
|
||||||
@ -75,7 +76,7 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
|||||||
FROM signal.t_vessel_tracks_5min t,
|
FROM signal.t_vessel_tracks_5min t,
|
||||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||||
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours'
|
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours'
|
||||||
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326)
|
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -104,6 +105,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
|||||||
SELECT
|
SELECT
|
||||||
t.mmsi,
|
t.mmsi,
|
||||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||||
|
t.time_bucket,
|
||||||
ST_Y((dp).geom) as lat,
|
ST_Y((dp).geom) as lat,
|
||||||
ST_X((dp).geom) as lon,
|
ST_X((dp).geom) as lon,
|
||||||
CASE
|
CASE
|
||||||
@ -113,7 +115,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
|||||||
FROM signal.t_vessel_tracks_5min t,
|
FROM signal.t_vessel_tracks_5min t,
|
||||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||||
WHERE t.time_bucket > %s
|
WHERE t.time_bucket > %s
|
||||||
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326)
|
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user