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:
부모
19b2cff39e
커밋
346e5cdcc7
@ -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));
|
||||
|
||||
// 항적표시 레이어 생성 (가장 먼저 추가 - 맨 아래 레이어)
|
||||
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],
|
||||
},
|
||||
const filteredTripsData = tripsDataRef.current.filter(
|
||||
(d) => iconVesselIds.has(d.vesselId)
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user