gc-wing/apps/web/src/features/trackReplay/stores/trackQueryStore.ts
htlee 85dc7146be feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기
- 패널에 시작/종료 datetime-local 입력 + 재조회 버튼 추가
- TrackQueryContext에 legacy 메타데이터(업종/소유주/허가번호 등) 포함
- CSV 다운로드: points(포인트별 lon/lat/timestamp/speedKnots) + vessel(선박 메타)
- speed는 haversine 거리/시간 기반 계산값(knots)
- trackQueryService에 queryTrackByDateRange 추가
- 패널 체크박스 정리: 가상선박/반복 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 03:00:41 +09:00

246 lines
7.4 KiB
TypeScript

import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types';
import { queryTrackByDateRange } from '../services/trackQueryService';
import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
interface TrackQueryState {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
highlightedVesselId: string | null;
isLoading: boolean;
error: string | null;
queryState: TrackQueryStatus;
renderEpoch: number;
lastQueryKey: string | null;
queryContext: TrackQueryContext | null;
showPoints: boolean;
showVirtualShip: boolean;
showLabels: boolean;
showTrail: boolean;
hideLiveShips: boolean;
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
requery: (startTimeIso: string, endTimeIso: string) => Promise<void>;
setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setHighlightedVesselId: (vesselId: string | null) => void;
setShowPoints: (show: boolean) => void;
setShowVirtualShip: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
setShowTrail: (show: boolean) => void;
setHideLiveShips: (hide: boolean) => void;
toggleVesselEnabled: (vesselId: string) => void;
getEnabledTracks: () => ProcessedTrack[];
reset: () => void;
}
export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: 0,
lastQueryKey: null,
queryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
beginQuery: (queryKey: string, context?: TrackQueryContext) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: false,
queryContext: context ?? state.queryContext,
}));
},
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
return;
}
const range = getTracksTimeRange(tracks);
const playback = useTrackPlaybackStore.getState();
if (range) {
playback.setTimeRange(range.start, range.end);
playback.syncToRangeStart();
playback.setPlaybackSpeed(100);
} else {
playback.reset();
}
set((state) => ({
tracks,
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'ready',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
}));
if (range) {
if (typeof window !== 'undefined') {
window.requestAnimationFrame(() => {
useTrackPlaybackStore.getState().play();
});
} else {
useTrackPlaybackStore.getState().play();
}
}
},
applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) {
return;
}
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error,
queryState: 'error',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey ?? state.lastQueryKey,
hideLiveShips: false,
}));
},
closeQuery: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
hideLiveShips: false,
}));
},
requery: async (startTimeIso: string, endTimeIso: string) => {
const ctx = get().queryContext;
if (!ctx) return;
const queryKey = `requery:${ctx.mmsi}:${Date.now()}`;
const updatedContext: TrackQueryContext = { ...ctx, startTimeIso, endTimeIso };
get().beginQuery(queryKey, updatedContext);
try {
const tracks = await queryTrackByDateRange({
mmsi: updatedContext.mmsi,
startTimeIso: updatedContext.startTimeIso,
endTimeIso: updatedContext.endTimeIso,
shipNameHint: updatedContext.shipNameHint,
shipKindCodeHint: updatedContext.shipKindCodeHint,
nationalCodeHint: updatedContext.nationalCodeHint,
isPermitted: updatedContext.isPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey);
},
clearTracks: () => {
get().closeQuery();
},
setLoading: (loading: boolean) =>
set((state) => ({
isLoading: loading,
queryState: loading ? 'loading' : state.error ? 'error' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setError: (error: string | null) =>
set((state) => ({
error,
queryState: error ? 'error' : state.isLoading ? 'loading' : state.tracks.length > 0 ? 'ready' : 'idle',
})),
setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }),
setShowPoints: (show: boolean) => set({ showPoints: show }),
setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }),
setShowLabels: (show: boolean) => set({ showLabels: show }),
setShowTrail: (show: boolean) => set({ showTrail: show }),
setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }),
toggleVesselEnabled: (vesselId: string) => {
const next = new Set(get().disabledVesselIds);
if (next.has(vesselId)) next.delete(vesselId);
else next.add(vesselId);
set((state) => ({
disabledVesselIds: next,
renderEpoch: state.renderEpoch + 1,
}));
},
getEnabledTracks: () => {
const { tracks, disabledVesselIds } = get();
if (disabledVesselIds.size === 0) return tracks;
return tracks.filter((track) => !disabledVesselIds.has(track.vesselId));
},
reset: () => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: false,
error: null,
queryState: 'idle',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
showTrail: true,
hideLiveShips: false,
}));
},
}));