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:
부모
ae48bca97a
커밋
8a159ce809
@ -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 }) {
|
||||
</ul>
|
||||
|
||||
<div className="btnWrap">
|
||||
<button type="button" className="trackBtn">항적조회</button>
|
||||
<button
|
||||
type="button"
|
||||
className="trackBtn"
|
||||
onClick={handleOpenTrackPanel}
|
||||
>
|
||||
항적조회
|
||||
</button>
|
||||
<button type="button" className="trackBtn">항로예측</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="pmFooter">데이터 수신시간 : {formattedTime}</div>
|
||||
|
||||
{/* 항적조회 패널 (모달 모드) */}
|
||||
{showTrackPanel && (
|
||||
<TrackQueryViewer
|
||||
modalMode
|
||||
isIntegrated={isIntegrated}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
onQuery={handleTrackQuery}
|
||||
isQuerying={isQuerying}
|
||||
onClose={handleCloseTrackPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<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 = [];
|
||||
|
||||
// 선박통합 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 {
|
||||
|
||||
@ -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)) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user