feat: 어구 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선 + 심볼 스케일 #212

병합
htlee feature/gear-replay-cleanup 에서 develop 로 4 commits 를 머지했습니다 2026-04-01 12:33:00 +09:00
29개의 변경된 파일1611개의 추가작업 그리고 426개의 파일을 삭제

파일 보기

@ -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();
} }
} }

파일 보기

@ -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>
); );
} }

파일 보기

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

파일 보기

@ -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;

파일 보기

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

파일 보기

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

파일 보기

@ -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(() => {

파일 보기

@ -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

파일 보기

@ -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))
""" """