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 { 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)) {
|
||||||
|
// 라이브 데이터 우선 사용 (m.id === featureId === signalSourceCode + targetId)
|
||||||
|
const liveShip = features.get(m.id);
|
||||||
|
if (liveShip) {
|
||||||
|
result.push(liveShip);
|
||||||
|
} else if (m.ship) {
|
||||||
result.push(m.ship);
|
result.push(m.ship);
|
||||||
|
}
|
||||||
seen.add(m.id);
|
seen.add(m.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user