- 패널에 시작/종료 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>
246 lines
7.4 KiB
TypeScript
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,
|
|
}));
|
|
},
|
|
}));
|