Merge pull request 'release: 2026-03-08 (5건 커밋)' (#52) from develop into main
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 35s

Reviewed-on: #52
This commit is contained in:
htlee 2026-03-08 13:03:40 +09:00
커밋 ff6a7cc710
31개의 변경된 파일1811개의 추가작업 그리고 538개의 파일을 삭제

파일 보기

@ -81,5 +81,8 @@
]
}
]
},
"env": {
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
}
}

파일 보기

@ -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";

파일 보기

@ -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;
}

파일 보기

@ -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) {
if (byMmsi.has(m)) continue;
byMmsi.set(m, v);
continue;
}
if (score(v) > score(prev)) 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;
}

파일 보기

@ -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;

파일 보기

@ -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 */

파일 보기

@ -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',

파일 보기

@ -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);
}

파일 보기

@ -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;

파일 보기

@ -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<ProcessedTrack[]> {
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<ProcessedTrack[]> {
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<ProcessedTrack[]> {
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<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsis, params.hasPermitted);
}

파일 보기

@ -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<void>;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void;
@ -53,6 +55,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: 0,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,
@ -70,11 +73,48 @@ export const useTrackQueryStore = create<TrackQueryState>()((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<string>(),
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<TrackQueryState>()((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<TrackQueryState>()((set, get) => ({
renderEpoch: state.renderEpoch + 1,
lastQueryKey: null,
queryContext: null,
multiQueryContext: null,
showPoints: true,
showVirtualShip: true,
showLabels: true,

파일 보기

@ -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<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
selectAllFiltered: (filtered: DerivedLegacyVessel[]) => void;
clearAll: () => void;
searchQuery: string;
setSearchQuery: (q: string) => void;
shipCodeFilter: Set<string>;
toggleShipCode: (code: string) => void;
toggleAllShipCodes: (allCodes: string[]) => void;
onlySailing: boolean;
setOnlySailing: (v: boolean) => void;
stateFilter: Set<string>;
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<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [shipCodeFilter, setShipCodeFilter] = useState<Set<string>>(new Set());
const [onlySailing, setOnlySailing] = useState(false);
const [stateFilter, setStateFilter] = useState<Set<string>>(new Set());
const [selectionWarning, setSelectionWarning] = useState<string | null>(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<number>) => {
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,
};
}

파일 보기

@ -0,0 +1,3 @@
export type { VesselDescriptor } from './model/types';
export { MAX_VESSEL_SELECT } from './model/types';
export { useVesselSelectModal } from './hooks/useVesselSelectModal';

파일 보기

@ -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;

파일 보기

@ -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}
/>
<DashboardSidebar
@ -442,7 +448,13 @@ export function DashboardPage() {
freeCamera={state.freeCamera}
oceanMapSettings={state.oceanMapSettings}
/>
<GlobalTrackReplayPanel />
<GlobalTrackReplayPanel
isVesselListOpen={vesselSelectModal.isOpen}
onToggleVesselList={() => {
if (vesselSelectModal.isOpen) vesselSelectModal.close();
else vesselSelectModal.reopen();
}}
/>
<WeatherPanel
snapshot={weather.snapshot}
isLoading={weather.isLoading}
@ -465,6 +477,9 @@ export function DashboardPage() {
{hasUnread && (
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
)}
{vesselSelectModal.isOpen && (
<VesselSelectModal modal={vesselSelectModal} vessels={legacyVesselsAll} />
)}
{imageModal && (
<ShipImageModal
images={imageModal.images}

파일 보기

@ -1,18 +1,4 @@
// ── Shared map constants ──
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
// (features/ must not import from widgets/).
export const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
export const DEPTH_DISABLED_PARAMS = {
depthCompare: 'always',

파일 보기

@ -0,0 +1,187 @@
// ── 선종(Ship Kind) 상수 + SVG 아이콘 생성 ──
// gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입.
// 기타 AIS: 선종별 색상 + 이동(화살표)/정지(원형) 분리.
// 대상 선박: legacy code 색상 + 약간 더 큰 SVG + 흰색 테두리.
import { LEGACY_CODE_COLORS_RGB, rgbToHex, type Rgb } from './palette';
// ── 선종 상수 (8종) ──
export const SIGNAL_KIND = {
FISHING: '000020',
KCGV: '000021',
PASSENGER: '000022',
CARGO: '000023',
TANKER: '000024',
GOV: '000025',
NORMAL: '000027',
BUOY: '000028',
} as const;
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
/** 선종별 한글 라벨 */
export const SHIP_KIND_LABELS: Record<string, string> = {
'000020': '어선',
'000021': '경비함정',
'000022': '여객선',
'000023': '화물선',
'000024': '유조선',
'000025': '관공선',
'000027': '일반',
'000028': '부이',
};
/** 선종별 범례/UI 색상 (hex) */
export const SHIP_KIND_COLORS: Record<string, string> = {
'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 xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48"><path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="${STROKE}" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
}
/** 정지 선박 SVG (원형, 16×16) */
export function makeStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="${STROKE}" stroke-width="1.2"/></svg>`;
}
/** 부이 SVG (다색, 32×44) */
export function makeBuoySvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44"><line x1="16" y1="10" x2="16" y2="38" stroke="#5D4037" stroke-width="2.5" stroke-linecap="round"/><line x1="10" y1="38" x2="22" y2="38" stroke="#5D4037" stroke-width="2" stroke-linecap="round"/><ellipse cx="16" cy="24" rx="9" ry="7" fill="#E53935" stroke="#333" stroke-width="1"/><rect x="8" y="22" width="16" height="4" rx="1" fill="#FDD835" opacity="0.85"/><rect x="14.5" y="8" width="3" height="10" fill="#666"/><circle cx="16" cy="7" r="3.5" fill="#FFC107" stroke="#444" stroke-width="0.8"/></svg>`;
}
/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */
export function makeTargetMovingShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="52" viewBox="0 0 36 52"><path d="M18 2 L9 14 L5 30 L8 49 L28 49 L31 30 L27 14 Z" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="2" stroke-linejoin="round"/></svg>`;
}
/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */
export function makeTargetStoppedShipSvg(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="1.5"/></svg>`;
}
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<string, ShipIconSpec> = {};
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<string, ShipIconSpec> = {};
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);
}

파일 보기

@ -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() {
</div>
))}
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
<div className="lt" style={{ marginTop: 8 }}> AIS ()</div>
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
<div key={code} className="li">
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
{SHIP_KIND_LABELS[code]}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}>CN Permit()</div>
<div className="li">

파일 보기

@ -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,

파일 보기

@ -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<number>();
// ── 대상 선박 브리딩 애니메이션 ──
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';

파일 보기

@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const;
export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
@ -35,7 +50,6 @@ export function useDeckLayers(
settings: Map3DSettings;
trackReplayDeckLayers: unknown[];
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
shipData: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | 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<AisTarget>({
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
if (hasAlarms) {
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
const pulseLyr = new IconLayer<AisTarget>({
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) => {
const kind = alarmMmsiMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
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: { getRadius: [normalR, hoverR] },
updateTriggers: {
getSize: [selectedMmsi],
},
});
const updated = mercatorLayersRef.current.map((l) =>
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<AisTarget>({
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(() => {

파일 보기

@ -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),

파일 보기

@ -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<maplibregl.Map | null>,
@ -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

파일 보기

@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
@ -64,7 +69,6 @@ interface DeckSelectCallbacks {
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
shipLayerData: AisTarget[];
shipOverlayLayerData: AisTarget[];
legacyTargetsOrdered: AisTarget[];
legacyOverlayTargets: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo> | 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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
@ -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<FleetCircle>({ 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<AisTarget>({ 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<AisTarget>({ 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<AisTarget>({
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<AisTarget>({
new IconLayer<AisTarget>({
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(

파일 보기

@ -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<string> | null = null;
function preloadShipIcon(): Promise<string> {
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();

파일 보기

@ -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, 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,

파일 보기

@ -15,6 +15,7 @@ interface Props {
onToggleTheme?: () => void;
isSidebarOpen?: boolean;
onMenuToggle?: () => void;
onOpenMultiTrack?: () => void;
}
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
@ -39,7 +40,7 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "
);
}
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle, onOpenMultiTrack }: Props) {
const [isStatsOpen, setIsStatsOpen] = useState(false);
return (
@ -83,6 +84,15 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi
{/* 데스크톱: 인라인 통계 */}
<div className="ml-auto hidden items-center gap-3.5 md:flex">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
{onOpenMultiTrack && (
<button
className="cursor-pointer whitespace-nowrap rounded-md border border-blue-500/50 bg-blue-600/20 px-2.5 py-1 text-[11px] font-semibold text-blue-300 transition-all duration-150 hover:border-blue-400 hover:bg-blue-600/30 hover:text-blue-200"
onClick={onOpenMultiTrack}
title="다중 선박 항적 조회"
>
</button>
)}
</div>
{/* 항상 표시: 시계 + 테마 + 사용자 */}

파일 보기

@ -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<HTMLDivElement | null>(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<string | null>(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<HTMLDivElement>) => {
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 (
<div
ref={panelRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: PANEL_WIDTH,
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(95vw, 700px)',
background: 'rgba(15,23,42,0.94)',
border: '1px solid rgba(148,163,184,0.35)',
borderRadius: 12,
@ -217,23 +147,31 @@ export function GlobalTrackReplayPanel() {
}}
>
{/* Header */}
<div
onPointerDown={handleHeaderPointerDown}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>
Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
</strong>
<div style={{ display: 'flex', gap: 6 }}>
{onToggleVesselList && (
<button
type="button"
onClick={onToggleVesselList}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
touchAction: 'none',
fontSize: 11,
padding: '4px 8px',
borderRadius: 6,
border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
cursor: 'pointer',
}}
>
<strong style={{ fontSize: 13 }}>Track Replay</strong>
</button>
)}
<button
type="button"
onClick={() => closeTrackQuery()}
onPointerDown={(event) => event.stopPropagation()}
style={{
fontSize: 11,
padding: '4px 8px',
@ -247,73 +185,41 @@ export function GlobalTrackReplayPanel() {
</button>
</div>
</div>
{error ? (
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
) : null}
{error ? <div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> : null}
{requeryWarning ? <div style={{ marginBottom: 8, color: '#fbbf24', fontSize: 12 }}>{requeryWarning}</div> : null}
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
{/* Vessel count */}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
{tracks.length}
</div>
{/* Vessel list — dynamic layout */}
{isMultiMode && vesselCount > 0 ? (
<VesselListSection
tracks={tracks}
disabledVesselIds={disabledVesselIds}
toggleVesselEnabled={toggleVesselEnabled}
vesselCount={vesselCount}
isExpanded={isVesselListExpanded}
onToggleExpand={() => setIsVesselListExpanded((v) => !v)}
/>
) : vesselCount > 0 ? (
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}> {vesselCount}</div>
) : null}
{/* Date range editing */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
<input type="datetime-local" title="시작 시각" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
<input type="datetime-local" title="종료 시각" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
onClick={handleRequery}
disabled={isLoading || !queryContext || !editStartTime || !editEndTime}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(96,165,250,0.5)',
background: 'rgba(37,99,235,0.25)',
color: '#93c5fd',
cursor: 'pointer',
}}
>
<button type="button" onClick={handleRequery} disabled={isLoading || !hasRequeryContext || !editStartTime || !editEndTime} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(96,165,250,0.5)', background: 'rgba(37,99,235,0.25)', color: '#93c5fd', cursor: 'pointer' }}>
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={isLoading || tracks.length === 0}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(74,222,128,0.5)',
background: 'rgba(22,163,74,0.2)',
color: '#86efac',
cursor: 'pointer',
}}
>
<button type="button" onClick={handleExportCsv} disabled={isLoading || tracks.length === 0} style={{ flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 6, border: '1px solid rgba(74,222,128,0.5)', background: 'rgba(22,163,74,0.2)', color: '#86efac', cursor: 'pointer' }}>
CSV
</button>
</div>
@ -321,40 +227,17 @@ export function GlobalTrackReplayPanel() {
{/* Playback controls */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={btnBase}
>
<button type="button" onClick={() => (isPlaying ? pause() : play())} disabled={tracks.length === 0} style={btnBase}>
{isPlaying ? '일시정지' : '재생'}
</button>
<button
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={btnBase}
>
<button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
</button>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<select
value={playbackSpeed}
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
style={{
background: 'rgba(30,41,59,0.85)',
border: '1px solid rgba(148,163,184,0.45)',
borderRadius: 6,
color: '#e2e8f0',
fontSize: 12,
padding: '4px 6px',
}}
>
<select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value))} style={{ background: 'rgba(30,41,59,0.85)', border: '1px solid rgba(148,163,184,0.45)', borderRadius: 6, color: '#e2e8f0', fontSize: 12, padding: '4px 6px' }}>
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
<option key={speed} value={speed}>{speed}x</option>
))}
</select>
</label>
@ -362,15 +245,7 @@ export function GlobalTrackReplayPanel() {
{/* Timeline slider */}
<div style={{ marginBottom: 10 }}>
<input
type="range"
min={startTime}
max={endTime || startTime + 1}
value={currentTime}
onChange={(event) => setCurrentTime(Number(event.target.value))}
style={{ width: '100%' }}
disabled={tracks.length === 0 || endTime <= startTime}
/>
<input type="range" title="타임라인" min={startTime} max={endTime || startTime + 1} value={currentTime} onChange={(event) => setCurrentTime(Number(event.target.value))} style={{ width: '100%' }} disabled={tracks.length === 0 || endTime <= startTime} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
<span>{formatDateTime(currentTime)}</span>
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
@ -379,24 +254,57 @@ export function GlobalTrackReplayPanel() {
{/* Display toggles */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={hideLiveShips}
onChange={(event) => setHideLiveShips(event.target.checked)}
/>{' '}
</label>
<label><input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> </label>
<label><input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> </label>
<label><input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> </label>
<label><input type="checkbox" checked={hideLiveShips} onChange={(event) => setHideLiveShips(event.target.checked)} /> </label>
</div>
</div>
);
}
/* ── Vessel list sub-component ── */
interface VesselListSectionProps {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
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 (
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 11, color: '#93c5fd' }}> {vesselCount}</span>
{showExpandToggle && (
<button type="button" onClick={onToggleExpand} style={{ fontSize: 10, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px' }}>
{isExpanded ? '▴ 접기' : '▾ 펼치기'}
</button>
)}
</div>
{isListVisible && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: showExpandToggle ? 120 : undefined, overflowY: showExpandToggle ? 'auto' : undefined }}>
{tracks.map((track) => {
const isEnabled = !disabledVesselIds.has(track.vesselId);
const kindColor = SHIP_KIND_COLORS[track.shipKindCode] || '#607D8B';
return (
<label key={track.vesselId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 6px', borderRadius: 999, background: isEnabled ? 'rgba(148,163,184,0.12)' : 'rgba(148,163,184,0.04)', fontSize: 10, color: isEnabled ? '#cbd5e1' : '#64748B', cursor: 'pointer', opacity: isEnabled ? 1 : 0.5 }}>
<input type="checkbox" checked={isEnabled} onChange={() => toggleVesselEnabled(track.vesselId)} style={{ width: 10, height: 10, accentColor: kindColor }} />
<span style={{ width: 6, height: 6, borderRadius: '50%', background: kindColor, flexShrink: 0 }} />
{track.shipName}
<span style={{ color: '#64748B' }}>({track.targetId.slice(-5)})</span>
</label>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -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<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => 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<DragState | null>(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 (
<table style={{ ...STYLE_TABLE, userSelect: dragState ? 'none' : undefined }}>
<thead>
<tr>
<th style={STYLE_TH_CHECKBOX} />
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}>MMSI</th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={v.mmsi}
style={{ cursor: 'pointer', background: rowBg }}
onMouseDown={(e) => handleMouseDown(idx, e)}
onMouseEnter={() => handleMouseEnter(idx)}
>
<td style={tdStyle}>
<input
type="checkbox"
title="선택"
checked={previewChecked}
onChange={() => toggleMmsi(v.mmsi)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer' }}
/>
</td>
<td style={tdStyle}>
<span style={getDotStyle(meta.color)} />
{v.shipCode}
</td>
<td style={tdStyle}>{v.permitNo}</td>
<td style={tdStyle}>{v.name}</td>
<td style={tdStyle}>{mmsiDisplay}</td>
<td style={tdStyle}>{sogDisplay}</td>
<td style={tdStyle}>
<span style={stateBadgeStyle}>{v.state.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

파일 보기

@ -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<string, string> = {
: '#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 (
<>
<div style={STYLE_BACKDROP} onClick={close} />
<div
style={{
...STYLE_OVERLAY,
}}
>
<div
style={{
...STYLE_CONTENT,
transform: `translate(${position.x}px, ${position.y}px)`,
}}
onClick={handleContentClick}
>
{/* 헤더 (드래그 핸들) */}
<div
style={STYLE_HEADER}
onPointerDown={handlePointerDown}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#64748b', fontSize: 12 }}></span>
<strong style={{ fontSize: 13 }}> </strong>
<span style={{ color: '#64748b', fontSize: 11 }}>({vessels.length})</span>
</div>
<button style={STYLE_CLOSE_BTN} onClick={close}>
</button>
</div>
{/* 업종 + 상태 필터 */}
<div style={STYLE_FILTERS}>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={shipCodeFilter.size === VESSEL_TYPE_ORDER.length} onClick={() => toggleAllShipCodes(VESSEL_TYPE_ORDER)}>
</ToggleButton>
{VESSEL_TYPE_ORDER.map((code) => {
const meta = VESSEL_TYPES[code];
return (
<ToggleButton key={code} on={shipCodeFilter.has(code)} onClick={() => toggleShipCode(code)}>
<span style={STYLE_DOT(meta.color)} />
{code}
</ToggleButton>
);
})}
</div>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={stateFilter.size === STATE_LABELS.length} onClick={() => toggleAllStates([...STATE_LABELS])}>
</ToggleButton>
{STATE_LABELS.map((label) => (
<ToggleButton key={label} on={stateFilter.has(label)} onClick={() => toggleStateFilter(label)}>
<span style={STYLE_DOT(STATE_COLORS[label])} />
{label}
</ToggleButton>
))}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#94a3b8',
cursor: 'pointer',
marginLeft: 4,
}}
>
<input
type="checkbox"
checked={onlySailing}
onChange={(e) => setOnlySailing(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
</label>
</div>
</div>
{/* 검색 */}
<div style={STYLE_SEARCH}>
<TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
</div>
{/* 그리드 */}
<div style={STYLE_GRID}>
<VesselSelectGrid vessels={filteredVessels} selectedMmsis={selectedMmsis} toggleMmsi={toggleMmsi} setMmsis={setMmsis} />
</div>
{/* 기간 설정 바 */}
<div style={STYLE_DATE_BAR}>
{[7, 14, 21, 28].map((d) => (
<Button key={d} variant="ghost" size="sm" onClick={() => applyPresetDays(d)}>
{d}
</Button>
))}
<div style={STYLE_SEPARATOR} />
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
</div>
{/* 푸터 */}
<div style={STYLE_FOOTER}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#cbd5e1',
cursor: 'pointer',
}}
>
<input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} />
({filteredVessels.length})
</label>
{selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>}
<div style={STYLE_FOOTER_SPACER} />
<span style={{ color: '#93c5fd', fontSize: 12 }}> {selectedMmsis.size}</span>
<Button variant="primary" size="md" disabled={selectedMmsis.size === 0} onClick={() => submitQuery(vessels)}>
{isQuerying ? '재조회' : '조회 시작'}
</Button>
</div>
</div>
</div>
</>
);
}

파일 보기

@ -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,