diff --git a/src/components/ship/ShipDetailModal.jsx b/src/components/ship/ShipDetailModal.jsx
index 8b9b6ffd..3b0262d4 100644
--- a/src/components/ship/ShipDetailModal.jsx
+++ b/src/components/ship/ShipDetailModal.jsx
@@ -9,6 +9,14 @@
*/
import { useRef, useState, useCallback, useEffect } from 'react';
import useShipStore from '../../stores/shipStore';
+import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
+import {
+ fetchVesselTracksV2,
+ convertToProcessedTracks,
+ buildVesselListForQuery,
+ isIntegratedTargetId,
+} from '../../tracking/services/trackQueryApi';
+import { TrackQueryViewer } from '../../tracking/components/TrackQueryViewer';
import {
SHIP_KIND_LABELS,
SIGNAL_FLAG_CONFIGS,
@@ -93,7 +101,8 @@ function formatDateTime(raw) {
*/
function SignalFlags({ ship }) {
const isIntegrate = useShipStore((s) => s.isIntegrate);
- const isIntegratedShip = ship.targetId && ship.targetId.includes('_');
+ // 통합선박 판별: 언더스코어 또는 integrate 플래그
+ const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
const useIntegratedMode = isIntegrate && isIntegratedShip;
return (
@@ -197,6 +206,17 @@ export default function ShipDetailModal({ modal }) {
const closeDetailModal = useShipStore((s) => s.closeDetailModal);
const updateModalPos = useShipStore((s) => s.updateModalPos);
+ // 항적조회 패널 상태
+ const [showTrackPanel, setShowTrackPanel] = useState(false);
+ const [isQuerying, setIsQuerying] = useState(false);
+ const [timeRange, setTimeRange] = useState(() => {
+ const now = new Date();
+ const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전
+ const pad = (n) => String(n).padStart(2, '0');
+ const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ return { fromDate: toLocal(from), toDate: toLocal(now) };
+ });
+
// 드래그 상태 - 초기 위치는 스토어에서 계산된 initialPos 사용
const [position, setPosition] = useState(() => ({ ...modal.initialPos }));
const posRef = useRef(modal.initialPos);
@@ -240,7 +260,83 @@ export default function ShipDetailModal({ modal }) {
};
}, [modal.id, updateModalPos]);
+ // KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음)
+ const toKstISOString = useCallback((date) => {
+ const pad = (n, len = 2) => String(n).padStart(len, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
+ }, []);
+
+ // 항적 조회 실행 (공용)
+ const executeTrackQuery = useCallback(async (fromDate, toDate) => {
+ const { ship } = modal;
+ const startTime = new Date(fromDate);
+ const endTime = new Date(toDate);
+
+ if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return;
+ if (startTime >= endTime) return;
+
+ const isIntegrated = isIntegratedTargetId(ship.targetId);
+ const queryResult = buildVesselListForQuery(ship, 'modal');
+
+ if (!queryResult.canQuery) {
+ useTrackQueryStore.getState().setError(queryResult.errorMessage || '조회 불가');
+ return;
+ }
+
+ setIsQuerying(true);
+ const store = useTrackQueryStore.getState();
+
+ try {
+ const rawTracks = await fetchVesselTracksV2({
+ startTime: toKstISOString(startTime),
+ endTime: toKstISOString(endTime),
+ vessels: queryResult.vessels,
+ isIntegration: '0',
+ });
+ const processed = convertToProcessedTracks(rawTracks);
+ if (processed.length === 0) {
+ store.setError('항적 데이터가 없습니다.');
+ } else {
+ store.setTracks(processed, startTime.getTime());
+ }
+ } catch (e) {
+ console.error('[ShipDetailModal] 항적 조회 실패:', e);
+ store.setError('항적 조회 실패');
+ }
+ setIsQuerying(false);
+ }, [modal, toKstISOString]);
+
+ // 항적조회 패널 열기 + 즉시 3일 조회
+ const handleOpenTrackPanel = useCallback(async () => {
+ // 이전 항적 데이터 초기화
+ useTrackQueryStore.getState().reset();
+ useTrackQueryStore.getState().setModalMode(true, modal.id);
+ setShowTrackPanel(true);
+
+ // 즉시 3일 항적 조회
+ const now = new Date();
+ const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
+ const pad = (n) => String(n).padStart(2, '0');
+ const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ const newTimeRange = { fromDate: toLocal(from), toDate: toLocal(now) };
+ setTimeRange(newTimeRange);
+
+ await executeTrackQuery(from, now);
+ }, [modal.id, executeTrackQuery]);
+
+ // 항적조회 패널 닫기
+ const handleCloseTrackPanel = useCallback(() => {
+ setShowTrackPanel(false);
+ useTrackQueryStore.getState().reset();
+ }, []);
+
+ // 시간 폼에서 재조회
+ const handleTrackQuery = useCallback(async () => {
+ await executeTrackQuery(timeRange.fromDate, timeRange.toDate);
+ }, [timeRange, executeTrackQuery]);
+
const { ship, id } = modal;
+ const isIntegrated = isIntegratedTargetId(ship.targetId);
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타';
const sog = Number(ship.sog) || 0;
const cog = Number(ship.cog) || 0;
@@ -313,13 +409,32 @@ export default function ShipDetailModal({ modal }) {
-
+
{/* footer */}
데이터 수신시간 : {formattedTime}
+
+ {/* 항적조회 패널 (모달 모드) */}
+ {showTrackPanel && (
+
+ )}
);
}
diff --git a/src/map/ShipBatchRenderer.js b/src/map/ShipBatchRenderer.js
index 0f893098..9666d80c 100644
--- a/src/map/ShipBatchRenderer.js
+++ b/src/map/ShipBatchRenderer.js
@@ -428,9 +428,10 @@ function calculateAndCleanupLiveShips() {
// ⑥ 카운트 대상
const targetId = ship.targetId;
+ const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate);
- if (isIntegrate && targetId && targetId.includes('_')) {
- // 통합모드 + 통합선박 → 후보 수집
+ if (isIntegrate && isIntegratedShip) {
+ // 통합모드 + 통합선박 (언더스코어 또는 integrate 플래그) → 후보 수집
const activeKey = SOURCE_TO_ACTIVE_KEY[sourceCode];
if (!activeKey || ship[activeKey] !== '1') return;
if (!enabledSources.has(sourceCode)) return;
@@ -442,7 +443,8 @@ function calculateAndCleanupLiveShips() {
}
} else {
// 비통합 또는 단독선박
- if (sourceCode === SIGNAL_SOURCE_RADAR) return; // 단독 레이더 카운트 제외
+ // 단독 레이더(통합되지 않은 레이더)만 카운트 제외
+ if (sourceCode === SIGNAL_SOURCE_RADAR && !isIntegratedShip) return;
if (seenTargetIds.has(targetId)) return;
if (!kindVisibility[ship.signalKindCode]) return;
diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.js
index 77d835d4..9874c249 100644
--- a/src/map/layers/shipLayer.js
+++ b/src/map/layers/shipLayer.js
@@ -13,6 +13,7 @@ import {
SIGNAL_KIND_CODE_BUOY,
SIGNAL_FLAG_CONFIGS,
} from '../../types/constants';
+import useShipStore from '../../stores/shipStore';
// 아이콘 아틀라스 이미지
import atlasImg from '../../assets/img/icon/atlas.png';
@@ -206,9 +207,7 @@ export function clearClusterCache() {
* @returns {boolean} SVG 생성 가능 여부
*/
function canGenerateSignalSVG(ship, isIntegrate) {
- const isIntegratedShipTarget = ship.targetId && ship.targetId.includes('_');
-
- if (isIntegrate && isIntegratedShipTarget) {
+ if (isIntegrate && isIntegratedShip(ship)) {
// 통합선박 + 선박통합 ON: 장비 값이 '0' 또는 '1'인 것이 하나라도 있어야 함
return ship.ais === '0' || ship.ais === '1' ||
ship.vpass === '0' || ship.vpass === '1' ||
@@ -657,11 +656,43 @@ const flagSvgCache = new Map();
/**
* 통합선박 여부 판별
- * @param {string} targetId - TARGET_ID
+ * @param {Object} ship - 선박 객체
* @returns {boolean} 통합선박 여부
*/
-function isIntegratedShip(targetId) {
- return targetId && targetId.includes('_');
+function isIntegratedShip(ship) {
+ return ship.targetId && (ship.targetId.includes('_') || ship.integrate);
+}
+
+/**
+ * 통합선박의 장비 플래그 병합
+ * 동일 targetId를 공유하는 모든 feature의 장비 플래그를 합산
+ * 예: 레이더 feature(vtsRadar='1') + AIS feature(ais='1') → { ais:'1', vtsRadar:'1' }
+ * '1'(활성) > '0'(비활성) > ''(없음) 우선순위로 병합
+ * @returns {Map} targetId → 병합된 장비 플래그
+ */
+function buildMergedEquipmentFlags() {
+ const { features } = useShipStore.getState();
+ const map = new Map();
+
+ features.forEach((ship) => {
+ const targetId = ship.targetId;
+ if (!targetId || !isIntegratedShip(ship)) return;
+
+ const existing = map.get(targetId) || {};
+ for (const config of SIGNAL_FLAG_CONFIGS) {
+ const key = config.dataKey;
+ const val = ship[key];
+ // '1'이면 무조건 설정, '0'은 기존이 '1'이 아닐 때만
+ if (val === '1') {
+ existing[key] = '1';
+ } else if (val === '0' && existing[key] !== '1') {
+ existing[key] = '0';
+ }
+ }
+ map.set(targetId, existing);
+ });
+
+ return map;
}
/**
@@ -685,7 +716,7 @@ function buildFlagStateArray(ship, isIntegrate) {
const flagArray = [];
// 선박통합 ON이고 통합선박인 경우에만 통합 모드로 처리
- const useIntegratedMode = isIntegrate && isIntegratedShip(ship.targetId);
+ const useIntegratedMode = isIntegrate && isIntegratedShip(ship);
for (const config of SIGNAL_FLAG_CONFIGS) {
let isVisible = false;
@@ -818,10 +849,27 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) {
return null;
}
+ // 통합모드: 동일 targetId의 모든 feature에서 장비 플래그 병합
+ // 대표 feature(예: 레이더)에는 자기 장비 플래그만 있으므로,
+ // 같은 targetId를 공유하는 다른 feature(AIS 등)의 플래그를 합쳐야 함
+ let mergedFlagsMap = null;
+ if (isIntegrate) {
+ mergedFlagsMap = buildMergedEquipmentFlags();
+ }
+
// 신호 플래그 데이터 생성 (SVG 캐싱 적용)
const flagData = ships
.map((ship) => {
- const svg = getCachedFlagSVG(ship, isIntegrate);
+ // 통합선박이면 병합된 장비 플래그 적용
+ let effectiveShip = ship;
+ if (mergedFlagsMap && ship.targetId && ship.targetId.includes('_')) {
+ const merged = mergedFlagsMap.get(ship.targetId);
+ if (merged) {
+ effectiveShip = { ...ship, ...merged };
+ }
+ }
+
+ const svg = getCachedFlagSVG(effectiveShip, isIntegrate);
if (!svg) return null;
return {
diff --git a/src/stores/shipStore.js b/src/stores/shipStore.js
index 77804458..e9ab0a9e 100644
--- a/src/stores/shipStore.js
+++ b/src/stores/shipStore.js
@@ -113,9 +113,9 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) {
// 다크시그널 스킵
if (darkSignalIds.has(featureId)) return;
- // 단독선박 스킵 (targetId에 '_' 없음)
+ // 단독선박 스킵 (targetId에 '_' 없고 integrate 플래그도 없음)
const targetId = ship.targetId;
- if (!targetId || !targetId.includes('_')) return;
+ if (!targetId || (!targetId.includes('_') && !ship.integrate)) return;
const sourceCode = ship.signalSourceCode;
@@ -654,8 +654,31 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
*/
openDetailModal: (ship) => {
set((state) => {
+ let displayShip = ship;
+
+ // 통합선박이고 신호원이 레이더인 경우, 비레이더 신호원으로 교체
+ const isIntegrated = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
+ if (isIntegrated && ship.signalSourceCode === SIGNAL_SOURCE_CODE_RADAR) {
+ // 같은 targetId의 비레이더 신호원 중 우선순위가 가장 높은 것 찾기
+ const alternatives = Array.from(state.features.values())
+ .filter(f =>
+ f.targetId === ship.targetId &&
+ f.signalSourceCode !== SIGNAL_SOURCE_CODE_RADAR
+ )
+ .sort((a, b) => {
+ // 우선순위 정렬 (낮은 숫자 = 높은 우선순위)
+ const rankA = SOURCE_PRIORITY_RANK[a.signalSourceCode] ?? 99;
+ const rankB = SOURCE_PRIORITY_RANK[b.signalSourceCode] ?? 99;
+ return rankA - rankB;
+ });
+
+ if (alternatives.length > 0) {
+ displayShip = alternatives[0];
+ }
+ }
+
// 이미 열린 동일 선박 모달이면 무시
- if (state.detailModals.some((m) => m.id === ship.featureId)) {
+ if (state.detailModals.some((m) => m.id === displayShip.featureId)) {
return state;
}
@@ -663,7 +686,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
const basePos = state.lastModalPos || { x: 0, y: 100 };
const initialPos = { x: basePos.x + 140, y: basePos.y };
- const newModal = { ship, id: ship.featureId, initialPos };
+ const newModal = { ship: displayShip, id: displayShip.featureId, initialPos };
let modals = [...state.detailModals, newModal];
// 3개 초과 시 가장 오래된 모달 제거
@@ -816,8 +839,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
});
detailModals.forEach((m) => {
- if (m.ship && !seen.has(m.id)) {
- result.push(m.ship);
+ if (!seen.has(m.id)) {
+ // 라이브 데이터 우선 사용 (m.id === featureId === signalSourceCode + targetId)
+ const liveShip = features.get(m.id);
+ if (liveShip) {
+ result.push(liveShip);
+ } else if (m.ship) {
+ result.push(m.ship);
+ }
seen.add(m.id);
}
});