fix: 단독선박+레이더 통합 표시 및 모달 생성 로직 개선

- integrate 플래그를 활용한 통합선박 판별 로직 추가
  - shipStore.js: buildDynamicPrioritySet에 integrate 조건 추가
  - ShipBatchRenderer.js: 카운트 로직에 integrate 조건 추가
  - shipLayer.js: isIntegratedShip 함수 개선

- 선박 모달 생성 로직 개선
  - openDetailModal에서 레이더 대표 선박 자동 교체
  - 통합선박의 비레이더 신호원 우선순위 기반 선택

- 모달 신호상태 아이콘 표시 통일
  - ShipDetailModal.jsx: SignalFlags에 integrate 조건 추가
  - 선박 아이콘과 모달의 신호상태 표시 로직 통일

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-03 08:41:28 +09:00
부모 ae48bca97a
커밋 8a159ce809
4개의 변경된 파일213개의 추가작업 그리고 19개의 파일을 삭제

파일 보기

@ -9,6 +9,14 @@
*/ */
import { useRef, useState, useCallback, useEffect } from 'react'; import { useRef, useState, useCallback, useEffect } from 'react';
import useShipStore from '../../stores/shipStore'; 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 { import {
SHIP_KIND_LABELS, SHIP_KIND_LABELS,
SIGNAL_FLAG_CONFIGS, SIGNAL_FLAG_CONFIGS,
@ -93,7 +101,8 @@ function formatDateTime(raw) {
*/ */
function SignalFlags({ ship }) { function SignalFlags({ ship }) {
const isIntegrate = useShipStore((s) => s.isIntegrate); 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; const useIntegratedMode = isIntegrate && isIntegratedShip;
return ( return (
@ -197,6 +206,17 @@ export default function ShipDetailModal({ modal }) {
const closeDetailModal = useShipStore((s) => s.closeDetailModal); const closeDetailModal = useShipStore((s) => s.closeDetailModal);
const updateModalPos = useShipStore((s) => s.updateModalPos); 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 // - initialPos
const [position, setPosition] = useState(() => ({ ...modal.initialPos })); const [position, setPosition] = useState(() => ({ ...modal.initialPos }));
const posRef = useRef(modal.initialPos); const posRef = useRef(modal.initialPos);
@ -240,7 +260,83 @@ export default function ShipDetailModal({ modal }) {
}; };
}, [modal.id, updateModalPos]); }, [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 { ship, id } = modal;
const isIntegrated = isIntegratedTargetId(ship.targetId);
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타'; const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타';
const sog = Number(ship.sog) || 0; const sog = Number(ship.sog) || 0;
const cog = Number(ship.cog) || 0; const cog = Number(ship.cog) || 0;
@ -313,13 +409,32 @@ export default function ShipDetailModal({ modal }) {
</ul> </ul>
<div className="btnWrap"> <div className="btnWrap">
<button type="button" className="trackBtn">항적조회</button> <button
type="button"
className="trackBtn"
onClick={handleOpenTrackPanel}
>
항적조회
</button>
<button type="button" className="trackBtn">항로예측</button> <button type="button" className="trackBtn">항로예측</button>
</div> </div>
</div> </div>
{/* footer */} {/* footer */}
<div className="pmFooter">데이터 수신시간 : {formattedTime}</div> <div className="pmFooter">데이터 수신시간 : {formattedTime}</div>
{/* 항적조회 패널 (모달 모드) */}
{showTrackPanel && (
<TrackQueryViewer
modalMode
isIntegrated={isIntegrated}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
onQuery={handleTrackQuery}
isQuerying={isQuerying}
onClose={handleCloseTrackPanel}
/>
)}
</div> </div>
); );
} }

파일 보기

@ -428,9 +428,10 @@ function calculateAndCleanupLiveShips() {
// ⑥ 카운트 대상 // ⑥ 카운트 대상
const targetId = ship.targetId; 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]; const activeKey = SOURCE_TO_ACTIVE_KEY[sourceCode];
if (!activeKey || ship[activeKey] !== '1') return; if (!activeKey || ship[activeKey] !== '1') return;
if (!enabledSources.has(sourceCode)) return; if (!enabledSources.has(sourceCode)) return;
@ -442,7 +443,8 @@ function calculateAndCleanupLiveShips() {
} }
} else { } else {
// 비통합 또는 단독선박 // 비통합 또는 단독선박
if (sourceCode === SIGNAL_SOURCE_RADAR) return; // 단독 레이더 카운트 제외 // 단독 레이더(통합되지 않은 레이더)만 카운트 제외
if (sourceCode === SIGNAL_SOURCE_RADAR && !isIntegratedShip) return;
if (seenTargetIds.has(targetId)) return; if (seenTargetIds.has(targetId)) return;
if (!kindVisibility[ship.signalKindCode]) return; if (!kindVisibility[ship.signalKindCode]) return;

파일 보기

@ -13,6 +13,7 @@ import {
SIGNAL_KIND_CODE_BUOY, SIGNAL_KIND_CODE_BUOY,
SIGNAL_FLAG_CONFIGS, SIGNAL_FLAG_CONFIGS,
} from '../../types/constants'; } from '../../types/constants';
import useShipStore from '../../stores/shipStore';
// 아이콘 아틀라스 이미지 // 아이콘 아틀라스 이미지
import atlasImg from '../../assets/img/icon/atlas.png'; import atlasImg from '../../assets/img/icon/atlas.png';
@ -206,9 +207,7 @@ export function clearClusterCache() {
* @returns {boolean} SVG 생성 가능 여부 * @returns {boolean} SVG 생성 가능 여부
*/ */
function canGenerateSignalSVG(ship, isIntegrate) { function canGenerateSignalSVG(ship, isIntegrate) {
const isIntegratedShipTarget = ship.targetId && ship.targetId.includes('_'); if (isIntegrate && isIntegratedShip(ship)) {
if (isIntegrate && isIntegratedShipTarget) {
// 통합선박 + 선박통합 ON: 장비 값이 '0' 또는 '1'인 것이 하나라도 있어야 함 // 통합선박 + 선박통합 ON: 장비 값이 '0' 또는 '1'인 것이 하나라도 있어야 함
return ship.ais === '0' || ship.ais === '1' || return ship.ais === '0' || ship.ais === '1' ||
ship.vpass === '0' || ship.vpass === '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} 통합선박 여부 * @returns {boolean} 통합선박 여부
*/ */
function isIntegratedShip(targetId) { function isIntegratedShip(ship) {
return targetId && targetId.includes('_'); 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<string, Object>} 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 = []; const flagArray = [];
// 선박통합 ON이고 통합선박인 경우에만 통합 모드로 처리 // 선박통합 ON이고 통합선박인 경우에만 통합 모드로 처리
const useIntegratedMode = isIntegrate && isIntegratedShip(ship.targetId); const useIntegratedMode = isIntegrate && isIntegratedShip(ship);
for (const config of SIGNAL_FLAG_CONFIGS) { for (const config of SIGNAL_FLAG_CONFIGS) {
let isVisible = false; let isVisible = false;
@ -818,10 +849,27 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) {
return null; return null;
} }
// 통합모드: 동일 targetId의 모든 feature에서 장비 플래그 병합
// 대표 feature(예: 레이더)에는 자기 장비 플래그만 있으므로,
// 같은 targetId를 공유하는 다른 feature(AIS 등)의 플래그를 합쳐야 함
let mergedFlagsMap = null;
if (isIntegrate) {
mergedFlagsMap = buildMergedEquipmentFlags();
}
// 신호 플래그 데이터 생성 (SVG 캐싱 적용) // 신호 플래그 데이터 생성 (SVG 캐싱 적용)
const flagData = ships const flagData = ships
.map((ship) => { .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; if (!svg) return null;
return { return {

파일 보기

@ -113,9 +113,9 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) {
// 다크시그널 스킵 // 다크시그널 스킵
if (darkSignalIds.has(featureId)) return; if (darkSignalIds.has(featureId)) return;
// 단독선박 스킵 (targetId에 '_' 없음) // 단독선박 스킵 (targetId에 '_' 없고 integrate 플래그도 없음)
const targetId = ship.targetId; const targetId = ship.targetId;
if (!targetId || !targetId.includes('_')) return; if (!targetId || (!targetId.includes('_') && !ship.integrate)) return;
const sourceCode = ship.signalSourceCode; const sourceCode = ship.signalSourceCode;
@ -654,8 +654,31 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
*/ */
openDetailModal: (ship) => { openDetailModal: (ship) => {
set((state) => { 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; return state;
} }
@ -663,7 +686,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
const basePos = state.lastModalPos || { x: 0, y: 100 }; const basePos = state.lastModalPos || { x: 0, y: 100 };
const initialPos = { x: basePos.x + 140, y: basePos.y }; 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]; let modals = [...state.detailModals, newModal];
// 3개 초과 시 가장 오래된 모달 제거 // 3개 초과 시 가장 오래된 모달 제거
@ -816,8 +839,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
}); });
detailModals.forEach((m) => { detailModals.forEach((m) => {
if (m.ship && !seen.has(m.id)) { if (!seen.has(m.id)) {
result.push(m.ship); // 라이브 데이터 우선 사용 (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); seen.add(m.id);
} }
}); });