feat: 리플레이 궤적 표시 TripsLayer 전환 및 이상치 자동 분류

- ScatterplotLayer 기반 수동 프레임 기록 → TripsLayer(deck.gl/geo-layers) GPU 기반 렌더링 전환
- 궤적 길이: 1시간 (시간 기반, 배속 무관 동일 시각적 길이)
- 32비트 float 정밀도 문제 해결: startTime 기준 상대 타임스탬프 사용

이상치 선박 자동 '삭제' 그룹 분류:
- 조건1: 인접 포인트 간 거리 > 100km가 (조회일수 × 5)건 이상
- 조건2: 선박별 평균 속도 > 50노트
- queryCompleted 시 1회만 계산, 렌더링 성능 영향 없음
- 사용자가 이후 그룹 변경 가능

기본값 변경:
- 궤적 표시: true (기본 활성화)
- 재생 배속: 500배속
- 로딩 완료 시 자동 재생 시작

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-06 09:07:51 +09:00
부모 19b2cff39e
커밋 346e5cdcc7
7개의 변경된 파일3843개의 추가작업 그리고 120개의 파일을 삭제

파일 보기

@ -17,7 +17,9 @@
"dependencies": {
"@deck.gl/core": "^9.2.6",
"@deck.gl/extensions": "^9.2.6",
"@deck.gl/geo-layers": "^9.2.6",
"@deck.gl/layers": "^9.2.6",
"@deck.gl/mesh-layers": "^9.2.6",
"@stomp/stompjs": "^7.2.1",
"axios": "^1.4.0",
"dayjs": "^1.11.11",

파일 보기

@ -208,6 +208,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho
getPixelOffset: [14, 0],
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Arial, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineColor: themeColors.shipLabelOutline,
outlineWidth: 2,
pickable: false,

파일 보기

@ -8,6 +8,10 @@
* 3. animationStore.getCurrentVesselPositions() 현재 위치 계산
* 4. deck.gl 레이어 생성 전역 레지스트리 등록 shipBatchRenderer.immediateRender()
*
* 궤적 표시:
* - TripsLayer (@deck.gl/geo-layers): GPU 기반 페이딩 궤적
* - 기존 경로 데이터(mergedTrackStore) 직접 사용, 프레임 기록 불필요
*
* 필터링:
* - filterModules.custom: 선박 아이콘 표시
* - filterModules.path: 항적 라인 표시
@ -16,7 +20,7 @@
* - vesselStates: 선박 상태 (NORMAL/SELECTED/DELETED)
*/
import { useEffect, useRef, useCallback } from 'react';
import { ScatterplotLayer } from '@deck.gl/layers';
import { TripsLayer } from '@deck.gl/geo-layers';
import useMergedTrackStore from '../stores/mergedTrackStore';
import useAnimationStore from '../stores/animationStore';
import useReplayStore from '../stores/replayStore';
@ -29,17 +33,78 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants';
/**
* TripsLayer 궤적 길이 (밀리초 단위, startTime 기준 상대시간)
* 1시간 = 3,600,000ms
* 배속과 무관하게 동일한 시각적 궤적 길이 보장 (시간 기반)
*/
const TRAIL_LENGTH_MS = 3600000;
/**
* 좌표 이상치(GPS 점프) 판별 삭제 그룹 분류용
*
* 조건 1: 인접 포인트 거리 > 100km가 (조회일수 × 5) 이상
* - 1 조회 5, 3 조회 15
* 조건 2: 해당 선박의 평균 속도 > 100노트
* - 평균 속도 = 전체 경로 길이 / (항적 종료시간 - 시작시간)
*
* 하나라도 해당하면 이상치 선박으로 판정
* 포인트 제거 없이 그대로 렌더링하되, 그룹 분류만 '삭제' 설정
*
* @param {Array} geometry - [[lon, lat], ...] 좌표 배열
* @param {Array} timestampsMs - 밀리초 타임스탬프 배열
* @param {number} queryDays - 조회 기간 ()
* @returns {boolean} true면 이상치 선박 (삭제 그룹 대상)
*/
const MAX_DIST_DEG = 1.0; // ~100~110km (맨해튼 거리, 도 단위)
const OUTLIER_PER_DAY = 5; // 1일당 허용 이상치 건수
const MAX_AVG_SPEED_KNOTS = 50; // 평균 속도 임계값 (노트) — 상선 최고속도 ~25노트의 2배
const DEG_TO_NM = 60; // 1도 ≈ 60해리 (위도 기준 근사)
const MS_TO_HOURS = 1 / 3600000; // 밀리초 → 시간
function isOutlierVessel(geometry, timestampsMs, queryDays) {
if (!geometry || geometry.length < 2) return false;
const outlierThreshold = Math.max(OUTLIER_PER_DAY * queryDays, OUTLIER_PER_DAY);
let outlierCount = 0;
let totalDistDeg = 0;
for (let i = 1; i < geometry.length; i++) {
const prev = geometry[i - 1];
const curr = geometry[i];
const dist = Math.abs(curr[0] - prev[0]) + Math.abs(curr[1] - prev[1]);
totalDistDeg += dist;
if (dist > MAX_DIST_DEG) {
outlierCount++;
if (outlierCount >= outlierThreshold) {
return true; // 조건 1 충족 — 조기 종료
}
}
}
// 조건 2: 해당 선박의 평균 속도 > 100노트
const startTime = timestampsMs[0];
const endTime = timestampsMs[timestampsMs.length - 1];
const durationHours = (endTime - startTime) * MS_TO_HOURS;
if (durationHours > 0) {
const totalDistNm = totalDistDeg * DEG_TO_NM;
const avgSpeedKnots = totalDistNm / durationHours;
if (avgSpeedKnots > MAX_AVG_SPEED_KNOTS) {
return true; // 조건 2 충족
}
}
return false;
}
/**
* 선박 상태에 따라 표시 여부 결정
* @param {string} vesselId - 선박 ID
* @param {Object} filterModule - 필터 모듈 설정 (showNormal, showSelected, showDeleted)
* @param {Map} vesselStates - 선박 상태
* @param {Set} deletedVesselIds - 삭제된 선박 Set (레거시)
* @param {Set} selectedVesselIds - 선택된 선박 Set (레거시)
* @returns {boolean} 표시 여부
*/
function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds, selectedVesselIds) {
// 선박 상태 확인
let state = VesselState.NORMAL;
if (vesselStates.has(vesselId)) {
state = vesselStates.get(vesselId);
@ -49,7 +114,6 @@ function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds
state = VesselState.SELECTED;
}
// 상태에 따라 필터 적용
switch (state) {
case VesselState.NORMAL:
return filterModule.showNormal;
@ -68,10 +132,13 @@ function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds
export default function useReplayLayer() {
const staticLayersRef = useRef([]);
const tracksRef = useRef([]);
// TripsLayer용: startTime 기준 상대 타임스탬프 (32비트 float 안전)
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
const queryCompleted = useReplayStore((s) => s.queryCompleted);
// animationStore에서 currentTime, playbackSpeed 구독 (ReplayV2.tsx와 동일)
// animationStore에서 currentTime, playbackSpeed 구독
const currentTime = useAnimationStore((s) => s.currentTime);
const playbackSpeed = useAnimationStore((s) => s.playbackSpeed);
@ -86,6 +153,9 @@ export default function useReplayLayer() {
const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId);
const setHighlightedVesselId = useReplayStore((s) => s.setHighlightedVesselId);
// 항적표시 토글 구독
const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled);
/**
* 항적/아이콘 호버 하이라이트 설정
*/
@ -98,7 +168,6 @@ export default function useReplayLayer() {
*/
const handleIconHover = useCallback((shipData, x, y) => {
if (shipData) {
// ShipTooltip 형식에 맞게 변환하여 shipStore에 저장
useShipStore.getState().setHoverInfo({
ship: {
shipName: shipData.shipName,
@ -110,7 +179,6 @@ export default function useReplayLayer() {
x,
y,
});
// 하이라이트도 설정
setHighlightedVesselId(shipData.vesselId);
} else {
useShipStore.getState().setHoverInfo(null);
@ -120,8 +188,6 @@ export default function useReplayLayer() {
/**
* 트랙 필터링
* @param {Array} tracks - 전체 트랙 배열
* @returns {Array} 필터링된 트랙 배열
*/
const filterTracks = useCallback((tracks) => {
const replayState = useReplayStore.getState();
@ -129,12 +195,9 @@ export default function useReplayLayer() {
const pathFilter = filterModules[FilterModuleType.PATH];
return tracks.filter((track) => {
// 1. 선종 필터
if (!shipKindCodeFilter.has(track.shipKindCode)) {
return false;
}
// 2. 상태 필터 (항적)
return shouldShowVessel(
track.vesselId,
pathFilter,
@ -147,8 +210,6 @@ export default function useReplayLayer() {
/**
* 현재 위치 필터링
* @param {Array} positions - 전체 위치 배열
* @returns {Object} { iconPositions, labelPositions }
*/
const filterPositions = useCallback((positions) => {
const replayState = useReplayStore.getState();
@ -160,30 +221,15 @@ export default function useReplayLayer() {
const labelPositions = [];
positions.forEach((pos) => {
// 1. 선종 필터
if (!shipKindCodeFilter.has(pos.shipKindCode)) {
return;
}
// 2. 아이콘 필터 (CUSTOM)
if (shouldShowVessel(
pos.vesselId,
customFilter,
vesselStates,
deletedVesselIds,
selectedVesselIds
)) {
if (shouldShowVessel(pos.vesselId, customFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
iconPositions.push(pos);
}
// 3. 라벨 필터 (LABEL)
if (shouldShowVessel(
pos.vesselId,
labelFilter,
vesselStates,
deletedVesselIds,
selectedVesselIds
)) {
if (shouldShowVessel(pos.vesselId, labelFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
labelPositions.push(pos);
}
});
@ -193,17 +239,14 @@ export default function useReplayLayer() {
/**
* 애니메이션 렌더링 요청
* animationStore.getCurrentVesselPositions() 사용 (메인 프로젝트 동일)
*/
const requestAnimatedRender = useCallback(() => {
// getCurrentVesselPositions로 현재 시간의 선박 위치 계산
const currentPositions = useAnimationStore.getState().getCurrentVesselPositions();
if (currentPositions.length === 0 && tracksRef.current.length === 0) {
return;
}
// position → lon/lat 변환 (createVirtualShipLayers 형식에 맞춤)
const formattedPositions = currentPositions.map((pos) => ({
vesselId: pos.vesselId,
lon: pos.position[0],
@ -228,49 +271,40 @@ export default function useReplayLayer() {
// 트랙 필터링
const filteredTracks = filterTracks(tracksRef.current);
// ===== 항적표시 기능 (playbackTrailStore) =====
const trailStore = usePlaybackTrailStore.getState();
const layers = [];
// 항적표시가 활성화되어 있으면 프레임 기록 (shipKindCode 포함)
if (trailStore.isEnabled && iconPositions.length > 0) {
trailStore.recordFrame(
iconPositions.map((pos) => ({
vesselId: pos.vesselId,
lon: pos.lon,
lat: pos.lat,
shipKindCode: pos.shipKindCode,
}))
// ===== TripsLayer 궤적 표시 =====
if (isTrailEnabled && tripsDataRef.current.length > 0) {
// 아이콘(CUSTOM 필터)에 표시되는 선박만 궤적 표시
// (선종 필터 + 선박 상태 필터가 이미 iconPositions에 적용됨)
const iconVesselIds = new Set(iconPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId)
);
}
// 항적표시 레이어 생성 (가장 먼저 추가 - 맨 아래 레이어)
if (trailStore.isEnabled) {
const trailPoints = trailStore.getVisiblePoints();
if (trailPoints.length > 0) {
const trailLayer = new ScatterplotLayer({
id: 'replay-playback-trail',
data: trailPoints,
pickable: false,
stroked: false,
filled: true,
radiusUnits: 'pixels',
getPosition: (d) => [d.lon, d.lat],
getRadius: (d) => trailStore.getPointSize(d.frameIndex),
getFillColor: (d) => {
const opacity = trailStore.getOpacity(d.frameIndex);
return [60, 60, 60, Math.floor(opacity * 180)]; // 회색 점, 투명도 0-180
},
updateTriggers: {
getRadius: [trailStore.frameIndex],
getFillColor: [trailStore.frameIndex],
},
if (filteredTripsData.length > 0) {
const relativeCurrentTime = useAnimationStore.getState().currentTime - startTimeRef.current;
const tripsLayer = new TripsLayer({
id: 'replay-trips-trail',
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
jointRounded: true,
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: relativeCurrentTime,
});
layers.push(trailLayer);
layers.push(tripsLayer);
}
}
// 현재 하이라이트 상태 가져오기
// 현재 하이라이트 상태
const currentHighlightedVesselId = useReplayStore.getState().highlightedVesselId;
// 정적 레이어 재생성 (필터 적용 + 하이라이트)
@ -290,7 +324,7 @@ export default function useReplayLayer() {
onPathHover: handlePathHover,
});
// 라벨 레이어 별도 생성 (라벨 필터 적용)
// 라벨 레이어 별도 생성
const labelLayers = createVirtualShipLayers({
currentPositions: labelPositions,
showVirtualShip: false,
@ -299,31 +333,18 @@ export default function useReplayLayer() {
registerReplayLayers([...layers, ...staticLayers, ...dynamicLayers, ...labelLayers]);
shipBatchRenderer.immediateRender();
}, [filterTracks, filterPositions, handlePathHover, handleIconHover]);
}, [filterTracks, filterPositions, handlePathHover, handleIconHover, isTrailEnabled]);
/**
* 쿼리 완료 라이브 선박 숨기기 + 정적 레이어 생성 + 초기 위치 렌더
* tracks + tripsData 빌드 함수 (queryCompleted 또는 청크 추가 호출)
*/
useEffect(() => {
if (!queryCompleted) {
// queryCompleted가 false로 바뀌면 리플레이 종료 → 클린업
unregisterReplayLayers();
staticLayersRef.current = [];
tracksRef.current = [];
showLiveShips();
shipBatchRenderer.immediateRender();
return;
}
// 리플레이 모드 시작 → 라이브 선박 숨기기
hideLiveShips();
const vesselChunks = useMergedTrackStore.getState().vesselChunks;
if (vesselChunks.size === 0) return;
const rebuildTracksAndTripsData = useCallback(() => {
const chunks = useMergedTrackStore.getState().vesselChunks;
if (chunks.size === 0) return;
// vesselChunks → tracks 배열 변환
const tracks = [];
vesselChunks.forEach((vc, vesselId) => {
chunks.forEach((vc, vesselId) => {
const path = useMergedTrackStore.getState().getMergedPath(vesselId);
if (!path || path.geometry.length < 2) return;
@ -344,12 +365,71 @@ export default function useReplayLayer() {
// 시간 범위 업데이트 (animationStore)
useAnimationStore.getState().updateTimeRange();
// 초기 렌더링
requestAnimatedRender();
}, [queryCompleted, requestAnimatedRender]);
// TripsLayer 데이터 준비: startTime 기준 상대 타임스탬프 (32비트 float 안전)
const { startTime, endTime } = useAnimationStore.getState();
startTimeRef.current = startTime;
// 조회 기간 (일) 계산 — 조건 1의 동적 임계값에 사용
const queryDays = Math.max(1, Math.ceil((endTime - startTime) / (24 * 60 * 60 * 1000)));
// TripsLayer 데이터 생성 — 포인트 제거 없이 전체 경로 사용
// vesselId당 1개 항목
const tripsData = [];
const outlierVesselIds = []; // 이상치 선박 (삭제 그룹 대상)
tracks.forEach((track) => {
if (track.geometry.length < 2) return;
// 이상치 판별
if (isOutlierVessel(track.geometry, track.timestampsMs, queryDays)) {
outlierVesselIds.push(track.vesselId);
}
tripsData.push({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - startTime),
});
});
tripsDataRef.current = tripsData;
// 이상치 선박을 '삭제' 그룹으로 자동 설정
if (outlierVesselIds.length > 0) {
const { setVesselState } = useReplayStore.getState();
outlierVesselIds.forEach((vesselId) => {
setVesselState(vesselId, VesselState.DELETED);
});
}
}, []);
/**
* currentTime 변경 애니메이션 렌더링 (ReplayV2.tsx와 동일한 패턴)
* 쿼리 완료 라이브 선박 숨기기 + 데이터 빌드
*/
useEffect(() => {
if (!queryCompleted) {
unregisterReplayLayers();
staticLayersRef.current = [];
tracksRef.current = [];
tripsDataRef.current = [];
startTimeRef.current = 0;
showLiveShips();
shipBatchRenderer.immediateRender();
return;
}
// 리플레이 모드 시작 → 라이브 선박 숨기기
hideLiveShips();
rebuildTracksAndTripsData();
requestAnimatedRender();
// 자동 재생 시작
useAnimationStore.getState().play();
}, [queryCompleted, requestAnimatedRender, rebuildTracksAndTripsData]);
/**
* currentTime 변경 애니메이션 렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
@ -357,26 +437,20 @@ export default function useReplayLayer() {
}, [currentTime, queryCompleted, requestAnimatedRender]);
/**
* 필터 변경 재렌더링 + 궤적 필터 동기화
* 필터 변경 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
// 선종 필터 OFF 시 해당 선종 궤적 즉시 제거
const trailStore = usePlaybackTrailStore.getState();
if (trailStore.isEnabled && trailStore.trails.size > 0) {
trailStore.removeTrailsByFilter(shipKindCodeFilter);
}
requestAnimatedRender();
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);
/**
* 배속 변경 항적 maxFrames 업데이트
* 항적표시 토글 변경 재렌더링
*/
useEffect(() => {
usePlaybackTrailStore.getState().updatePlaybackSpeed(playbackSpeed);
}, [playbackSpeed]);
if (!queryCompleted) return;
requestAnimatedRender();
}, [isTrailEnabled, queryCompleted, requestAnimatedRender]);
/**
* 하이라이트 변경 레이어 재렌더링
@ -398,7 +472,6 @@ export default function useReplayLayer() {
const { vesselStates, deletedVesselIds, selectedVesselIds, setVesselState } = useReplayStore.getState();
// 현재 상태 확인
let currentState = VesselState.NORMAL;
if (vesselStates.has(currentHighlightedId)) {
currentState = vesselStates.get(currentHighlightedId);
@ -411,14 +484,12 @@ export default function useReplayLayer() {
let targetState = null;
if (event.key === 'Delete') {
// Delete 키: NORMAL/SELECTED → DELETED, DELETED → NORMAL
if (currentState === VesselState.DELETED) {
targetState = VesselState.NORMAL;
} else {
targetState = VesselState.DELETED;
}
} else if (event.key === 'Insert') {
// Insert 키: NORMAL → SELECTED, SELECTED → NORMAL, DELETED → SELECTED
if (currentState === VesselState.DELETED) {
targetState = VesselState.SELECTED;
} else if (currentState === VesselState.SELECTED) {
@ -430,9 +501,7 @@ export default function useReplayLayer() {
if (targetState !== null) {
setVesselState(currentHighlightedId, targetState);
// 하이라이트 해제
useReplayStore.getState().setHighlightedVesselId(null);
// 레이어 재렌더링
requestAnimatedRender();
}
};
@ -451,6 +520,7 @@ export default function useReplayLayer() {
unregisterReplayLayers();
staticLayersRef.current = [];
tracksRef.current = [];
tripsDataRef.current = [];
showLiveShips();
shipBatchRenderer.immediateRender();
};

파일 보기

@ -83,7 +83,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
currentTime: 0,
startTime: 0,
endTime: 0,
playbackSpeed: 1,
playbackSpeed: 500,
loop: false,
loopEnabled: false, // 레거시 호환
@ -367,7 +367,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
isPlaying: false,
playbackState: PlaybackState.IDLE,
currentTime: 0,
playbackSpeed: 1,
playbackSpeed: 500,
startTime: 0,
endTime: 0,
animationFrameId: null,

파일 보기

@ -48,7 +48,7 @@ const usePlaybackTrailStore = create(
// ========== 상태 ==========
/** 항적표시 토글 상태 */
isEnabled: false,
isEnabled: true,
/** 선박별 항적 포인트 Map (vesselId -> TrailPoint[]) */
trails: new Map(),

파일 보기

@ -278,8 +278,8 @@ const useReplayStore = create(
// mergedTrackStore 클리어
useMergedTrackStore.getState().clear();
// playbackTrailStore 초기화 (setEnabled(false)가 trails와 frameIndex도 클리어)
usePlaybackTrailStore.getState().setEnabled(false);
// playbackTrailStore 데이터만 초기화 (토글 상태는 유지)
usePlaybackTrailStore.getState().clearTrails();
set({
currentQuery: null,

3650
yarn.lock Normal file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff