diff --git a/.claude/settings.json b/.claude/settings.json index 0e1660c..f95864b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -81,5 +81,8 @@ ] } ] + }, + "env": { + "CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5" } } 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 && ( = { + '000020': '어선', + '000021': '경비함정', + '000022': '여객선', + '000023': '화물선', + '000024': '유조선', + '000025': '관공선', + '000027': '일반', + '000028': '부이', +}; + +/** 선종별 범례/UI 색상 (hex) */ +export const SHIP_KIND_COLORS: Record = { + '000020': '#00C853', + '000021': '#FF5722', + '000022': '#2196F3', + '000023': '#9C27B0', + '000024': '#F44336', + '000025': '#FF9800', + '000027': '#607D8B', + '000028': '#795548', +}; + +/** 정렬된 선종 코드 목록 (범례 표시 순서) */ +export const SHIP_KIND_ORDER = [ + '000020', '000021', '000022', '000023', + '000024', '000025', '000027', '000028', +] as const; + +// ── SVG 아이콘 생성기 ── + +const STROKE = 'rgba(0,0,0,0.6)'; +const TARGET_STROKE = 'rgba(255,255,255,0.7)'; + +/** 이동 중 선박 SVG (화살표 형태, 32×48) */ +export function makeMovingShipSvg(fill: string): string { + return ``; +} + +/** 정지 선박 SVG (원형, 16×16) */ +export function makeStoppedShipSvg(fill: string): string { + return ``; +} + +/** 부이 SVG (다색, 32×44) */ +export function makeBuoySvg(): string { + return ``; +} + +/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */ +export function makeTargetMovingShipSvg(fill: string): string { + return ``; +} + +/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */ +export function makeTargetStoppedShipSvg(fill: string): string { + return ``; +} + +function toDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +/** Deck.gl IconLayer getIcon 반환 타입 */ +export interface ShipIconSpec { + url: string; + width: number; + height: number; + anchorX: number; + anchorY: number; +} + +// ── 기타 AIS 아이콘 스펙 사전 생성 (8종 × 2상태 + buoy) ── + +const OTHER_ICON_SPECS: Record = {}; + +for (const code of SHIP_KIND_ORDER) { + const color = SHIP_KIND_COLORS[code] || '#607D8B'; + + if (code === '000028') { + OTHER_ICON_SPECS[`${code}-buoy`] = { + url: toDataUri(makeBuoySvg()), + width: 32, height: 44, anchorX: 16, anchorY: 22, + }; + continue; + } + + OTHER_ICON_SPECS[`${code}-moving`] = { + url: toDataUri(makeMovingShipSvg(color)), + width: 32, height: 48, anchorX: 16, anchorY: 24, + }; + OTHER_ICON_SPECS[`${code}-stopped`] = { + url: toDataUri(makeStoppedShipSvg(color)), + width: 16, height: 16, anchorX: 8, anchorY: 8, + }; +} + +// fallback +const FALLBACK_MOVING: ShipIconSpec = OTHER_ICON_SPECS['000027-moving']; +const FALLBACK_STOPPED: ShipIconSpec = OTHER_ICON_SPECS['000027-stopped']; + +// ── 대상 선박 아이콘 스펙 사전 생성 (7 legacyCode × 2상태) ── + +const LEGACY_CODES = ['PT', 'PT-S', 'GN', 'OT', 'PS', 'FC', 'C21'] as const; + +const TARGET_ICON_SPECS: Record = {}; + +for (const code of LEGACY_CODES) { + const rgb: Rgb = LEGACY_CODE_COLORS_RGB[code] || [100, 116, 139]; + const hex = rgbToHex(rgb); + + TARGET_ICON_SPECS[`${code}-moving`] = { + url: toDataUri(makeTargetMovingShipSvg(hex)), + width: 36, height: 52, anchorX: 18, anchorY: 26, + }; + TARGET_ICON_SPECS[`${code}-stopped`] = { + url: toDataUri(makeTargetStoppedShipSvg(hex)), + width: 20, height: 20, anchorX: 10, anchorY: 10, + }; +} + +// fallback (FC 색상) +const TARGET_FALLBACK_MOVING: ShipIconSpec = TARGET_ICON_SPECS['FC-moving']; +const TARGET_FALLBACK_STOPPED: ShipIconSpec = TARGET_ICON_SPECS['FC-stopped']; + +// ── SOG 기준 이동/정지 판단 (kn) ── + +export const SPEED_THRESHOLD_KN = 1; + +// ── 조회 함수 ── + +/** 기타 AIS 아이콘 스펙 조회 */ +export function getShipIconSpec( + signalKindCode: string | undefined | null, + sog: number | undefined | null, +): ShipIconSpec { + const code = signalKindCode || '000027'; + if (code === '000028') return OTHER_ICON_SPECS['000028-buoy'] || FALLBACK_STOPPED; + + const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN; + const key = `${code}-${isMoving ? 'moving' : 'stopped'}`; + return OTHER_ICON_SPECS[key] || (isMoving ? FALLBACK_MOVING : FALLBACK_STOPPED); +} + +/** 대상 선박 아이콘 스펙 조회 */ +export function getTargetShipIconSpec( + legacyShipCode: string | undefined | null, + sog: number | undefined | null, +): ShipIconSpec { + const code = legacyShipCode || 'FC'; + const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN; + const key = `${code}-${isMoving ? 'moving' : 'stopped'}`; + return TARGET_ICON_SPECS[key] || (isMoving ? TARGET_FALLBACK_MOVING : TARGET_FALLBACK_STOPPED); +} + +/** 선박 아이콘 회전각 (부이는 0, 나머지는 -cog) */ +export function getShipIconAngle( + signalKindCode: string | undefined | null, + cog: number | undefined | null, +): number { + const code = signalKindCode || '000027'; + if (code === '000028') return 0; + return -(Number.isFinite(cog) ? (cog as number) : 0); +} diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 3ae458f..e64d9de 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta"; -import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; +import { LEGACY_CODE_COLORS_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; +import { SHIP_KIND_ORDER, SHIP_KIND_LABELS, SHIP_KIND_COLORS } from "../../shared/lib/map/shipKind"; export function MapLegend() { const [isOpen, setIsOpen] = useState(true); @@ -25,23 +26,13 @@ export function MapLegend() { ))} -
기타 AIS 선박(속도)
-
-
- SOG ≥ 10 kt -
-
-
- 1 ≤ SOG < 10 kt -
-
-
- SOG < 1 kt -
-
-
- SOG unknown -
+
기타 AIS 선박(선종)
+ {SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => ( +
+
+ {SHIP_KIND_LABELS[code]} +
+ ))}
CN Permit(업종)
diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 1aeb1c8..4d889a1 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -624,7 +624,7 @@ export function Map3D({ useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { - projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipData, legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index 583ffbb..8a23d64 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -14,10 +14,7 @@ const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; -// ── Ship icon mapping (Deck.gl IconLayer) ── -// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage) - -export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants'; +// Ship icon mapping removed — now using shipKind.ts SVG-based icons // ── Ship constants ── @@ -47,14 +44,20 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)'; // ── Flat map icon sizes ── -export const FLAT_SHIP_ICON_SIZE = 19; -export const FLAT_SHIP_ICON_SIZE_SELECTED = 28; -export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; +export const FLAT_OTHER_SHIP_SIZE = 20; +export const FLAT_TARGET_SHIP_SIZE = 26; export const FLAT_LEGACY_HALO_RADIUS = 14; export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; -export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; export const EMPTY_MMSI_SET = new Set(); +// ── 대상 선박 브리딩 애니메이션 ── + +export const HALO_BREATHE_PERIOD_MS = 2000; +export const HALO_BREATHE_SELECTED_R_MIN = 16; +export const HALO_BREATHE_SELECTED_R_MAX = 22; +export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14; +export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19; + // ── Deck.gl view ID ── export const DECK_VIEW_ID = 'mapbox'; diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index f8b1aca..7fca618 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -3,7 +3,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox'; import { type PickingInfo } from '@deck.gl/core'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; -import { ScatterplotLayer } from '@deck.gl/layers'; +import { IconLayer, ScatterplotLayer } from '@deck.gl/layers'; import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types'; import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; @@ -19,11 +19,26 @@ import { } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; +import { + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + HALO_BREATHE_PERIOD_MS, + HALO_BREATHE_SELECTED_R_MIN, + HALO_BREATHE_SELECTED_R_MAX, + HALO_BREATHE_HIGHLIGHTED_R_MIN, + HALO_BREATHE_HIGHLIGHTED_R_MAX, +} from '../constants'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). // Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. const ENABLE_GLOBE_DECK_OVERLAYS = false; +/** 64×64 white ring SVG for alarm pulse IconLayer */ +const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent( + '', +)}`; +const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const; + export function useDeckLayers( mapRef: MutableRefObject, @@ -35,7 +50,6 @@ export function useDeckLayers( settings: Map3DSettings; trackReplayDeckLayers: unknown[]; shipLayerData: AisTarget[]; - shipOverlayLayerData: AisTarget[]; shipData: AisTarget[]; legacyHits: Map | null | undefined; pairLinks: PairLink[] | undefined; @@ -72,7 +86,7 @@ export function useDeckLayers( }, ) { const { - projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, + projection, settings, trackReplayDeckLayers, shipLayerData, shipData, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, @@ -98,9 +112,12 @@ export function useDeckLayers( }, [legacyTargets]); const legacyOverlayTargets = useMemo(() => { - if (shipHighlightSet.size === 0) return []; - return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); - }, [legacyTargets, shipHighlightSet]); + if (shipHighlightSet.size === 0 && selectedMmsi == null) return []; + return legacyTargets.filter((target) => + shipHighlightSet.has(target.mmsi) || + (selectedMmsi != null && target.mmsi === selectedMmsi), + ); + }, [legacyTargets, shipHighlightSet, selectedMmsi]); const alarmTargets = useMemo(() => { if (!alarmMmsiMap || alarmMmsiMap.size === 0) return []; @@ -134,7 +151,6 @@ export function useDeckLayers( const layers = buildMercatorDeckLayers({ shipLayerData, - shipOverlayLayerData, legacyTargetsOrdered, legacyOverlayTargets, legacyHits, @@ -246,7 +262,6 @@ export function useDeckLayers( legacyTargetsOrdered, legacyHits, legacyOverlayTargets, - shipOverlayLayerData, pairRangesInteractive, pairLinksInteractive, fcLinesInteractive, @@ -275,9 +290,12 @@ export function useDeckLayers( onClickShipPhoto, ]); - // Mercator alarm pulse breathing animation (rAF) + // Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류 + const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0; + const hasTargetOverlays = legacyOverlayTargets.length > 0; + useEffect(() => { - if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) { + if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) { if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; return; @@ -295,34 +313,75 @@ export function useDeckLayers( return; } - const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2; - const normalR = 8 + t * 6; - const hoverR = 12 + t * 6; + const now = Date.now(); + let updated = mercatorLayersRef.current; - const pulseLyr = new ScatterplotLayer({ - id: 'alarm-pulse', - data: alarmTargets, - pickable: false, - billboard: false, - filled: true, - stroked: false, - radiusUnits: 'pixels', - getRadius: (d) => { - const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); - return isHover ? hoverR : normalR; - }, - getFillColor: (d) => { - const kind = alarmMmsiMap.get(d.mmsi); - return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { getRadius: [normalR, hoverR] }, - }); + // 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션 + if (hasAlarms) { + const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2; - const updated = mercatorLayersRef.current.map((l) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (l as any)?.id === 'alarm-pulse' ? pulseLyr : l, - ); + const pulseLyr = new IconLayer({ + id: 'alarm-pulse', + data: alarmTargets, + pickable: false, + 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 + ? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2 + : (FLAT_LEGACY_HALO_RADIUS + 8) * 2; + }, + getColor: (d) => { + const kind = alarmMmsiMap!.get(d.mmsi); + 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: { + getSize: [selectedMmsi], + }, + }); + updated = updated.map((l) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (l as any)?.id === 'alarm-pulse' ? pulseLyr : l, + ); + } + + // 2. 대상 선박 브리딩 링 + if (hasTargetOverlays) { + const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2; + const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN); + const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN); + const alpha = Math.round(155 + tH * 100); + + const haloLyr = new ScatterplotLayer({ + id: 'legacy-halo-overlay', + data: legacyOverlayTargets, + pickable: false, + billboard: false, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: (d) => (selectedMmsi != null && d.mmsi === selectedMmsi ? selR : hlR), + lineWidthUnits: 'pixels', + getLineWidth: 2.5, + getLineColor: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return [14, 234, 255, alpha] as [number, number, number, number]; + return [245, 158, 11, alpha] as [number, number, number, number]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + updateTriggers: { getRadius: [selR, hlR], getLineColor: [alpha, selectedMmsi] }, + }); + updated = updated.map((l) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (l as any)?.id === 'legacy-halo-overlay' ? haloLyr : l, + ); + } try { currentOverlay.setProps({ layers: updated } as never); @@ -336,7 +395,7 @@ export function useDeckLayers( if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; }; - }, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]); + }, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]); // Globe Deck overlay useEffect(() => { diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts index 52f629e..4c3b0a3 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipHover.ts @@ -123,7 +123,7 @@ export function useGlobeShipHover( sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', }), iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index eaaf29e..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, @@ -84,9 +113,9 @@ export function useGlobeShipLayers( (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420, ); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - // 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리 - // hover overlay 레이어가 확대 + z-priority를 담당 + const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + // 대상 선박은 1.3x 배율 적용 + const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale; const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3); const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45); const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8); @@ -106,7 +135,7 @@ export function useGlobeShipLayers( isAnchored: isAnchored ? 1 : 0, shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', }), iconSize3, iconSize7, @@ -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 7703556..e266515 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -8,19 +8,14 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, PairRangeCircle } from '../types'; import { - SHIP_ICON_MAPPING, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_OTHER_SHIP_SIZE, + FLAT_TARGET_SHIP_SIZE, FLAT_LEGACY_HALO_RADIUS, FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, DEPTH_DISABLED_PARAMS, GLOBE_OVERLAY_PARAMS, HALO_OUTLINE_COLOR, HALO_OUTLINE_COLOR_SELECTED, - HALO_OUTLINE_COLOR_HIGHLIGHTED, PAIR_RANGE_NORMAL_DECK, PAIR_RANGE_WARN_DECK, PAIR_LINE_NORMAL_DECK, @@ -38,8 +33,18 @@ import { FLEET_RANGE_LINE_DECK_HL, FLEET_RANGE_FILL_DECK_HL, } from '../constants'; -import { getDisplayHeading, getShipColor } from './shipUtils'; -import { getCachedShipIcon } from './shipIconCache'; +import { getDisplayHeading } from './shipUtils'; +import { + getShipIconSpec, + getTargetShipIconSpec, + getShipIconAngle, + 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( + '', +)}`; /* ── 공통 콜백 인터페이스 ─────────────────────────────── */ @@ -64,7 +69,6 @@ interface DeckSelectCallbacks { export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks { shipLayerData: AisTarget[]; - shipOverlayLayerData: AisTarget[]; legacyTargetsOrdered: AisTarget[]; legacyOverlayTargets: AisTarget[]; legacyHits: Map | null | undefined; @@ -101,10 +105,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ if (isTargetShip(t.mmsi)) shipTargetData.push(t); else shipOtherData.push(t); } - const shipOverlayOtherData: AisTarget[] = []; - for (const t of ctx.shipOverlayLayerData) { - if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t); - } /* ─ density ─ */ if (ctx.showDensity) { @@ -318,26 +318,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ }; if (shipOtherData.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'ships-other-halo', - data: shipOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - getPosition: (d) => [d.lon, d.lat] as [number, number], - radiusUnits: 'pixels', - getRadius: 10, - getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number], - getLineColor: (d) => { - const c = getShipColor(d, null, null, EMPTY_MMSI_SET); - return [c[0], c[1], c[2], 100] as [number, number, number, number]; - }, - stroked: true, - lineWidthUnits: 'pixels', - getLineWidth: 1, - }), - ); layers.push( new IconLayer({ id: 'ships-other', @@ -345,14 +325,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', + getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog), getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + getAngle: (d) => getShipIconAngle(d.signalKindCode ?? d.shipKindCode, d.cog), sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + getSize: FLAT_OTHER_SHIP_SIZE, onHover: shipOnHover, onClick: shipOnClick, alphaCutoff: 0.05, @@ -360,31 +337,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ ); } - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: 'ships-overlay-other', - data: shipOverlayOtherData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), - sizeUnits: 'pixels', - getSize: (d) => { - if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - if (ctx.legacyTargetsOrdered.length > 0) { layers.push( new ScatterplotLayer({ @@ -413,14 +365,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ pickable: true, billboard: false, parameters: overlayParams, - iconAtlas: getCachedShipIcon(), - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => 'ship', + getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog), getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), + getAngle: (d) => { + const isMoving = Number.isFinite(d.sog) && (d.sog as number) > SPEED_THRESHOLD_KN; + return isMoving ? -getDisplayHeading({ cog: d.cog, heading: d.heading }) : 0; + }, sizeUnits: 'pixels', - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + getSize: FLAT_TARGET_SHIP_SIZE, onHover: shipOnHover, onClick: shipOnClick, alphaCutoff: 0.05, @@ -444,14 +396,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); } - /* ─ legacy overlay (highlight/selected) ─ */ + /* ─ legacy overlay (highlight/selected — breathing ring, no icon enlargement) ─ */ if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) { - layers.push(new ScatterplotLayer({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); - } - - if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) { - const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)); - layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } })); + layers.push(new ScatterplotLayer({ + id: 'legacy-halo-overlay', + data: ctx.legacyOverlayTargets, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: 'pixels', + getRadius: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 18 : 16), + lineWidthUnits: 'pixels', + getLineWidth: 2.5, + getLineColor: (d) => { + if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 200]; + return [245, 158, 11, 190]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + })); } /* ─ ship name labels (Mercator) ─ */ @@ -496,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/map3d/lib/shipIconCache.ts b/apps/web/src/widgets/map3d/lib/shipIconCache.ts deleted file mode 100644 index b7bdd8e..0000000 --- a/apps/web/src/widgets/map3d/lib/shipIconCache.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시. - * Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록 - * 인라인 data URL을 전달한다. - */ -const SHIP_SVG_URL = '/assets/ship.svg'; - -let _cachedDataUrl: string | null = null; -let _promise: Promise | null = null; - -function preloadShipIcon(): Promise { - if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl); - if (_promise) return _promise; - _promise = fetch(SHIP_SVG_URL) - .then((res) => res.text()) - .then((svg) => { - _cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; - return _cachedDataUrl; - }) - .catch(() => SHIP_SVG_URL); - return _promise; -} - -/** 캐시된 data URL 또는 폴백 URL 반환 */ -export function getCachedShipIcon(): string { - return _cachedDataUrl ?? SHIP_SVG_URL; -} - -// 모듈 임포트 시 즉시 로드 시작 -preloadShipIcon(); diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts index eaab8bb..d18b4a1 100644 --- a/apps/web/src/widgets/map3d/lib/shipUtils.ts +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -1,12 +1,10 @@ import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import { rgbToHex } from '../../../shared/lib/map/palette'; +import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind'; import { ANCHOR_SPEED_THRESHOLD_KN, LEGACY_CODE_COLORS, - MAP_SELECTED_SHIP_RGB, - MAP_HIGHLIGHT_SHIP_RGB, - MAP_DEFAULT_SHIP_RGB, } from '../constants'; import { isFiniteNumber } from './setUtils'; import { normalizeAngleDeg } from './geometry'; @@ -53,44 +51,21 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) { export function getGlobeBaseShipColor({ legacy, - sog, + signalKindCode, }: { legacy: string | null; - sog: number | null; + signalKindCode?: string; }) { + // 대상 선박: legacy code 색상 (밝게) if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } - // Keep alpha control in icon-opacity only to avoid double-multiplying transparency. - if (!isFiniteNumber(sog)) return '#64748b'; - if (sog >= 10) return '#94a3b8'; - if (sog >= 1) return '#64748b'; - return '#475569'; -} - -export function getShipColor( - t: AisTarget, - selectedMmsi: number | null, - legacyShipCode: string | null, - highlightedMmsis: Set, -): [number, number, number, number] { - if (selectedMmsi && t.mmsi === selectedMmsi) { - return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; - } - if (highlightedMmsis.has(t.mmsi)) { - return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; - } - if (legacyShipCode) { - const rgb = LEGACY_CODE_COLORS[legacyShipCode]; - if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; - return [245, 158, 11, 235]; - } - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; - if (t.sog >= 10) return [148, 163, 184, 215]; - if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210]; - return [71, 85, 105, 200]; + // 기타 AIS: signalKindCode → 선종별 색상 + const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027']; + if (kindColor) return kindColor; + return '#607D8B'; } export function buildGlobeShipFeature( @@ -108,7 +83,10 @@ export function buildGlobeShipFeature( mmsi: t.mmsi, heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }), anchored, - color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }), + color: getGlobeBaseShipColor({ + legacy: legacy?.shipCode ?? null, + signalKindCode: t.signalKindCode || t.shipKindCode || '000027', + }), selected: isSelected, highlighted: isHighlighted, permitted: legacy ? 1 : 0, 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,