diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index e218375..aa53c16 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -19,3 +19,4 @@ @import "./styles/components/weather.css"; @import "./styles/components/weather-overlay.css"; @import "./styles/components/announcement.css"; +@import "./styles/components/vessel-select-modal.css"; diff --git a/apps/web/src/app/styles/components/vessel-select-modal.css b/apps/web/src/app/styles/components/vessel-select-modal.css new file mode 100644 index 0000000..e540aad --- /dev/null +++ b/apps/web/src/app/styles/components/vessel-select-modal.css @@ -0,0 +1,152 @@ +/* ── Vessel select modal ─────────────────────────────────────────── */ + +.vessel-select-modal { + position: fixed; + inset: 0; + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.vessel-select-modal__content { + background: rgba(15, 23, 42, 0.96); + backdrop-filter: blur(12px); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 12px; + color: #e2e8f0; + width: 95vw; + max-width: 720px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(2, 6, 23, 0.6); + overflow: hidden; +} + +.vessel-select-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + font-size: 14px; + flex-shrink: 0; +} + +.vessel-select-modal__close { + background: none; + border: none; + color: #94a3b8; + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; +} + +.vessel-select-modal__close:hover { + color: #e2e8f0; +} + +.vessel-select-modal__back { + background: transparent; + border: none; + color: #94a3b8; + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + line-height: 1; +} + +.vessel-select-modal__back:hover { + color: #e2e8f0; +} + +.vessel-select-modal__filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 10px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.1); + flex-shrink: 0; +} + +.vessel-select-modal__search { + padding: 8px 16px; + flex-shrink: 0; +} + +.vessel-select-modal__grid { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 16px; +} + +.vessel-select-modal__grid::-webkit-scrollbar { + width: 4px; +} + +.vessel-select-modal__grid::-webkit-scrollbar-track { + background: transparent; +} + +.vessel-select-modal__grid::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.3); + border-radius: 2px; +} + +.vessel-select-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border-top: 1px solid rgba(148, 163, 184, 0.15); + flex-shrink: 0; + font-size: 12px; +} + +.vessel-select-modal__body { + padding: 16px; + flex: 1; + overflow-y: auto; +} + +.vessel-select-modal__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.vessel-select-modal__chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + background: rgba(148, 163, 184, 0.12); + border-radius: 999px; + font-size: 11px; + color: #cbd5e1; +} + +.vessel-select-modal__presets { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.vessel-select-modal input[type='datetime-local'] { + flex: 1; + font-size: 12px; + padding: 6px 10px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(30, 41, 59, 0.8); + color: #e2e8f0; + color-scheme: dark; +} diff --git a/apps/web/src/entities/legacyVessel/lib/index.ts b/apps/web/src/entities/legacyVessel/lib/index.ts index b1eb02f..ea12343 100644 --- a/apps/web/src/entities/legacyVessel/lib/index.ts +++ b/apps/web/src/entities/legacyVessel/lib/index.ts @@ -31,14 +31,14 @@ export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesse if (score(v) > score(prev)) byName.set(k, v); } + if (typeof v.mmsi === 'number' && Number.isFinite(v.mmsi)) { + const prev = byMmsi.get(v.mmsi); + if (!prev || score(v) > score(prev)) byMmsi.set(v.mmsi, v); + } for (const m of v.mmsiList || []) { if (!Number.isFinite(m)) continue; - const prev = byMmsi.get(m); - if (!prev) { - byMmsi.set(m, v); - continue; - } - if (score(v) > score(prev)) byMmsi.set(m, v); + if (byMmsi.has(m)) continue; + byMmsi.set(m, v); } } @@ -57,19 +57,6 @@ export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): L const hit = idx.byMmsi.get(mmsi); if (hit) return hit; } - - const nameKey = t.name ? normalizeShipName(t.name) : ""; - if (nameKey) { - const hit = idx.byName.get(nameKey); - if (hit) return hit; - } - - const csKey = t.callsign ? normalizeShipName(t.callsign) : ""; - if (csKey) { - const hit = idx.byName.get(csKey); - if (hit) return hit; - } - return null; } diff --git a/apps/web/src/entities/legacyVessel/model/types.ts b/apps/web/src/entities/legacyVessel/model/types.ts index c998342..b9ae5fb 100644 --- a/apps/web/src/entities/legacyVessel/model/types.ts +++ b/apps/web/src/entities/legacyVessel/model/types.ts @@ -11,6 +11,7 @@ export type LegacyVesselInfo = { shipCode: string; // PT | PT-S | GN | OT | PS | FC | ... ton: number | null; callSign: string; + mmsi: number | null; mmsiList: number[]; workSeaArea: string; workTerm1: string; diff --git a/apps/web/src/features/announcement/data/announcements.ts b/apps/web/src/features/announcement/data/announcements.ts index 5582f93..ed1e4d2 100644 --- a/apps/web/src/features/announcement/data/announcements.ts +++ b/apps/web/src/features/announcement/data/announcements.ts @@ -28,6 +28,33 @@ export const ANNOUNCEMENTS: Announcement[] = [ }, ], }, + { + id: 2, + title: 'Wing Fleet Dashboard 업데이트', + date: '2026-03-08', + items: [ + { + icon: '🎯', + title: '대상선박 다중항적 조회', + description: '상단 "대상 선박 선택" 버튼으로 최대 20척을 한번에 선택하여 항적을 조회할 수 있습니다. 업종·상태 필터, 검색, 드래그 범위 선택을 지원합니다.', + }, + { + icon: '📅', + title: '조회 기간 프리셋', + description: '7일·14일·21일·28일 프리셋 버튼으로 기간을 빠르게 설정할 수 있습니다. 최대 조회 범위는 28일이며 초과 시 자동 조정됩니다.', + }, + { + icon: '🔄', + title: '항적 재조회 및 선박 목록 토글', + description: '리플레이 패널에서 기간을 수정하여 재조회하거나 CSV로 내보낼 수 있습니다. "선박 목록" 버튼으로 선택 화면을 열고 닫을 수 있습니다.', + }, + { + icon: '🔔', + title: '경고 표시 개선', + description: '실시간 경고 효과가 테두리 링 형태로 변경되어 선박 아이콘과의 가독성이 향상되었습니다.', + }, + ], + }, ]; /** 현재 최신 공지 ID */ diff --git a/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts index c83477d..035c13d 100644 --- a/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts +++ b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts @@ -58,6 +58,7 @@ function makeLegacy( shipNameCn: null, ton: 100, callSign: '', + mmsi: o.mmsiList[0] ?? null, workSeaArea: '서해', workTerm1: '2025-01-01', workTerm2: '2025-12-31', diff --git a/apps/web/src/features/trackReplay/lib/csvExport.ts b/apps/web/src/features/trackReplay/lib/csvExport.ts index be021cd..159a33c 100644 --- a/apps/web/src/features/trackReplay/lib/csvExport.ts +++ b/apps/web/src/features/trackReplay/lib/csvExport.ts @@ -1,6 +1,6 @@ import { DISPLAY_TZ } from '../../../shared/lib/datetime'; import { haversineNm } from '../../../shared/lib/geo/haversineNm'; -import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; +import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types'; const BOM = '\uFEFF'; @@ -31,14 +31,14 @@ function calcSpeedKnots(track: ProcessedTrack, index: number): number { } /** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */ -export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { +export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string { const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots']; const rows: string[] = [header.join(',')]; const mmsi = ctx?.mmsi ?? ''; for (const track of tracks) { - const trackMmsi = mmsi || track.targetId; + const trackMmsi = multiCtx ? track.targetId : (mmsi || track.targetId); for (let i = 0; i < track.geometry.length; i++) { rows.push( [ @@ -57,7 +57,7 @@ export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext } /** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */ -export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { +export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string { const header = [ 'mmsi', 'shipName', @@ -83,23 +83,29 @@ export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext ]; const rows: string[] = [header.join(',')]; + // Build per-mmsi lookup for multi-vessel mode + const multiVesselMap = multiCtx + ? new Map(multiCtx.vessels.map((v) => [String(v.mmsi), v])) + : null; + for (const track of tracks) { const firstTs = track.timestampsMs[0] ?? 0; const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0; const info = track.chnPrmShipInfo; + const mv = multiVesselMap?.get(track.targetId); rows.push( [ - escCsv(ctx?.mmsi ?? track.targetId), + escCsv(mv?.mmsi ?? ctx?.mmsi ?? track.targetId), escCsv(track.shipName), - escCsv(ctx?.vesselType ?? ''), - escCsv(ctx?.ownerCn), - escCsv(ctx?.ownerRoman), - escCsv(ctx?.permitNo), - escCsv(ctx?.pairPermitNo), - escCsv(ctx?.ton), - escCsv(ctx?.callSign), - escCsv(ctx?.workSeaArea), + escCsv(mv?.vesselType ?? ctx?.vesselType ?? ''), + escCsv(mv?.ownerCn ?? ctx?.ownerCn), + escCsv(mv?.ownerRoman ?? ctx?.ownerRoman), + escCsv(mv?.permitNo ?? ctx?.permitNo), + escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo), + escCsv(mv?.ton ?? ctx?.ton), + escCsv(mv?.callSign ?? ctx?.callSign), + escCsv(mv?.workSeaArea ?? ctx?.workSeaArea), escCsv(track.nationalCode), escCsv(track.stats.totalDistanceNm), escCsv(track.stats.avgSpeed), @@ -130,12 +136,12 @@ function downloadCsv(csvContent: string, filename: string): void { URL.revokeObjectURL(url); } -export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): void { +export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void { const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); - downloadCsv(buildDynamicCsv(tracks, ctx), `track-points-${ts}.csv`); + downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`); setTimeout(() => { - downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`); + downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`); }, 100); } diff --git a/apps/web/src/features/trackReplay/model/track.types.ts b/apps/web/src/features/trackReplay/model/track.types.ts index da36d20..3a8e5b3 100644 --- a/apps/web/src/features/trackReplay/model/track.types.ts +++ b/apps/web/src/features/trackReplay/model/track.types.ts @@ -64,6 +64,25 @@ export interface TrackQueryContext { workSeaArea?: string; } +export interface MultiTrackQueryContext { + vessels: Array<{ + mmsi: number; + shipNameHint?: string; + isPermitted: boolean; + vesselType?: string; + ownerCn?: string | null; + ownerRoman?: string | null; + permitNo?: string; + pairPermitNo?: string | null; + ton?: number | null; + callSign?: string; + workSeaArea?: string; + shipCode?: string; + }>; + startTimeIso: string; + endTimeIso: string; +} + export interface ReplayStreamQueryRequest { startTime: string; endTime: string; diff --git a/apps/web/src/features/trackReplay/services/trackQueryService.ts b/apps/web/src/features/trackReplay/services/trackQueryService.ts index 1b1ac30..d7d4c4b 100644 --- a/apps/web/src/features/trackReplay/services/trackQueryService.ts +++ b/apps/web/src/features/trackReplay/services/trackQueryService.ts @@ -120,16 +120,16 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] { async function fetchV2Tracks( startTimeIso: string, endTimeIso: string, - mmsi: number, - isPermitted: boolean, + mmsis: number[], + hasPermitted: boolean, ): Promise { const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim(); const requestBody = { startTime: startTimeIso, endTime: endTimeIso, - vessels: [String(mmsi)], - includeChnPrmShip: isPermitted, + vessels: mmsis.map(String), + includeChnPrmShip: hasPermitted, }; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; @@ -156,9 +156,20 @@ async function fetchV2Tracks( export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise { const end = new Date(); const start = new Date(end.getTime() - params.minutes * 60_000); - return fetchV2Tracks(start.toISOString(), end.toISOString(), params.mmsi, params.isPermitted ?? false); + return fetchV2Tracks(start.toISOString(), end.toISOString(), [params.mmsi], params.isPermitted ?? false); } export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise { - return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false); + return fetchV2Tracks(params.startTimeIso, params.endTimeIso, [params.mmsi], params.isPermitted ?? false); +} + +export type QueryMultiTrackParams = { + mmsis: number[]; + startTimeIso: string; + endTimeIso: string; + hasPermitted: boolean; +}; + +export async function queryMultiTrack(params: QueryMultiTrackParams): Promise { + return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsis, params.hasPermitted); } diff --git a/apps/web/src/features/trackReplay/stores/trackQueryStore.ts b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts index 34e100e..4b3792a 100644 --- a/apps/web/src/features/trackReplay/stores/trackQueryStore.ts +++ b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { getTracksTimeRange } from '../lib/adapters'; -import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; -import { queryTrackByDateRange } from '../services/trackQueryService'; +import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types'; +import { queryMultiTrack, queryTrackByDateRange } from '../services/trackQueryService'; import { useTrackPlaybackStore } from './trackPlaybackStore'; export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -16,6 +16,7 @@ interface TrackQueryState { renderEpoch: number; lastQueryKey: string | null; queryContext: TrackQueryContext | null; + multiQueryContext: MultiTrackQueryContext | null; showPoints: boolean; showVirtualShip: boolean; showLabels: boolean; @@ -23,6 +24,7 @@ interface TrackQueryState { hideLiveShips: boolean; beginQuery: (queryKey: string, context?: TrackQueryContext) => void; + beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise; applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void; applyQueryError: (error: string, queryKey?: string | null) => void; closeQuery: () => void; @@ -53,6 +55,7 @@ export const useTrackQueryStore = create()((set, get) => ({ renderEpoch: 0, lastQueryKey: null, queryContext: null, + multiQueryContext: null, showPoints: true, showVirtualShip: true, showLabels: true, @@ -70,11 +73,48 @@ export const useTrackQueryStore = create()((set, get) => ({ queryState: 'loading', renderEpoch: state.renderEpoch + 1, lastQueryKey: queryKey, - hideLiveShips: false, + hideLiveShips: true, queryContext: context ?? state.queryContext, + multiQueryContext: null, })); }, + beginMultiQuery: async (queryKey: string, ctx: MultiTrackQueryContext) => { + useTrackPlaybackStore.getState().reset(); + set((state) => ({ + tracks: [], + disabledVesselIds: new Set(), + highlightedVesselId: null, + isLoading: true, + error: null, + queryState: 'loading', + renderEpoch: state.renderEpoch + 1, + lastQueryKey: queryKey, + hideLiveShips: true, + queryContext: null, + multiQueryContext: ctx, + })); + + try { + const mmsis = ctx.vessels.map((v) => v.mmsi); + const hasPermitted = ctx.vessels.some((v) => v.isPermitted); + const tracks = await queryMultiTrack({ + mmsis, + startTimeIso: ctx.startTimeIso, + endTimeIso: ctx.endTimeIso, + hasPermitted, + }); + + if (tracks.length > 0) { + get().applyTracksSuccess(tracks, queryKey); + } else { + get().applyQueryError('항적 데이터가 없습니다.', queryKey); + } + } catch (e) { + get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); + } + }, + applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => { const currentQueryKey = get().lastQueryKey; if (queryKey != null && queryKey !== currentQueryKey) { @@ -146,11 +186,21 @@ export const useTrackQueryStore = create()((set, get) => ({ renderEpoch: state.renderEpoch + 1, lastQueryKey: null, queryContext: null, + multiQueryContext: null, hideLiveShips: false, })); }, requery: async (startTimeIso: string, endTimeIso: string) => { + const multiCtx = get().multiQueryContext; + if (multiCtx) { + const queryKey = `requery-multi:${Date.now()}`; + const updatedCtx: MultiTrackQueryContext = { ...multiCtx, startTimeIso, endTimeIso }; + // Preserve multiQueryContext across beginMultiQuery + await get().beginMultiQuery(queryKey, updatedCtx); + return; + } + const ctx = get().queryContext; if (!ctx) return; @@ -235,6 +285,7 @@ export const useTrackQueryStore = create()((set, get) => ({ renderEpoch: state.renderEpoch + 1, lastQueryKey: null, queryContext: null, + multiQueryContext: null, showPoints: true, showVirtualShip: true, showLabels: true, diff --git a/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts new file mode 100644 index 0000000..6e5e7b5 --- /dev/null +++ b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts @@ -0,0 +1,273 @@ +import { useCallback, useState } from 'react'; +import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types'; +import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types'; +import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore'; +import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types'; + +/** ms → datetime-local input value (KST = UTC+9) */ +function toDateTimeLocalKST(ms: number): string { + const kstDate = new Date(ms + 9 * 3600_000); + return kstDate.toISOString().slice(0, 16); +} + +/** datetime-local value (KST) → ISO string */ +function fromDateTimeLocalKST(value: string): string { + return `${value}:00+09:00`; +} + +const DEFAULT_DAYS = 7; + +export interface VesselSelectModalState { + isOpen: boolean; + open: () => void; + reopen: () => void; + close: () => void; + + selectedMmsis: Set; + toggleMmsi: (mmsi: number) => void; + setMmsis: (mmsis: Set) => void; + selectAllFiltered: (filtered: DerivedLegacyVessel[]) => void; + clearAll: () => void; + + searchQuery: string; + setSearchQuery: (q: string) => void; + + shipCodeFilter: Set; + toggleShipCode: (code: string) => void; + toggleAllShipCodes: (allCodes: string[]) => void; + + onlySailing: boolean; + setOnlySailing: (v: boolean) => void; + + stateFilter: Set; + toggleStateFilter: (label: string) => void; + toggleAllStates: (allLabels: string[]) => void; + + startTime: string; + endTime: string; + setStartTime: (v: string) => void; + setEndTime: (v: string) => void; + applyPresetDays: (hours: number) => void; + + isQuerying: boolean; + submitQuery: (allVessels: DerivedLegacyVessel[]) => void; + + position: { x: number; y: number }; + setPosition: (pos: { x: number; y: number }) => void; + + selectionWarning: string | null; +} + +export function useVesselSelectModal(): VesselSelectModalState { + const [isOpen, setIsOpen] = useState(false); + const [selectedMmsis, setSelectedMmsis] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [shipCodeFilter, setShipCodeFilter] = useState>(new Set()); + const [onlySailing, setOnlySailing] = useState(false); + const [stateFilter, setStateFilter] = useState>(new Set()); + const [selectionWarning, setSelectionWarning] = useState(null); + const [isQuerying, setIsQuerying] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + const [startTime, setStartTime] = useState(() => { + const n = Date.now(); + return toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000); + }); + const [endTime, setEndTime] = useState(() => toDateTimeLocalKST(Date.now())); + + const open = useCallback(() => { + setIsOpen(true); + setSelectedMmsis(new Set()); + setSearchQuery(''); + setShipCodeFilter(new Set()); + setOnlySailing(false); + setStateFilter(new Set()); + setSelectionWarning(null); + setIsQuerying(false); + setPosition({ x: 0, y: 0 }); + const n = Date.now(); + setStartTime(toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000)); + setEndTime(toDateTimeLocalKST(n)); + }, []); + + const reopen = useCallback(() => setIsOpen(true), []); + + const close = useCallback(() => setIsOpen(false), []); + + const toggleMmsi = useCallback((mmsi: number) => { + setSelectedMmsis((prev) => { + const next = new Set(prev); + if (next.has(mmsi)) { + next.delete(mmsi); + setSelectionWarning(null); + } else { + if (next.size >= MAX_VESSEL_SELECT) { + setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`); + return prev; + } + next.add(mmsi); + setSelectionWarning(null); + } + return next; + }); + }, []); + + const setMmsis = useCallback((mmsis: Set) => { + if (mmsis.size > MAX_VESSEL_SELECT) { + setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`); + return; + } + setSelectedMmsis(mmsis); + setSelectionWarning(null); + }, []); + + const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => { + const capped = filtered.slice(0, MAX_VESSEL_SELECT); + setSelectedMmsis(new Set(capped.map((v) => v.mmsi))); + if (filtered.length > MAX_VESSEL_SELECT) { + setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다 (${filtered.length}척 중 ${MAX_VESSEL_SELECT}척 선택됨)`); + } else { + setSelectionWarning(null); + } + }, []); + + const clearAll = useCallback(() => { + setSelectedMmsis(new Set()); + setSelectionWarning(null); + }, []); + + const toggleShipCode = useCallback((code: string) => { + setShipCodeFilter((prev) => { + const next = new Set(prev); + if (next.has(code)) next.delete(code); + else next.add(code); + return next; + }); + }, []); + + const toggleAllShipCodes = useCallback((allCodes: string[]) => { + setShipCodeFilter((prev) => + prev.size === allCodes.length ? new Set() : new Set(allCodes), + ); + }, []); + + const toggleStateFilter = useCallback((label: string) => { + setStateFilter((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + }, []); + + const toggleAllStates = useCallback((allLabels: string[]) => { + setStateFilter((prev) => + prev.size === allLabels.length ? new Set() : new Set(allLabels), + ); + }, []); + + const applyPresetDays = useCallback( + (days: number) => { + const now = Date.now(); + const spanMs = days * 86_400_000; + + // datetime-local (KST) → ms (UTC) + const parseKST = (v: string) => new Date(`${v}:00+09:00`).getTime(); + const curStart = parseKST(startTime); + const curEnd = parseKST(endTime); + const curGap = curEnd - curStart; + + if (curGap > spanMs) { + // 현재 간격이 프리셋보다 넓으면 → 시작을 종료 기준으로 조정 + const cappedEnd = Math.min(curEnd, now); + setEndTime(toDateTimeLocalKST(cappedEnd)); + setStartTime(toDateTimeLocalKST(cappedEnd - spanMs)); + } else { + // 시작 기준으로 종료 확장, 종료 max = 현재 + const newEnd = Math.min(curStart + spanMs, now); + setEndTime(toDateTimeLocalKST(newEnd)); + // 종료가 clamp 되었으면 시작도 조정 + if (newEnd - curStart < spanMs) { + setStartTime(toDateTimeLocalKST(newEnd - spanMs)); + } + } + }, + [startTime, endTime], + ); + + const submitQuery = useCallback( + (allVessels: DerivedLegacyVessel[]) => { + const selected = allVessels.filter((v) => selectedMmsis.has(v.mmsi)); + if (selected.length === 0) return; + + const maxMs = MAX_QUERY_DAYS * 86_400_000; + const sMs = new Date(fromDateTimeLocalKST(startTime)).getTime(); + const eMs = new Date(fromDateTimeLocalKST(endTime)).getTime(); + if (eMs - sMs > maxMs) { + const clampedEnd = toDateTimeLocalKST(sMs + maxMs); + setEndTime(clampedEnd); + setSelectionWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`); + return; + } + + const vessels = selected.map((v) => ({ + mmsi: v.mmsi, + shipNameHint: v.name, + isPermitted: true, + vesselType: v.shipCode, + ownerCn: v.ownerCn, + ownerRoman: v.ownerRoman, + permitNo: v.permitNo, + pairPermitNo: v.pairPermitNo, + ton: v.legacy.ton, + callSign: v.callsign ?? undefined, + workSeaArea: v.workSeaArea ?? undefined, + shipCode: v.shipCode, + })); + + const ctx: MultiTrackQueryContext = { + vessels, + startTimeIso: fromDateTimeLocalKST(startTime), + endTimeIso: fromDateTimeLocalKST(endTime), + }; + + const queryKey = `multi:${selected.length}:${Date.now()}`; + useTrackQueryStore.getState().beginMultiQuery(queryKey, ctx); + setIsQuerying(true); + setIsOpen(false); + }, + [selectedMmsis, startTime, endTime], + ); + + return { + isOpen, + open, + reopen, + close, + selectedMmsis, + toggleMmsi, + setMmsis, + selectAllFiltered, + clearAll, + searchQuery, + setSearchQuery, + shipCodeFilter, + toggleShipCode, + toggleAllShipCodes, + onlySailing, + setOnlySailing, + stateFilter, + toggleStateFilter, + toggleAllStates, + startTime, + endTime, + setStartTime, + setEndTime, + applyPresetDays, + isQuerying, + submitQuery, + position, + setPosition, + selectionWarning, + }; +} diff --git a/apps/web/src/features/vesselSelect/index.ts b/apps/web/src/features/vesselSelect/index.ts new file mode 100644 index 0000000..a309c7d --- /dev/null +++ b/apps/web/src/features/vesselSelect/index.ts @@ -0,0 +1,3 @@ +export type { VesselDescriptor } from './model/types'; +export { MAX_VESSEL_SELECT } from './model/types'; +export { useVesselSelectModal } from './hooks/useVesselSelectModal'; diff --git a/apps/web/src/features/vesselSelect/model/types.ts b/apps/web/src/features/vesselSelect/model/types.ts new file mode 100644 index 0000000..a8f6c7c --- /dev/null +++ b/apps/web/src/features/vesselSelect/model/types.ts @@ -0,0 +1,19 @@ +export interface VesselDescriptor { + mmsi: number; + shipNameHint?: string; + shipKindCodeHint?: string; + nationalCodeHint?: string; + isPermitted: boolean; + vesselType?: string; + ownerCn?: string | null; + ownerRoman?: string | null; + permitNo?: string; + pairPermitNo?: string | null; + ton?: number | null; + callSign?: string; + workSeaArea?: string; + shipCode?: string; +} + +export const MAX_VESSEL_SELECT = 20; +export const MAX_QUERY_DAYS = 28; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index c56c551..32e34e8 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -31,6 +31,8 @@ import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlay import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel"; import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement"; +import { useVesselSelectModal } from "../../features/vesselSelect"; +import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal"; import { buildLegacyHitMap, computeCountsByType, @@ -67,6 +69,9 @@ export function DashboardPage() { // ── Announcement popup ── const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid); + // ── Vessel select modal (multi-track) ── + const vesselSelectModal = useVesselSelectModal(); + // ── Data fetching ── const { data: zones, error: zonesError } = useZones(); const { data: legacyData, error: legacyError } = useLegacyVessels(); @@ -351,6 +356,7 @@ export function DashboardPage() { onToggleTheme={toggleTheme} isSidebarOpen={isSidebarOpen} onMenuToggle={() => setIsSidebarOpen((v) => !v)} + onOpenMultiTrack={vesselSelectModal.open} /> - + { + if (vesselSelectModal.isOpen) vesselSelectModal.close(); + else vesselSelectModal.reopen(); + }} + /> )} + {vesselSelectModal.isOpen && ( + + )} {imageModal && ( ', +)}`; +const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const; + export function useDeckLayers( mapRef: MutableRefObject, @@ -308,30 +316,35 @@ export function useDeckLayers( const now = Date.now(); let updated = mercatorLayersRef.current; - // 1. 알람 맥동 (기존) + // 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션 if (hasAlarms) { const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2; - const normalR = 8 + tA * 6; - const hoverR = 12 + tA * 6; - const pulseLyr = new ScatterplotLayer({ + const pulseLyr = new IconLayer({ id: 'alarm-pulse', data: alarmTargets, pickable: false, - billboard: false, - filled: true, - stroked: false, - radiusUnits: 'pixels', - getRadius: (d) => { + billboard: true, + iconAtlas: ALARM_RING_ICON_URL, + iconMapping: ALARM_RING_ICON_MAPPING, + getIcon: () => 'ring', + sizeUnits: 'pixels', + sizeScale: 0.9 + tA * 0.2, + opacity: 0.2 + tA * 0.7, + getSize: (d) => { const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); - return isHover ? hoverR : normalR; + return isHover + ? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2 + : (FLAT_LEGACY_HALO_RADIUS + 8) * 2; }, - getFillColor: (d) => { + getColor: (d) => { const kind = alarmMmsiMap!.get(d.mmsi); - return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; + return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number]; }, getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { getRadius: [normalR, hoverR] }, + updateTriggers: { + getSize: [selectedMmsi], + }, }); updated = updated.map((l) => // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index 06fda54..6727a24 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -28,12 +28,41 @@ import { clampNumber } from '../lib/geometry'; import { guardedSetVisibility } from '../lib/layerHelpers'; // ── Alarm pulse animation constants ── -const ALARM_PULSE_R_MIN = 8; -const ALARM_PULSE_R_MAX = 14; -const ALARM_PULSE_R_HOVER_MIN = 12; -const ALARM_PULSE_R_HOVER_MAX = 18; +// Offset from outline radius so pulse ring never overlaps the outline stroke +const ALARM_PULSE_OFFSET_MIN = 5; // px offset from base circle at rest +const ALARM_PULSE_OFFSET_MAX = 11; // px offset at peak +const ALARM_PULSE_HOVER_OFFSET_MIN = 7; +const ALARM_PULSE_HOVER_OFFSET_MAX = 14; const ALARM_PULSE_PERIOD_MS = 1500; +// Base circle radii per zoom (from mlExpressions BASE_VALUES) +const BASE_R_BY_ZOOM = [ + [3, 4], [7, 6], [10, 8], [14, 12], [18, 32], +] as const; + +/** Build zoom-interpolated radius = base + offset for alarm pulse */ +function buildAlarmPulseRadiusExpr(offset: number) { + const stops: unknown[] = ['interpolate', ['linear'], ['zoom']]; + for (const [z, base] of BASE_R_BY_ZOOM) { + stops.push(z, base + offset); + } + return stops as never; +} + +/** Build zoom-interpolated radius with hover/normal case for alarm pulse */ +function buildAlarmPulseRadiusCaseExpr(normalOffset: number, hoverOffset: number) { + const stops: unknown[] = ['interpolate', ['linear'], ['zoom']]; + for (const [z, base] of BASE_R_BY_ZOOM) { + stops.push(z, [ + 'case', + ['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]], + base + hoverOffset, + base + normalOffset, + ]); + } + return stops as never; +} + /** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */ export function useGlobeShipLayers( mapRef: MutableRefObject, @@ -372,10 +401,12 @@ export function useGlobeShipLayers( filter: ['==', ['get', 'alarmed'], 1] as never, layout: { visibility }, paint: { - 'circle-radius': ALARM_PULSE_R_MIN, - 'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never, - 'circle-opacity': 0.35, - 'circle-stroke-width': 0, + 'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN), + 'circle-color': 'transparent', + 'circle-opacity': 0, + 'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never, + 'circle-stroke-opacity': 0.7, + 'circle-stroke-width': 1.5, }, } as unknown as LayerSpecification, before, @@ -701,16 +732,16 @@ export function useGlobeShipLayers( return; } const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2; - const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN); - const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN); + const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN); + const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN); try { if (map.getLayer('ships-globe-alarm-pulse')) { - map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [ - 'case', - ['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]], - hoverR, - normalR, - ] as never); + map.setPaintProperty( + 'ships-globe-alarm-pulse', + 'circle-radius', + buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff), + ); + map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5); } } catch { // ignore diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index ed11c1c..e266515 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -41,6 +41,11 @@ import { SPEED_THRESHOLD_KN, } from '../../../shared/lib/map/shipKind'; +/** 64×64 white ring SVG — mask:true로 getColor 틴트 적용 */ +const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent( + '', +)}`; + /* ── 공통 콜백 인터페이스 ─────────────────────────────── */ interface DeckHoverCallbacks { @@ -455,32 +460,33 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ } } - /* ─ alarm pulse + badge ─ */ + /* ─ alarm pulse (IconLayer + SVG ring) + badge ─ */ const alarmTargets = ctx.alarmTargets ?? []; const alarmMap = ctx.alarmMmsiMap; if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) { - const pulseR = ctx.alarmPulseRadius ?? 8; - const pulseHR = ctx.alarmPulseHoverRadius ?? 12; + const pulseSize = ctx.alarmPulseRadius ?? 40; + const pulseHoverSize = ctx.alarmPulseHoverRadius ?? 48; layers.push( - new ScatterplotLayer({ + new IconLayer({ id: 'alarm-pulse', data: alarmTargets, pickable: false, - billboard: false, + billboard: true, parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: 'pixels', - getRadius: (d) => { + iconAtlas: ALARM_RING_ICON_URL, + iconMapping: { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } }, + getIcon: () => 'ring', + sizeUnits: 'pixels', + getSize: (d) => { const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi); - return isHover ? pulseHR : pulseR; + return isHover ? pulseHoverSize : pulseSize; }, - getFillColor: (d) => { + getColor: (d) => { const kind = alarmMap.get(d.mmsi); - return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; + return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number]; }, getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] }, + updateTriggers: { getSize: [pulseSize, pulseHoverSize, ctx.selectedMmsi, ctx.shipHighlightSet] }, }), ); layers.push( diff --git a/apps/web/src/widgets/topbar/Topbar.tsx b/apps/web/src/widgets/topbar/Topbar.tsx index 6c1c151..37c0cb6 100644 --- a/apps/web/src/widgets/topbar/Topbar.tsx +++ b/apps/web/src/widgets/topbar/Topbar.tsx @@ -15,6 +15,7 @@ interface Props { onToggleTheme?: () => void; isSidebarOpen?: boolean; onMenuToggle?: () => void; + onOpenMultiTrack?: () => void; } function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick) { @@ -39,7 +40,7 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick + {onOpenMultiTrack && ( + + )} {/* 항상 표시: 시계 + 테마 + 사용자 */} diff --git a/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx index 8e67983..9df37b7 100644 --- a/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx +++ b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx @@ -1,7 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore'; import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport'; +import { SHIP_KIND_COLORS } from '../../shared/lib/map/shipKind'; +import type { ProcessedTrack } from '../../features/trackReplay/model/track.types'; +import { MAX_QUERY_DAYS } from '../../features/vesselSelect/model/types'; function formatDateTime(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '--'; @@ -12,14 +15,12 @@ function formatDateTime(ms: number): string { )}:${pad(date.getSeconds())}`; } -/** ms → datetime-local input value (KST = UTC+9) */ function toDateTimeLocalKST(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return ''; const kstDate = new Date(ms + 9 * 3600_000); return kstDate.toISOString().slice(0, 16); } -/** datetime-local value (KST) → ISO string */ function fromDateTimeLocalKST(value: string): string { return `${value}:00+09:00`; } @@ -44,46 +45,18 @@ const btnBase: React.CSSProperties = { cursor: 'pointer', }; -export function GlobalTrackReplayPanel() { - const PANEL_WIDTH = 420; - const PANEL_MARGIN = 12; - const PANEL_DEFAULT_TOP = 16; - const PANEL_RIGHT_RESERVED = 520; - - const panelRef = useRef(null); - const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>( - null, - ); - const [isDragging, setIsDragging] = useState(false); - - const clampPosition = useCallback( - (x: number, y: number) => { - if (typeof window === 'undefined') return { x, y }; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const panelHeight = panelRef.current?.offsetHeight ?? 360; - return { - x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)), - y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)), - }; - }, - [PANEL_MARGIN, PANEL_WIDTH], - ); - - const [position, setPosition] = useState(() => { - if (typeof window === 'undefined') { - return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP }; - } - return { - x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED), - y: PANEL_DEFAULT_TOP, - }; - }); +interface GlobalTrackReplayPanelProps { + isVesselListOpen?: boolean; + onToggleVesselList?: () => void; +} +export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }: GlobalTrackReplayPanelProps) { const tracks = useTrackQueryStore((state) => state.tracks); const isLoading = useTrackQueryStore((state) => state.isLoading); const error = useTrackQueryStore((state) => state.error); const queryContext = useTrackQueryStore((state) => state.queryContext); + const multiQueryContext = useTrackQueryStore((state) => state.multiQueryContext); + const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds); const showPoints = useTrackQueryStore((state) => state.showPoints); const showLabels = useTrackQueryStore((state) => state.showLabels); const showTrail = useTrackQueryStore((state) => state.showTrail); @@ -94,6 +67,7 @@ export function GlobalTrackReplayPanel() { const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips); const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery); const requery = useTrackQueryStore((state) => state.requery); + const toggleVesselEnabled = useTrackQueryStore((state) => state.toggleVesselEnabled); const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); const currentTime = useTrackPlaybackStore((state) => state.currentTime); @@ -120,92 +94,48 @@ export function GlobalTrackReplayPanel() { const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v })); const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v })); + const [requeryWarning, setRequeryWarning] = useState(null); const handleRequery = useCallback(() => { if (!editStartTime || !editEndTime) return; + const sMs = new Date(fromDateTimeLocalKST(editStartTime)).getTime(); + const eMs = new Date(fromDateTimeLocalKST(editEndTime)).getTime(); + const maxMs = MAX_QUERY_DAYS * 86_400_000; + if (eMs - sMs > maxMs) { + const clamped = toDateTimeLocalKST(sMs + maxMs); + setEditEndTime(clamped); + setRequeryWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`); + return; + } + setRequeryWarning(null); requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime)); }, [editStartTime, editEndTime, requery]); const handleExportCsv = useCallback(() => { if (tracks.length === 0) return; - exportTrackCsv(tracks, queryContext); - }, [tracks, queryContext]); + exportTrackCsv(tracks, queryContext, multiQueryContext); + }, [tracks, queryContext, multiQueryContext]); const progress = useMemo(() => { if (endTime <= startTime) return 0; return ((currentTime - startTime) / (endTime - startTime)) * 100; }, [startTime, endTime, currentTime]); + const isVisible = isLoading || tracks.length > 0 || !!error; - - useEffect(() => { - if (!isVisible) return; - if (typeof window === 'undefined') return; - const onResize = () => { - setPosition((prev) => clampPosition(prev.x, prev.y)); - }; - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, [clampPosition, isVisible]); - - useEffect(() => { - if (!isVisible) return; - const onPointerMove = (event: PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - setPosition(() => { - const nextX = drag.originX + (event.clientX - drag.startX); - const nextY = drag.originY + (event.clientY - drag.startY); - return clampPosition(nextX, nextY); - }); - }; - - const stopDrag = (event: PointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - dragRef.current = null; - setIsDragging(false); - }; - - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', stopDrag); - window.addEventListener('pointercancel', stopDrag); - - return () => { - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', stopDrag); - window.removeEventListener('pointercancel', stopDrag); - }; - }, [clampPosition, isVisible]); - - const handleHeaderPointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0) return; - dragRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: position.x, - originY: position.y, - }; - setIsDragging(true); - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - // ignore - } - }, - [position.x, position.y], - ); + const isMultiMode = multiQueryContext != null; + const vesselCount = tracks.length; + const [isVesselListExpanded, setIsVesselListExpanded] = useState(false); + const hasRequeryContext = isMultiMode || !!queryContext; if (!isVisible) return null; return (
{/* Header */} -
- Track Replay - +
+ + Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''} + +
+ {onToggleVesselList && ( + + )} + +
- {error ? ( -
{error}
- ) : null} - + {error ?
{error}
: null} + {requeryWarning ?
{requeryWarning}
: null} {isLoading ?
항적 조회 중...
: null} - {/* Vessel count */} -
- 선박 {tracks.length}척 -
+ {/* Vessel list — dynamic layout */} + {isMultiMode && vesselCount > 0 ? ( + setIsVesselListExpanded((v) => !v)} + /> + ) : vesselCount > 0 ? ( +
선박 {vesselCount}척
+ ) : null} {/* Date range editing */}
- setEditStartTime(e.target.value)} - disabled={isLoading || !queryContext} - style={inputStyle} - /> + setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
- setEditEndTime(e.target.value)} - disabled={isLoading || !queryContext} - style={inputStyle} - /> + setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
- -
@@ -321,40 +227,17 @@ export function GlobalTrackReplayPanel() { {/* Playback controls */}
- - @@ -362,15 +245,7 @@ export function GlobalTrackReplayPanel() { {/* Timeline slider */}
- setCurrentTime(Number(event.target.value))} - style={{ width: '100%' }} - disabled={tracks.length === 0 || endTime <= startTime} - /> + setCurrentTime(Number(event.target.value))} style={{ width: '100%' }} disabled={tracks.length === 0 || endTime <= startTime} />
{formatDateTime(currentTime)} {Math.max(0, Math.min(100, progress)).toFixed(1)}% @@ -379,24 +254,57 @@ export function GlobalTrackReplayPanel() { {/* Display toggles */}
- - - - + + + +
); } + +/* ── Vessel list sub-component ── */ + +interface VesselListSectionProps { + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + toggleVesselEnabled: (vesselId: string) => void; + vesselCount: number; + isExpanded: boolean; + onToggleExpand: () => void; +} + +function VesselListSection({ tracks, disabledVesselIds, toggleVesselEnabled, vesselCount, isExpanded, onToggleExpand }: VesselListSectionProps) { + const showExpandToggle = vesselCount >= 5; + const alwaysShow = vesselCount <= 4; + const isListVisible = alwaysShow || isExpanded; + + return ( +
+
+ 선박 {vesselCount}척 + {showExpandToggle && ( + + )} +
+ {isListVisible && ( +
+ {tracks.map((track) => { + const isEnabled = !disabledVesselIds.has(track.vesselId); + const kindColor = SHIP_KIND_COLORS[track.shipKindCode] || '#607D8B'; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx new file mode 100644 index 0000000..f47e3e0 --- /dev/null +++ b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect, useCallback, type CSSProperties } from 'react'; +import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; +import { VESSEL_TYPES } from '../../entities/vessel/model/meta'; + +interface VesselSelectGridProps { + vessels: DerivedLegacyVessel[]; + selectedMmsis: Set; + toggleMmsi: (mmsi: number) => void; + setMmsis: (mmsis: Set) => void; +} + +interface DragState { + startIdx: number; + endIdx: number; + direction: 'check' | 'uncheck'; +} + +const STYLE_TABLE: CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + fontSize: 11, +}; + +const STYLE_TH: CSSProperties = { + position: 'sticky', + top: 0, + background: 'rgba(15,23,42,0.98)', + color: '#94a3b8', + textAlign: 'left', + padding: '6px 8px', + borderBottom: '1px solid rgba(148,163,184,0.2)', + fontWeight: 500, +}; + +const STYLE_TH_CHECKBOX: CSSProperties = { + ...STYLE_TH, + width: 28, +}; + +function getTdStyle(isSelected: boolean): CSSProperties { + return { + padding: '5px 8px', + borderBottom: '1px solid rgba(148,163,184,0.08)', + cursor: 'pointer', + background: isSelected ? 'rgba(59,130,246,0.12)' : undefined, + }; +} + +function getStateBadgeStyle(isFishing: boolean, isTransit: boolean): CSSProperties { + const color = isFishing ? '#22C55E' : isTransit ? '#3B82F6' : '#64748B'; + return { + background: `${color}22`, + color, + borderRadius: 3, + padding: '1px 4px', + fontSize: 10, + }; +} + +function getDotStyle(color: string): CSSProperties { + return { + display: 'inline-block', + width: 8, + height: 8, + borderRadius: '50%', + background: color, + marginRight: 4, + verticalAlign: 'middle', + }; +} + +function isInDragRange(idx: number, drag: DragState): boolean { + const min = Math.min(drag.startIdx, drag.endIdx); + const max = Math.max(drag.startIdx, drag.endIdx); + return idx >= min && idx <= max; +} + +function getDragHighlight(direction: 'check' | 'uncheck'): string { + return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)'; +} + +export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis }: VesselSelectGridProps) { + const [dragState, setDragState] = useState(null); + + const handleMouseDown = useCallback( + (idx: number, e: React.MouseEvent) => { + // 체크박스 직접 클릭은 무시 (기존 onChange 처리) + if ((e.target as HTMLElement).tagName === 'INPUT') return; + e.preventDefault(); + const isSelected = selectedMmsis.has(vessels[idx].mmsi); + setDragState({ startIdx: idx, endIdx: idx, direction: isSelected ? 'uncheck' : 'check' }); + }, + [vessels, selectedMmsis], + ); + + const handleMouseEnter = useCallback( + (idx: number) => { + if (!dragState) return; + setDragState((prev) => (prev ? { ...prev, endIdx: idx } : null)); + }, + [dragState], + ); + + // document-level mouseup: 드래그 종료 + useEffect(() => { + if (!dragState) return; + + const handleMouseUp = () => { + const { startIdx, endIdx, direction } = dragState; + if (startIdx === endIdx) { + // 단일 클릭 + toggleMmsi(vessels[startIdx].mmsi); + } else { + // 범위 선택 + const min = Math.min(startIdx, endIdx); + const max = Math.max(startIdx, endIdx); + const newSet = new Set(selectedMmsis); + for (let i = min; i <= max; i++) { + const mmsi = vessels[i].mmsi; + if (direction === 'check') newSet.add(mmsi); + else newSet.delete(mmsi); + } + setMmsis(newSet); + } + setDragState(null); + }; + + document.addEventListener('mouseup', handleMouseUp); + return () => document.removeEventListener('mouseup', handleMouseUp); + }, [dragState, vessels, selectedMmsis, toggleMmsi, setMmsis]); + + return ( + + + + + + + + + + + + + {vessels.map((v, idx) => { + const isSelected = selectedMmsis.has(v.mmsi); + const meta = VESSEL_TYPES[v.shipCode]; + const inRange = dragState ? isInDragRange(idx, dragState) : false; + + // 드래그 중 범위 내 행 → 예상 상태 미리보기 + let rowBg: string | undefined; + if (inRange && dragState) { + rowBg = getDragHighlight(dragState.direction); + } else if (isSelected) { + rowBg = 'rgba(59,130,246,0.12)'; + } + + const tdStyle = getTdStyle(false); // 배경은 tr에서 관리 + const stateBadgeStyle = getStateBadgeStyle(v.state.isFishing, v.state.isTransit); + const mmsiDisplay = String(v.mmsi); + const sogDisplay = v.sog !== null ? `${v.sog.toFixed(1)} kt` : '–'; + + // 드래그 중 범위 내 체크 상태 미리보기 + const previewChecked = inRange && dragState + ? dragState.direction === 'check' + : isSelected; + + return ( + handleMouseDown(idx, e)} + onMouseEnter={() => handleMouseEnter(idx)} + > + + + + + + + + + ); + })} + +
+ 업종등록번호선명MMSI속력상태
+ toggleMmsi(v.mmsi)} + onClick={(e) => e.stopPropagation()} + style={{ cursor: 'pointer' }} + /> + + + {v.shipCode} + {v.permitNo}{v.name}{mmsiDisplay}{sogDisplay} + {v.state.label} +
+ ); +} diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx new file mode 100644 index 0000000..d30dc78 --- /dev/null +++ b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx @@ -0,0 +1,410 @@ +import { useMemo, useEffect, useCallback, type CSSProperties } from 'react'; +import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal'; +import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; +import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta'; +import { VesselSelectGrid } from './VesselSelectGrid'; +import { ToggleButton, TextInput, Button } from '@wing/ui'; + +interface VesselSelectModalProps { + modal: VesselSelectModalState; + vessels: DerivedLegacyVessel[]; +} + +const STRIP_RE = /[\s\-.,_]/g; + +function normalize(s: string): string { + return s.replace(STRIP_RE, '').toLowerCase(); +} + +function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean { + if (normalize(v.permitNo).includes(nq)) return true; + if (normalize(v.name).includes(nq)) return true; + if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true; + if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true; + if (normalize(String(v.mmsi)).includes(nq)) return true; + return false; +} + +const STATE_LABELS = ['조업', '항해', '정지', '저속', '미상'] as const; +const STATE_COLORS: Record = { + 조업: '#22C55E', + 항해: '#3B82F6', + 정지: '#64748B', + 저속: '#EAB308', + 미상: '#6B7280', +}; + +const STYLE_OVERLAY: CSSProperties = { + position: 'fixed', + inset: 0, + zIndex: 1050, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', +}; + +const STYLE_BACKDROP: CSSProperties = { + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.35)', + pointerEvents: 'auto', +}; + +const STYLE_CONTENT: CSSProperties = { + position: 'relative', + display: 'flex', + flexDirection: 'column', + maxWidth: 720, + width: '95vw', + maxHeight: '57vh', + background: 'rgba(15,23,42,0.96)', + backdropFilter: 'blur(12px)', + border: '1px solid rgba(148,163,184,0.25)', + borderRadius: 12, + color: '#e2e8f0', + overflow: 'hidden', + pointerEvents: 'auto', +}; + +const STYLE_HEADER: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 16px', + borderBottom: '1px solid rgba(148,163,184,0.15)', + flexShrink: 0, + cursor: 'grab', + userSelect: 'none', +}; + +const STYLE_CLOSE_BTN: CSSProperties = { + background: 'transparent', + border: 'none', + color: '#94a3b8', + cursor: 'pointer', + fontSize: 16, + lineHeight: 1, + padding: '2px 6px', +}; + +const STYLE_FILTERS: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + padding: '8px 16px', + borderBottom: '1px solid rgba(148,163,184,0.1)', + flexShrink: 0, +}; + +const STYLE_FILTER_ROW: CSSProperties = { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: 5, +}; + +const STYLE_FILTER_LABEL: CSSProperties = { + fontSize: 11, + color: '#64748b', + minWidth: 28, + flexShrink: 0, +}; + +const STYLE_DOT = (color: string): CSSProperties => ({ + display: 'inline-block', + width: 7, + height: 7, + borderRadius: '50%', + background: color, + marginRight: 3, + verticalAlign: 'middle', +}); + +const STYLE_SEARCH: CSSProperties = { + padding: '6px 16px', + borderBottom: '1px solid rgba(148,163,184,0.1)', + flexShrink: 0, +}; + +const STYLE_GRID: CSSProperties = { + flex: 1, + overflowY: 'auto', + minHeight: 0, +}; + +const STYLE_DATE_BAR: CSSProperties = { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: 6, + padding: '8px 16px', + borderTop: '1px solid rgba(148,163,184,0.1)', + flexShrink: 0, + fontSize: 11, +}; + +const STYLE_DATETIME_INPUT: CSSProperties = { + fontSize: 11, + padding: '3px 6px', + borderRadius: 4, + border: '1px solid rgba(148,163,184,0.35)', + background: 'rgba(30,41,59,0.8)', + color: '#e2e8f0', + colorScheme: 'dark', +}; + +const STYLE_FOOTER: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 16px', + borderTop: '1px solid rgba(148,163,184,0.15)', + flexShrink: 0, +}; + +const STYLE_FOOTER_SPACER: CSSProperties = { flex: 1 }; + +const STYLE_SEPARATOR: CSSProperties = { + width: 1, + height: 14, + background: 'rgba(148,163,184,0.2)', + flexShrink: 0, +}; + +export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { + const { + isOpen, + close, + selectedMmsis, + toggleMmsi, + setMmsis, + selectAllFiltered, + clearAll, + searchQuery, + setSearchQuery, + shipCodeFilter, + toggleShipCode, + toggleAllShipCodes, + onlySailing, + setOnlySailing, + stateFilter, + toggleStateFilter, + toggleAllStates, + startTime, + endTime, + setStartTime, + setEndTime, + applyPresetDays, + isQuerying, + submitQuery, + position, + setPosition, + selectionWarning, + } = modal; + + // ── 필터 ── + const filteredVessels = useMemo(() => { + let list = vessels; + if (shipCodeFilter.size > 0) { + list = list.filter((v) => shipCodeFilter.has(v.shipCode)); + } + if (stateFilter.size > 0) { + list = list.filter((v) => stateFilter.has(v.state.label)); + } + if (onlySailing) { + list = list.filter((v) => v.state.isFishing || v.state.isTransit); + } + const nq = searchQuery.length >= 2 ? normalize(searchQuery) : ''; + if (nq) { + list = list.filter((v) => matchesQuery(v, nq)); + } + return list; + }, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]); + + // ── Escape 닫기 ── + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }, + [close], + ); + + useEffect(() => { + if (!isOpen) return; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleKeyDown]); + + // ── 드래그 ── + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + e.preventDefault(); + const startX = e.clientX - position.x; + const startY = e.clientY - position.y; + document.body.style.cursor = 'grabbing'; + + const handleMove = (ev: PointerEvent) => { + setPosition({ x: ev.clientX - startX, y: ev.clientY - startY }); + }; + const handleUp = () => { + document.body.style.cursor = ''; + document.removeEventListener('pointermove', handleMove); + document.removeEventListener('pointerup', handleUp); + }; + document.addEventListener('pointermove', handleMove); + document.addEventListener('pointerup', handleUp); + }, + [position, setPosition], + ); + + // ── 전체 선택 ── + const isAllSelected = filteredVessels.length > 0 && filteredVessels.every((v) => selectedMmsis.has(v.mmsi)); + + const handleSelectAllChange = useCallback(() => { + if (isAllSelected) clearAll(); + else selectAllFiltered(filteredVessels); + }, [isAllSelected, clearAll, selectAllFiltered, filteredVessels]); + + const handleContentClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + if (!isOpen) return null; + + return ( + <> +
+
+
+ {/* 헤더 (드래그 핸들) */} +
+
+ + 대상 선박 선택 + ({vessels.length}척) +
+ +
+ + {/* 업종 + 상태 필터 */} +
+
+ 업종 + toggleAllShipCodes(VESSEL_TYPE_ORDER)}> + 전체 + + {VESSEL_TYPE_ORDER.map((code) => { + const meta = VESSEL_TYPES[code]; + return ( + toggleShipCode(code)}> + + {code} + + ); + })} +
+
+ 상태 + toggleAllStates([...STATE_LABELS])}> + 전체 + + {STATE_LABELS.map((label) => ( + toggleStateFilter(label)}> + + {label} + + ))} + +
+
+ + {/* 검색 */} +
+ setSearchQuery(e.target.value)} /> +
+ + {/* 그리드 */} +
+ +
+ + {/* 기간 설정 바 */} +
+ {[7, 14, 21, 28].map((d) => ( + + ))} +
+ + +
+ + {/* 푸터 */} +
+ + {selectionWarning && {selectionWarning}} +
+ 선택 {selectedMmsis.size}척 + +
+
+
+ + ); +} diff --git a/scripts/prepare-legacy.mjs b/scripts/prepare-legacy.mjs index 51c5d45..cd45766 100644 --- a/scripts/prepare-legacy.mjs +++ b/scripts/prepare-legacy.mjs @@ -84,6 +84,7 @@ function readPermittedListXlsx(filePath) { const prev = byPermitNo.get(permitNo); if (prev) { if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi); + if (!prev.mmsi && mmsi) prev.mmsi = mmsi; continue; } @@ -101,6 +102,7 @@ function readPermittedListXlsx(filePath) { workTerm2: toStr(r.work_term2), quota: toStr(r.quota), shipCode: toStr(r.ship_code), + mmsi: mmsi, mmsiList: mmsi ? [mmsi] : [], sources: { permittedList: true, checklist: false, fleet906: false }, ownerCn: null, @@ -224,6 +226,7 @@ async function main() { workTerm2: "", quota: "", shipCode: c.shipCode, + mmsi: null, mmsiList: [], sources: { permittedList: false, checklist: true, fleet906: false }, ownerCn: c.ownerCn || null,