From 8a159ce8094ade2bc2798043815f853af7d22c43 Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Tue, 3 Feb 2026 08:41:28 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8B=A8=EB=8F=85=EC=84=A0=EB=B0=95+?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=8D=94=20=ED=86=B5=ED=95=A9=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - integrate 플래그를 활용한 통합선박 판별 로직 추가 - shipStore.js: buildDynamicPrioritySet에 integrate 조건 추가 - ShipBatchRenderer.js: 카운트 로직에 integrate 조건 추가 - shipLayer.js: isIntegratedShip 함수 개선 - 선박 모달 생성 로직 개선 - openDetailModal에서 레이더 대표 선박 자동 교체 - 통합선박의 비레이더 신호원 우선순위 기반 선택 - 모달 신호상태 아이콘 표시 통일 - ShipDetailModal.jsx: SignalFlags에 integrate 조건 추가 - 선박 아이콘과 모달의 신호상태 표시 로직 통일 Co-Authored-By: Claude Opus 4.5 --- src/components/ship/ShipDetailModal.jsx | 119 +++++++++++++++++++++++- src/map/ShipBatchRenderer.js | 8 +- src/map/layers/shipLayer.js | 64 +++++++++++-- src/stores/shipStore.js | 41 ++++++-- 4 files changed, 213 insertions(+), 19 deletions(-) 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); } });