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
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 35s
Reviewed-on: #52
This commit is contained in:
커밋
ff6a7cc710
@ -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";
|
||||
|
||||
152
apps/web/src/app/styles/components/vessel-select-modal.css
Normal file
152
apps/web/src/app/styles/components/vessel-select-modal.css
Normal file
@ -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) {
|
||||
byMmsi.set(m, v);
|
||||
continue;
|
||||
}
|
||||
if (score(v) > score(prev)) byMmsi.set(m, v);
|
||||
if (byMmsi.has(m)) continue;
|
||||
byMmsi.set(m, v);
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,19 +57,6 @@ export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): L
|
||||
const hit = idx.byMmsi.get(mmsi);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const nameKey = t.name ? normalizeShipName(t.name) : "";
|
||||
if (nameKey) {
|
||||
const hit = idx.byName.get(nameKey);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
|
||||
if (csKey) {
|
||||
const hit = idx.byName.get(csKey);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
273
apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts
Normal file
273
apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
3
apps/web/src/features/vesselSelect/index.ts
Normal file
3
apps/web/src/features/vesselSelect/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type { VesselDescriptor } from './model/types';
|
||||
export { MAX_VESSEL_SELECT } from './model/types';
|
||||
export { useVesselSelectModal } from './hooks/useVesselSelectModal';
|
||||
19
apps/web/src/features/vesselSelect/model/types.ts
Normal file
19
apps/web/src/features/vesselSelect/model/types.ts
Normal file
@ -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',
|
||||
|
||||
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
@ -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 < 10 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
||||
SOG < 1 kt
|
||||
</div>
|
||||
<div className="li">
|
||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
||||
SOG unknown
|
||||
</div>
|
||||
<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>({
|
||||
id: 'alarm-pulse',
|
||||
data: alarmTargets,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: (d) => {
|
||||
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
||||
return isHover ? hoverR : normalR;
|
||||
},
|
||||
getFillColor: (d) => {
|
||||
const kind = alarmMmsiMap.get(d.mmsi);
|
||||
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||
},
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
updateTriggers: { getRadius: [normalR, hoverR] },
|
||||
});
|
||||
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
|
||||
if (hasAlarms) {
|
||||
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
|
||||
|
||||
const updated = mercatorLayersRef.current.map((l) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||
);
|
||||
const pulseLyr = new IconLayer<AisTarget>({
|
||||
id: 'alarm-pulse',
|
||||
data: alarmTargets,
|
||||
pickable: false,
|
||||
billboard: true,
|
||||
iconAtlas: ALARM_RING_ICON_URL,
|
||||
iconMapping: ALARM_RING_ICON_MAPPING,
|
||||
getIcon: () => 'ring',
|
||||
sizeUnits: 'pixels',
|
||||
sizeScale: 0.9 + tA * 0.2,
|
||||
opacity: 0.2 + tA * 0.7,
|
||||
getSize: (d) => {
|
||||
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
||||
return isHover
|
||||
? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2
|
||||
: (FLAT_LEGACY_HALO_RADIUS + 8) * 2;
|
||||
},
|
||||
getColor: (d) => {
|
||||
const kind = alarmMmsiMap!.get(d.mmsi);
|
||||
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
|
||||
},
|
||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||
updateTriggers: {
|
||||
getSize: [selectedMmsi],
|
||||
},
|
||||
});
|
||||
updated = updated.map((l) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 대상 선박 브리딩 링
|
||||
if (hasTargetOverlays) {
|
||||
const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||
const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN);
|
||||
const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN);
|
||||
const alpha = Math.round(155 + tH * 100);
|
||||
|
||||
const haloLyr = new ScatterplotLayer<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,103 +147,79 @@ export function GlobalTrackReplayPanel() {
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTrackQuery()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(148,163,184,0.5)',
|
||||
background: 'rgba(30,41,59,0.7)',
|
||||
color: '#e2e8f0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
<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={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
선박 목록
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTrackQuery()}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(148,163,184,0.5)',
|
||||
background: 'rgba(30,41,59,0.7)',
|
||||
color: '#e2e8f0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
203
apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx
Normal file
203
apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
410
apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx
Normal file
410
apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx
Normal file
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user