2026-01-30 13:01:54 +09:00
|
|
|
/**
|
|
|
|
|
* 선박 데이터 Zustand 스토어
|
|
|
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts
|
|
|
|
|
* 참조: mda-react-front/src/common/deck.ts (카운트 쓰로틀링)
|
|
|
|
|
*/
|
|
|
|
|
import { create } from 'zustand';
|
|
|
|
|
import { subscribeWithSelector } from 'zustand/middleware';
|
|
|
|
|
import {
|
|
|
|
|
SIGNAL_KIND_CODE_FISHING,
|
|
|
|
|
SIGNAL_KIND_CODE_KCGV,
|
|
|
|
|
SIGNAL_KIND_CODE_PASSENGER,
|
|
|
|
|
SIGNAL_KIND_CODE_CARGO,
|
|
|
|
|
SIGNAL_KIND_CODE_TANKER,
|
|
|
|
|
SIGNAL_KIND_CODE_GOV,
|
|
|
|
|
SIGNAL_KIND_CODE_NORMAL,
|
|
|
|
|
SIGNAL_KIND_CODE_BUOY,
|
|
|
|
|
SIGNAL_SOURCE_CODE_AIS,
|
|
|
|
|
SIGNAL_SOURCE_CODE_VPASS,
|
|
|
|
|
SIGNAL_SOURCE_CODE_ENAV,
|
|
|
|
|
SIGNAL_SOURCE_CODE_VTS_AIS,
|
|
|
|
|
SIGNAL_SOURCE_CODE_D_MF_HF,
|
|
|
|
|
SIGNAL_SOURCE_CODE_RADAR,
|
|
|
|
|
NATIONAL_CODE_KR,
|
|
|
|
|
NATIONAL_CODE_CN,
|
|
|
|
|
NATIONAL_CODE_JP,
|
|
|
|
|
NATIONAL_CODE_KP,
|
|
|
|
|
NATIONAL_CODE_OTHER,
|
|
|
|
|
} from '../types/constants';
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 국적 코드 매핑 (ShipBatchRenderer.js와 동일)
|
|
|
|
|
// =====================
|
|
|
|
|
function mapNationalCode(nationalCode) {
|
|
|
|
|
if (!nationalCode) return 'OTHER';
|
|
|
|
|
const code = nationalCode.toUpperCase();
|
|
|
|
|
if (code === 'KR' || code === 'KOR' || code === '440') return 'KR';
|
|
|
|
|
if (code === 'CN' || code === 'CHN' || code === '412' || code === '413' || code === '414') return 'CN';
|
|
|
|
|
if (code === 'JP' || code === 'JPN' || code === '431' || code === '432') return 'JP';
|
|
|
|
|
if (code === 'KP' || code === 'PRK' || code === '445') return 'KP';
|
|
|
|
|
return 'OTHER';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 서버 수신시간 파싱 (receivedTime → ms timestamp)
|
|
|
|
|
// 형식: "YYYYMMDDHHmmss"
|
|
|
|
|
// =====================
|
|
|
|
|
function parseReceivedTime(receivedTime) {
|
|
|
|
|
if (!receivedTime || receivedTime.length < 14) return Date.now();
|
|
|
|
|
const y = receivedTime.slice(0, 4);
|
|
|
|
|
const M = receivedTime.slice(4, 6);
|
|
|
|
|
const d = receivedTime.slice(6, 8);
|
|
|
|
|
const h = receivedTime.slice(8, 10);
|
|
|
|
|
const m = receivedTime.slice(10, 12);
|
|
|
|
|
const s = receivedTime.slice(12, 14);
|
|
|
|
|
const ts = new Date(`${y}-${M}-${d}T${h}:${m}:${s}`).getTime();
|
|
|
|
|
return isNaN(ts) ? Date.now() : ts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 타임아웃 상수 (카운트 사이클에서 상태 전환/삭제 판정)
|
|
|
|
|
// =====================
|
|
|
|
|
//
|
|
|
|
|
// ■ 영해안 (LOST=0, Inshore)
|
|
|
|
|
// 국내 직접 수집수단(AIS 기지국, VTS 등)이 커버하는 해역.
|
|
|
|
|
// 수신 주기가 짧으므로(수 초~수 분) 12분 무수신 시 정상 이탈로 판단하여 삭제.
|
|
|
|
|
//
|
|
|
|
|
// ■ 영해밖 (LOST=1, Offshore)
|
|
|
|
|
// 직접 수집수단이 닿지 않아 위성 AIS(S-AIS) 등 간접 수단에 의존.
|
|
|
|
|
// 위성 AIS는 선박 위치·궤도에 따라 수신 간격이 30분~최대 1시간까지 벌어질 수 있어,
|
|
|
|
|
// 유효한 항해 중인 선박이 다크시그널로 오판되지 않도록 65분(3900초)으로 설정.
|
|
|
|
|
//
|
|
|
|
|
// ■ 레이더 (단독, 비통합)
|
|
|
|
|
// 레이더 신호는 실시간 회전 주기(수 초)에 맞춰 갱신되므로 타임아웃을 짧게 유지.
|
|
|
|
|
// 함정용은 /topic/ship-throttled-60s 채널 기반이므로 90초로 설정.
|
|
|
|
|
//
|
|
|
|
|
// 참조: mda-react-front/src/common/deck.ts
|
|
|
|
|
// 추후 사용자 설정 화면에서 커스텀 가능하도록 상수로 분리.
|
|
|
|
|
// =====================
|
|
|
|
|
const INSHORE_TIMEOUT_MS = 12 * 60 * 1000; // 720초 (12분) — 영해안: LOST=0, 무수신 시 삭제
|
|
|
|
|
const OFFSHORE_TIMEOUT_MS = 65 * 60 * 1000; // 3900초 (65분) — 영해밖: LOST=1, 무수신 시 다크시그널 전환
|
|
|
|
|
const RADAR_TIMEOUT_MS = 90 * 1000; // 90초 — 단독 레이더 비통합, 무수신 시 삭제
|
|
|
|
|
const SIGNAL_SOURCE_RADAR = '000005';
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 장비 활성 상태 판단
|
|
|
|
|
// 참조: mda-react-front/src/common/deck.ts - isAnyEquipmentActive
|
|
|
|
|
// AVETDR 6개 장비 중 하나라도 '1'(활성)이면 true
|
|
|
|
|
// =====================
|
|
|
|
|
const EQUIPMENT_KEYS = ['ais', 'vpass', 'enav', 'vtsAis', 'dMfHf', 'vtsRadar'];
|
|
|
|
|
|
|
|
|
|
function isAnyEquipmentActive(ship) {
|
|
|
|
|
return EQUIPMENT_KEYS.some(key => ship[key] === '1');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =====================
|
2026-01-30 13:23:26 +09:00
|
|
|
// 타임아웃 체크 쓰로틀 간격
|
2026-01-30 13:01:54 +09:00
|
|
|
// 참조: mda-react-front/src/common/deck.ts (271-331)
|
|
|
|
|
// =====================
|
2026-01-30 13:27:03 +09:00
|
|
|
/**
|
|
|
|
|
* 초기 선박 종류별 카운트
|
|
|
|
|
*/
|
|
|
|
|
const initialKindCounts = {
|
|
|
|
|
[SIGNAL_KIND_CODE_FISHING]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_KCGV]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_PASSENGER]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_CARGO]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_TANKER]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_GOV]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_NORMAL]: 0,
|
|
|
|
|
[SIGNAL_KIND_CODE_BUOY]: 0,
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-30 13:23:26 +09:00
|
|
|
// =====================
|
2026-01-30 13:39:19 +09:00
|
|
|
// 카운트 쓰로틀링 (5초 주기)
|
|
|
|
|
// 참조: mda-react-front/src/common/deck.ts (271-331)
|
2026-01-30 13:23:26 +09:00
|
|
|
// =====================
|
2026-01-30 13:39:19 +09:00
|
|
|
const COUNT_THROTTLE_MS = 5000; // 5초
|
|
|
|
|
|
|
|
|
|
/** 마지막 카운트 계산 시간 */
|
|
|
|
|
let lastCountTime = 0;
|
2026-01-30 13:23:26 +09:00
|
|
|
|
|
|
|
|
/** 마지막 타임아웃 체크 시간 */
|
|
|
|
|
let lastTimeoutCheckTime = 0;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
|
|
|
|
/**
|
2026-01-30 13:39:19 +09:00
|
|
|
* 전체 카운트 계산 (targetId 중복 제거 포함)
|
2026-01-30 13:23:26 +09:00
|
|
|
*
|
2026-01-30 13:39:19 +09:00
|
|
|
* 처리 순서 (메인 프로젝트 동일):
|
|
|
|
|
* ① darkSignalIds.has → darkCount++
|
|
|
|
|
* ② 단독 레이더 → 카운트 제외
|
|
|
|
|
* ⑤ !isAnyEquipmentActive → darkCount++
|
|
|
|
|
* ⑥ isPriority 필터 + 선종/신호원/국적 필터 → kindCounts++
|
|
|
|
|
* + targetId 기준 중복 제거
|
2026-01-30 13:23:26 +09:00
|
|
|
*
|
|
|
|
|
* @param {Map} features
|
|
|
|
|
* @param {Set} darkSignalIds
|
|
|
|
|
* @param {boolean} isIntegrate
|
|
|
|
|
* @param {Object} kindVisibility
|
|
|
|
|
* @param {Object} sourceVisibility
|
|
|
|
|
* @param {Object} nationalVisibility
|
2026-01-30 13:39:19 +09:00
|
|
|
* @returns {{ kindCounts: Object, darkSignalCount: number, totalCount: number }}
|
2026-01-30 13:23:26 +09:00
|
|
|
*/
|
2026-01-30 13:39:19 +09:00
|
|
|
function calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) {
|
|
|
|
|
const kindCounts = { ...initialKindCounts };
|
|
|
|
|
let darkSignalCount = 0;
|
2026-01-30 13:23:26 +09:00
|
|
|
const seenTargetIds = new Set();
|
|
|
|
|
|
|
|
|
|
features.forEach((ship, featureId) => {
|
2026-01-30 13:39:19 +09:00
|
|
|
// ① 다크시그널
|
|
|
|
|
if (darkSignalIds.has(featureId)) {
|
|
|
|
|
darkSignalCount++;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-30 13:23:26 +09:00
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// ② 단독 레이더 → 카운트 제외
|
|
|
|
|
if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) return;
|
|
|
|
|
|
|
|
|
|
// ⑤ 모든 장비 비활성 → 다크시그널
|
|
|
|
|
if (!isAnyEquipmentActive(ship)) {
|
|
|
|
|
darkSignalCount++;
|
|
|
|
|
return;
|
2026-01-30 13:23:26 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// ⑥ 통합 모드: isPriority가 아니면 카운트 제외
|
|
|
|
|
if (isIntegrate && ship.integrate && !ship.isPriority) return;
|
|
|
|
|
|
|
|
|
|
// 필터 적용
|
|
|
|
|
if (!kindVisibility[ship.signalKindCode]) return;
|
|
|
|
|
if (!sourceVisibility[ship.signalSourceCode]) return;
|
|
|
|
|
const mapped = mapNationalCode(ship.nationalCode);
|
|
|
|
|
if (!nationalVisibility[mapped]) return;
|
|
|
|
|
|
|
|
|
|
// targetId 중복 제거
|
|
|
|
|
if (ship.targetId && seenTargetIds.has(ship.targetId)) return;
|
|
|
|
|
if (ship.targetId) seenTargetIds.add(ship.targetId);
|
|
|
|
|
|
|
|
|
|
if (ship.signalKindCode && kindCounts[ship.signalKindCode] !== undefined) {
|
|
|
|
|
kindCounts[ship.signalKindCode]++;
|
2026-01-30 13:23:26 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
const totalCount = Object.values(kindCounts).reduce((a, b) => a + b, 0);
|
|
|
|
|
return { kindCounts, darkSignalCount, totalCount };
|
2026-01-30 13:01:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 초기 선박 종류별 표시 설정
|
|
|
|
|
*/
|
|
|
|
|
const initialKindVisibility = {
|
|
|
|
|
[SIGNAL_KIND_CODE_FISHING]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_KCGV]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_PASSENGER]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_CARGO]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_TANKER]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_GOV]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_NORMAL]: true,
|
|
|
|
|
[SIGNAL_KIND_CODE_BUOY]: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 초기 신호원별 표시 설정
|
|
|
|
|
*/
|
|
|
|
|
const initialSourceVisibility = {
|
|
|
|
|
[SIGNAL_SOURCE_CODE_AIS]: true,
|
|
|
|
|
[SIGNAL_SOURCE_CODE_VPASS]: true,
|
|
|
|
|
[SIGNAL_SOURCE_CODE_ENAV]: true,
|
|
|
|
|
[SIGNAL_SOURCE_CODE_VTS_AIS]: true,
|
|
|
|
|
[SIGNAL_SOURCE_CODE_D_MF_HF]: true,
|
|
|
|
|
[SIGNAL_SOURCE_CODE_RADAR]: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 초기 국적별 표시 설정
|
|
|
|
|
*/
|
|
|
|
|
const initialNationalVisibility = {
|
|
|
|
|
[NATIONAL_CODE_KR]: true,
|
|
|
|
|
[NATIONAL_CODE_CN]: true,
|
|
|
|
|
[NATIONAL_CODE_JP]: true,
|
|
|
|
|
[NATIONAL_CODE_KP]: true,
|
|
|
|
|
[NATIONAL_CODE_OTHER]: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 스토어
|
|
|
|
|
*/
|
|
|
|
|
const useShipStore = create(subscribeWithSelector((set, get) => ({
|
|
|
|
|
// =====================
|
|
|
|
|
// 상태 (State)
|
|
|
|
|
// =====================
|
|
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
/** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId
|
|
|
|
|
* ※ 성능 최적화: Map 인스턴스를 직접 mutate하고 featuresVersion으로 변경 감지
|
|
|
|
|
* (5000척 기준 new Map() 전체 복사 제거 → 배치당 O(batch)만 발생) */
|
2026-01-30 13:01:54 +09:00
|
|
|
features: new Map(),
|
|
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
/** features 변경 버전 카운터 (Zustand 참조 동등성 감지용)
|
|
|
|
|
* features Map은 동일 인스턴스를 유지하면서 내부만 변경하므로,
|
|
|
|
|
* 구독자가 변경을 감지할 수 있도록 버전 번호를 증가시킨다. */
|
|
|
|
|
featuresVersion: 0,
|
|
|
|
|
|
2026-01-30 13:01:54 +09:00
|
|
|
/** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조)
|
2026-01-30 13:06:56 +09:00
|
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds
|
|
|
|
|
* ※ 성능 최적화: Set 인스턴스를 직접 mutate하고 darkSignalVersion으로 변경 감지 */
|
2026-01-30 13:01:54 +09:00
|
|
|
darkSignalIds: new Set(),
|
|
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
/** darkSignalIds 변경 버전 카운터 */
|
|
|
|
|
darkSignalVersion: 0,
|
|
|
|
|
|
2026-01-30 13:01:54 +09:00
|
|
|
/** 선박 종류별 카운트 */
|
|
|
|
|
kindCounts: { ...initialKindCounts },
|
|
|
|
|
|
|
|
|
|
/** 선박 종류별 표시 여부 */
|
|
|
|
|
kindVisibility: { ...initialKindVisibility },
|
|
|
|
|
|
|
|
|
|
/** 신호원별 표시 여부 */
|
|
|
|
|
sourceVisibility: { ...initialSourceVisibility },
|
|
|
|
|
|
|
|
|
|
/** 국적별 표시 여부 */
|
|
|
|
|
nationalVisibility: { ...initialNationalVisibility },
|
|
|
|
|
|
|
|
|
|
/** 선택된 선박 ID (단일 클릭용, 레거시) */
|
|
|
|
|
selectedShipId: null,
|
|
|
|
|
|
|
|
|
|
/** Ctrl+Drag 다중 선택된 featureId 배열 (제한 없음) */
|
|
|
|
|
selectedShipIds: [],
|
|
|
|
|
|
|
|
|
|
/** 컨텍스트 메뉴 상태 { x, y, ships: [] } | null */
|
|
|
|
|
contextMenu: null,
|
|
|
|
|
|
|
|
|
|
/** 호버 중인 선박 정보 { ship, x, y } | null */
|
|
|
|
|
hoverInfo: null,
|
|
|
|
|
|
|
|
|
|
/** 상세 모달 배열 (최대 3개) [{ ship, id, initialPos }] */
|
|
|
|
|
detailModals: [],
|
|
|
|
|
|
|
|
|
|
/** 마지막 모달 위치 (새 모달 초기 위치 계산용) */
|
|
|
|
|
lastModalPos: null,
|
|
|
|
|
|
|
|
|
|
/** 다크시그널(소실신호) 표시 여부 */
|
|
|
|
|
darkSignalVisible: true,
|
|
|
|
|
|
|
|
|
|
/** 다크시그널 선박 수 */
|
|
|
|
|
darkSignalCount: 0,
|
|
|
|
|
|
|
|
|
|
/** 선박 표시 On/Off */
|
|
|
|
|
isShipVisible: true,
|
|
|
|
|
|
|
|
|
|
/** 선박 통합 모드 (통합선박에서 isPriority만 표시) */
|
|
|
|
|
isIntegrate: true,
|
|
|
|
|
|
|
|
|
|
/** 선명표시 여부 (개발 중 기본 활성화) */
|
|
|
|
|
showLabels: true,
|
|
|
|
|
|
|
|
|
|
/** 선명표시 옵션 (개발 중 기본 모두 활성화) */
|
|
|
|
|
labelOptions: {
|
|
|
|
|
showShipName: true, // 선박명
|
|
|
|
|
showSpeedVector: true, // 속도벡터
|
|
|
|
|
showShipSize: true, // 선박크기
|
|
|
|
|
showSignalStatus: true, // 신호상태
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** STOMP 연결 상태 */
|
|
|
|
|
isConnected: false,
|
|
|
|
|
|
|
|
|
|
/** 범례 표시 여부 */
|
|
|
|
|
showLegend: true,
|
|
|
|
|
|
|
|
|
|
/** 변경된 선박 ID 추적 (렌더링 최적화용) */
|
|
|
|
|
changedIds: new Set(),
|
|
|
|
|
|
|
|
|
|
/** 총 선박 수 */
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 액션 (Actions)
|
|
|
|
|
// =====================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 여러 선박 데이터 병합 (bulk update)
|
|
|
|
|
* 카운트는 5초 쓰로틀로 업데이트 (메인 프로젝트 동일)
|
|
|
|
|
* @param {Array} ships - 선박 데이터 배열
|
|
|
|
|
*/
|
|
|
|
|
mergeFeatures: (ships) => {
|
2026-01-30 13:39:19 +09:00
|
|
|
// ※ 성능 최적화: Map/Set을 직접 mutate (O(n) 전체 복사 제거)
|
|
|
|
|
// Zustand 변경 감지는 featuresVersion 카운터로 트리거
|
2026-01-30 13:06:56 +09:00
|
|
|
const state = get();
|
2026-01-30 13:39:19 +09:00
|
|
|
const { features, darkSignalIds } = state;
|
2026-01-30 13:06:56 +09:00
|
|
|
let darkChanged = false;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
ships.forEach((ship) => {
|
|
|
|
|
const featureId = ship.featureId;
|
|
|
|
|
if (!featureId) return;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
// 좌표가 없으면 스킵
|
|
|
|
|
if (!ship.longitude || !ship.latitude) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:48:29 +09:00
|
|
|
// 타임스탬프 비교: 기존 데이터보다 오래된 메시지는 무시
|
|
|
|
|
// 참조: mda-react-front/src/shared/model/deckStore.ts - mergeFeatures (line 163)
|
|
|
|
|
const newTimestamp = parseReceivedTime(ship.receivedTime);
|
|
|
|
|
const currentFeature = features.get(featureId);
|
|
|
|
|
if (currentFeature && newTimestamp < currentFeature.receivedTimestamp) {
|
|
|
|
|
return; // 이전 시간대 데이터 → 무시
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
const hasActive = isAnyEquipmentActive(ship);
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
// 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제)
|
|
|
|
|
if (!ship.lost && !hasActive) {
|
|
|
|
|
features.delete(featureId);
|
|
|
|
|
if (darkSignalIds.delete(featureId)) darkChanged = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
// 다크시그널 상태 판정
|
|
|
|
|
if (hasActive) {
|
|
|
|
|
if (darkSignalIds.delete(featureId)) darkChanged = true;
|
|
|
|
|
} else {
|
|
|
|
|
if (!darkSignalIds.has(featureId)) {
|
|
|
|
|
darkSignalIds.add(featureId);
|
|
|
|
|
darkChanged = true;
|
2026-01-30 13:01:54 +09:00
|
|
|
}
|
2026-01-30 13:06:56 +09:00
|
|
|
}
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:48:29 +09:00
|
|
|
features.set(featureId, { ...ship, receivedTimestamp: newTimestamp });
|
2026-01-30 13:01:54 +09:00
|
|
|
});
|
|
|
|
|
|
2026-01-30 13:23:26 +09:00
|
|
|
// 버전 카운터 증가
|
2026-01-30 13:06:56 +09:00
|
|
|
set((s) => ({
|
|
|
|
|
featuresVersion: s.featuresVersion + 1,
|
|
|
|
|
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// 타임아웃 체크 + 카운트 갱신 (5초 쓰로틀)
|
|
|
|
|
get().updateCountsThrottled();
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-30 13:39:19 +09:00
|
|
|
* 쓰로틀 카운트 업데이트 (5초 주기)
|
|
|
|
|
* 타임아웃 체크 + 카운트 재계산을 5초마다 수행
|
|
|
|
|
* 참조: mda-react-front/src/common/deck.ts - updateLayerData()
|
2026-01-30 13:23:26 +09:00
|
|
|
*
|
2026-01-30 13:39:19 +09:00
|
|
|
* 타임아웃 처리 순서 (메인 프로젝트 동일):
|
2026-01-30 13:23:26 +09:00
|
|
|
* ② 레이더(000005)+비통합 → timeout? delete
|
|
|
|
|
* ③ LOST=0 + INSHORE timeout → delete
|
|
|
|
|
* ④ LOST=1 + OFFSHORE timeout → darkSignal 전환
|
|
|
|
|
* ⑤ !isAnyEquipmentActive → darkSignal 전환
|
2026-01-30 13:01:54 +09:00
|
|
|
*/
|
2026-01-30 13:39:19 +09:00
|
|
|
updateCountsThrottled: () => {
|
2026-01-30 13:01:54 +09:00
|
|
|
const now = Date.now();
|
2026-01-30 13:39:19 +09:00
|
|
|
if (now - lastCountTime < COUNT_THROTTLE_MS) return;
|
|
|
|
|
lastCountTime = now;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// === 타임아웃 체크 ===
|
2026-01-30 13:23:26 +09:00
|
|
|
const state = get();
|
|
|
|
|
const { features, darkSignalIds } = state;
|
|
|
|
|
const newDarkIds = [];
|
|
|
|
|
const deleteIds = [];
|
2026-01-30 13:01:54 +09:00
|
|
|
|
|
|
|
|
features.forEach((ship, featureId) => {
|
2026-01-30 13:23:26 +09:00
|
|
|
if (darkSignalIds.has(featureId)) return;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
|
|
|
|
if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) {
|
2026-01-30 13:23:26 +09:00
|
|
|
if (now - ship.receivedTimestamp > RADAR_TIMEOUT_MS) deleteIds.push(featureId);
|
2026-01-30 13:01:54 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const elapsed = now - ship.receivedTimestamp;
|
|
|
|
|
|
|
|
|
|
if (!ship.lost && elapsed > INSHORE_TIMEOUT_MS) {
|
|
|
|
|
deleteIds.push(featureId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ship.lost && elapsed > OFFSHORE_TIMEOUT_MS) {
|
|
|
|
|
newDarkIds.push(featureId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isAnyEquipmentActive(ship)) {
|
|
|
|
|
newDarkIds.push(featureId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// 타임아웃된 선박 처리
|
2026-01-30 13:23:26 +09:00
|
|
|
newDarkIds.forEach(fid => darkSignalIds.add(fid));
|
|
|
|
|
deleteIds.forEach(fid => {
|
|
|
|
|
features.delete(fid);
|
|
|
|
|
darkSignalIds.delete(fid);
|
|
|
|
|
});
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// === 카운트 계산 (targetId 중복 제거 포함) ===
|
|
|
|
|
const { isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = get();
|
|
|
|
|
const counts = calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility);
|
|
|
|
|
|
|
|
|
|
const hasTimeoutChanges = newDarkIds.length > 0 || deleteIds.length > 0;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:23:26 +09:00
|
|
|
set((s) => ({
|
2026-01-30 13:39:19 +09:00
|
|
|
...(hasTimeoutChanges ? {
|
|
|
|
|
featuresVersion: s.featuresVersion + 1,
|
|
|
|
|
darkSignalVersion: s.darkSignalVersion + 1,
|
|
|
|
|
} : {}),
|
|
|
|
|
kindCounts: counts.kindCounts,
|
|
|
|
|
totalCount: counts.totalCount,
|
|
|
|
|
darkSignalCount: counts.darkSignalCount,
|
2026-01-30 13:23:26 +09:00
|
|
|
}));
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-30 13:39:19 +09:00
|
|
|
* 카운트 즉시 재계산 (필터 변경, 통합모드 전환 시)
|
2026-01-30 13:23:26 +09:00
|
|
|
* targetId 중복 제거 포함 전체 O(n) 순회
|
2026-01-30 13:01:54 +09:00
|
|
|
*/
|
|
|
|
|
recalculateCounts: () => {
|
2026-01-30 13:23:26 +09:00
|
|
|
const state = get();
|
|
|
|
|
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state;
|
2026-01-30 13:39:19 +09:00
|
|
|
const counts = calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility);
|
2026-01-30 13:23:26 +09:00
|
|
|
|
|
|
|
|
set({
|
2026-01-30 13:39:19 +09:00
|
|
|
kindCounts: counts.kindCounts,
|
|
|
|
|
totalCount: counts.totalCount,
|
|
|
|
|
darkSignalCount: counts.darkSignalCount,
|
2026-01-30 13:23:26 +09:00
|
|
|
});
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 단일 선박 추가/업데이트
|
|
|
|
|
* @param {Object} ship - 선박 데이터
|
|
|
|
|
*/
|
|
|
|
|
addOrUpdateFeature: (ship) => {
|
|
|
|
|
get().mergeFeatures([ship]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 삭제
|
|
|
|
|
* @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId)
|
|
|
|
|
*/
|
|
|
|
|
deleteFeatureById: (featureId) => {
|
2026-01-30 13:06:56 +09:00
|
|
|
const state = get();
|
|
|
|
|
state.features.delete(featureId);
|
|
|
|
|
const darkChanged = state.darkSignalIds.delete(featureId);
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
set((s) => ({
|
|
|
|
|
featuresVersion: s.featuresVersion + 1,
|
|
|
|
|
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
|
|
|
|
|
selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId,
|
|
|
|
|
}));
|
2026-01-30 13:01:54 +09:00
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 여러 선박 삭제
|
|
|
|
|
* @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId)
|
|
|
|
|
*/
|
|
|
|
|
deleteFeaturesByIds: (featureIds) => {
|
2026-01-30 13:06:56 +09:00
|
|
|
const state = get();
|
|
|
|
|
let darkChanged = false;
|
|
|
|
|
featureIds.forEach((featureId) => {
|
|
|
|
|
state.features.delete(featureId);
|
|
|
|
|
if (state.darkSignalIds.delete(featureId)) darkChanged = true;
|
2026-01-30 13:01:54 +09:00
|
|
|
});
|
|
|
|
|
|
2026-01-30 13:06:56 +09:00
|
|
|
set((s) => ({
|
|
|
|
|
featuresVersion: s.featuresVersion + 1,
|
|
|
|
|
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
|
|
|
|
|
selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-30 13:39:19 +09:00
|
|
|
// 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 종류별 표시 토글
|
|
|
|
|
* 필터 변경 시 카운트 즉시 재계산
|
|
|
|
|
* @param {string} kindCode - 선박 종류 코드
|
|
|
|
|
*/
|
|
|
|
|
toggleKindVisibility: (kindCode) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
kindVisibility: {
|
|
|
|
|
...state.kindVisibility,
|
|
|
|
|
[kindCode]: !state.kindVisibility[kindCode],
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
// 필터 변경 시 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 신호원별 표시 토글
|
|
|
|
|
* 필터 변경 시 카운트 즉시 재계산
|
|
|
|
|
* @param {string} sourceCode - 신호원 코드
|
|
|
|
|
*/
|
|
|
|
|
toggleSourceVisibility: (sourceCode) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
sourceVisibility: {
|
|
|
|
|
...state.sourceVisibility,
|
|
|
|
|
[sourceCode]: !state.sourceVisibility[sourceCode],
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
// 필터 변경 시 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 국적별 표시 토글
|
|
|
|
|
* 필터 변경 시 카운트 즉시 재계산
|
|
|
|
|
* @param {string} nationalCode - 국적 코드
|
|
|
|
|
*/
|
|
|
|
|
toggleNationalVisibility: (nationalCode) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
nationalVisibility: {
|
|
|
|
|
...state.nationalVisibility,
|
|
|
|
|
[nationalCode]: !state.nationalVisibility[nationalCode],
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
// 필터 변경 시 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다크시그널 표시 토글
|
|
|
|
|
*/
|
|
|
|
|
toggleDarkSignalVisible: () => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
darkSignalVisible: !state.darkSignalVisible,
|
|
|
|
|
}));
|
|
|
|
|
get().recalculateCounts();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다크시그널 선박 일괄 삭제
|
|
|
|
|
*/
|
|
|
|
|
clearDarkSignals: () => {
|
2026-01-30 13:06:56 +09:00
|
|
|
const state = get();
|
|
|
|
|
state.darkSignalIds.forEach((fid) => {
|
|
|
|
|
state.features.delete(fid);
|
2026-01-30 13:01:54 +09:00
|
|
|
});
|
2026-01-30 13:06:56 +09:00
|
|
|
state.darkSignalIds.clear();
|
|
|
|
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
featuresVersion: s.featuresVersion + 1,
|
|
|
|
|
darkSignalVersion: s.darkSignalVersion + 1,
|
|
|
|
|
}));
|
2026-01-30 13:39:19 +09:00
|
|
|
get().recalculateCounts();
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 표시 전체 On/Off
|
|
|
|
|
*/
|
|
|
|
|
toggleShipVisible: () => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
isShipVisible: !state.isShipVisible,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선명표시 On/Off
|
|
|
|
|
*/
|
|
|
|
|
toggleShowLabels: () => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
showLabels: !state.showLabels,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선명표시 옵션 설정
|
|
|
|
|
* @param {string} optionKey - 옵션 키 (showShipName, showSpeedVector, showShipSize, showSignalStatus)
|
|
|
|
|
*/
|
|
|
|
|
toggleLabelOption: (optionKey) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
labelOptions: {
|
|
|
|
|
...state.labelOptions,
|
|
|
|
|
[optionKey]: !state.labelOptions[optionKey],
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선명표시 옵션 직접 설정
|
|
|
|
|
* @param {Object} options - 옵션 객체
|
|
|
|
|
*/
|
|
|
|
|
setLabelOptions: (options) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
labelOptions: {
|
|
|
|
|
...state.labelOptions,
|
|
|
|
|
...options,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 통합 모드 토글
|
|
|
|
|
* 통합 모드 On: isPriority=1인 선박만 표시
|
|
|
|
|
* 통합 모드 Off: 모든 선박 표시
|
|
|
|
|
*/
|
|
|
|
|
toggleIntegrate: () => {
|
|
|
|
|
const newMode = !get().isIntegrate;
|
|
|
|
|
get().syncSelectedWithIntegrateMode(newMode);
|
|
|
|
|
set({ isIntegrate: newMode });
|
|
|
|
|
// 필터 변경 시 즉시 카운트 재계산
|
|
|
|
|
get().recalculateCounts();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 선택
|
|
|
|
|
* @param {string|null} featureId - 선택할 선박 ID (null이면 선택 해제, signalSourceCode + targetId)
|
|
|
|
|
*/
|
|
|
|
|
selectShip: (featureId) => {
|
|
|
|
|
set({ selectedShipId: featureId });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ctrl+Drag 다중 선택 설정
|
|
|
|
|
* @param {Array<string>} ids - featureId 배열
|
|
|
|
|
*/
|
|
|
|
|
setSelectedShipIds: (ids) => set({ selectedShipIds: ids }),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 다중 선택 해제
|
|
|
|
|
*/
|
|
|
|
|
clearSelectedShips: () => set({ selectedShipIds: [] }),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컨텍스트 메뉴 열기
|
|
|
|
|
* @param {{ x: number, y: number, ships: Array }} info
|
|
|
|
|
*/
|
|
|
|
|
openContextMenu: (info) => set({ contextMenu: info }),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 컨텍스트 메뉴 닫기
|
|
|
|
|
*/
|
|
|
|
|
closeContextMenu: () => set({ contextMenu: null }),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 통합모드 전환 시 selectedShipIds 동기화
|
|
|
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - syncSelectedFeaturesWithIntegrateMode
|
|
|
|
|
* @param {boolean} toIntegrateMode - 전환 후 통합모드 ON 여부
|
|
|
|
|
*/
|
|
|
|
|
syncSelectedWithIntegrateMode: (toIntegrateMode) => {
|
|
|
|
|
const { selectedShipIds, features } = get();
|
|
|
|
|
if (selectedShipIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const EQUIPMENT_MAP = [
|
|
|
|
|
{ index: 0, signalSourceCode: '000001', dataKey: 'ais' },
|
|
|
|
|
{ index: 1, signalSourceCode: '000003', dataKey: 'vpass' },
|
|
|
|
|
{ index: 2, signalSourceCode: '000002', dataKey: 'enav' },
|
|
|
|
|
{ index: 3, signalSourceCode: '000004', dataKey: 'vtsAis' },
|
|
|
|
|
{ index: 4, signalSourceCode: '000016', dataKey: 'dMfHf' },
|
|
|
|
|
// index 5 = VTS-Radar → 확장 시 제외
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (toIntegrateMode) {
|
|
|
|
|
// OFF → ON: 개별 장비 → 대표(isPriority) 선박으로 축소
|
|
|
|
|
const newIds = [];
|
|
|
|
|
const seenTargetIds = new Set();
|
|
|
|
|
|
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
|
|
|
const ship = features.get(fid);
|
|
|
|
|
if (!ship) return;
|
|
|
|
|
|
|
|
|
|
if (!ship.integrate) {
|
|
|
|
|
newIds.push(fid);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tid = ship.targetId;
|
|
|
|
|
if (seenTargetIds.has(tid)) return;
|
|
|
|
|
seenTargetIds.add(tid);
|
|
|
|
|
|
|
|
|
|
let priorityFid = null;
|
|
|
|
|
features.forEach((s, id) => {
|
|
|
|
|
if (s.targetId === tid && s.isPriority) priorityFid = id;
|
|
|
|
|
});
|
|
|
|
|
newIds.push(priorityFid || fid);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
set({ selectedShipIds: newIds });
|
|
|
|
|
} else {
|
|
|
|
|
// ON → OFF: 대표 선박 → isActive인 개별 장비로 확장
|
|
|
|
|
const newIds = [];
|
|
|
|
|
|
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
|
|
|
const ship = features.get(fid);
|
|
|
|
|
if (!ship) return;
|
|
|
|
|
|
|
|
|
|
if (!ship.integrate || !ship.isPriority) {
|
|
|
|
|
newIds.push(fid);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parts = ship.targetId.split('_');
|
|
|
|
|
let expanded = false;
|
|
|
|
|
|
|
|
|
|
EQUIPMENT_MAP.forEach(({ index, signalSourceCode, dataKey }) => {
|
|
|
|
|
const equipTargetId = parts[index];
|
|
|
|
|
if (!equipTargetId) return;
|
|
|
|
|
if (ship[dataKey] !== '1') return;
|
|
|
|
|
|
|
|
|
|
const equipFeatureId = signalSourceCode + equipTargetId;
|
|
|
|
|
if (features.has(equipFeatureId)) {
|
|
|
|
|
newIds.push(equipFeatureId);
|
|
|
|
|
expanded = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!expanded) newIds.push(fid);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
set({ selectedShipIds: newIds });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 호버 정보 설정
|
|
|
|
|
* @param {Object|null} info - { ship, x, y } 또는 null
|
|
|
|
|
*/
|
|
|
|
|
setHoverInfo: (info) => {
|
|
|
|
|
set({ hoverInfo: info });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 상세 모달 열기 (최대 3개, 4번째부터 FIFO 제거)
|
|
|
|
|
* 새 모달은 마지막 모달의 현재 위치 기준 우측 140px 오프셋으로 생성
|
|
|
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - setAddDetailModal
|
|
|
|
|
* @param {Object} ship - 선박 데이터
|
|
|
|
|
*/
|
|
|
|
|
openDetailModal: (ship) => {
|
|
|
|
|
set((state) => {
|
|
|
|
|
// 이미 열린 동일 선박 모달이면 무시
|
|
|
|
|
if (state.detailModals.some((m) => m.id === ship.featureId)) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측
|
|
|
|
|
const basePos = state.lastModalPos || { x: 0, y: 100 };
|
|
|
|
|
const initialPos = { x: basePos.x + 140, y: basePos.y };
|
|
|
|
|
|
|
|
|
|
const newModal = { ship, id: ship.featureId, initialPos };
|
|
|
|
|
let modals = [...state.detailModals, newModal];
|
|
|
|
|
|
|
|
|
|
// 3개 초과 시 가장 오래된 모달 제거
|
|
|
|
|
if (modals.length > 3) {
|
|
|
|
|
modals = modals.slice(modals.length - 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
detailModals: modals,
|
|
|
|
|
lastModalPos: initialPos,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모달 위치 업데이트 (드래그 후 호출)
|
|
|
|
|
* @param {string} modalId - 모달 ID
|
|
|
|
|
* @param {{ x: number, y: number }} pos - 현재 위치
|
|
|
|
|
*/
|
|
|
|
|
updateModalPos: (modalId, pos) => {
|
|
|
|
|
set({ lastModalPos: pos });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 상세 모달 닫기
|
|
|
|
|
* @param {string} modalId - 모달 ID (featureId)
|
|
|
|
|
*/
|
|
|
|
|
closeDetailModal: (modalId) => {
|
|
|
|
|
set((state) => ({
|
|
|
|
|
detailModals: state.detailModals.filter((m) => m.id !== modalId),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모든 상세 모달 닫기
|
|
|
|
|
*/
|
|
|
|
|
closeAllDetailModals: () => {
|
|
|
|
|
set({ detailModals: [], lastModalPos: null });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* STOMP 연결 상태 설정
|
|
|
|
|
* @param {boolean} connected - 연결 상태
|
|
|
|
|
*/
|
|
|
|
|
setConnected: (connected) => {
|
|
|
|
|
set({ isConnected: connected });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 범례 표시 토글
|
|
|
|
|
*/
|
|
|
|
|
toggleShowLegend: () => {
|
|
|
|
|
set((state) => ({ showLegend: !state.showLegend }));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모든 선박 데이터 초기화
|
|
|
|
|
*/
|
|
|
|
|
clearFeatures: () => {
|
2026-01-30 13:06:56 +09:00
|
|
|
set((s) => ({
|
2026-01-30 13:01:54 +09:00
|
|
|
features: new Map(),
|
2026-01-30 13:06:56 +09:00
|
|
|
featuresVersion: s.featuresVersion + 1,
|
2026-01-30 13:01:54 +09:00
|
|
|
darkSignalIds: new Set(),
|
2026-01-30 13:06:56 +09:00
|
|
|
darkSignalVersion: s.darkSignalVersion + 1,
|
2026-01-30 13:01:54 +09:00
|
|
|
kindCounts: { ...initialKindCounts },
|
|
|
|
|
selectedShipId: null,
|
|
|
|
|
selectedShipIds: [],
|
|
|
|
|
contextMenu: null,
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
darkSignalCount: 0,
|
2026-01-30 13:06:56 +09:00
|
|
|
}));
|
2026-01-30 13:01:54 +09:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 변경 ID 초기화 (렌더링 후 호출)
|
|
|
|
|
*/
|
|
|
|
|
clearChangedIds: () => {
|
|
|
|
|
set({ changedIds: new Set() });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 종류별 카운트 직접 설정 (서버 count 토픽용)
|
|
|
|
|
* @param {Object} counts - 종류별 카운트 객체
|
|
|
|
|
*/
|
|
|
|
|
setKindCounts: (counts) => {
|
|
|
|
|
const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
|
|
|
set({
|
|
|
|
|
kindCounts: { ...initialKindCounts, ...counts },
|
|
|
|
|
totalCount,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// =====================
|
|
|
|
|
// 셀렉터 (Selectors)
|
|
|
|
|
// =====================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 표시 가능한 선박 목록 (필터 적용)
|
|
|
|
|
* @returns {Array} 필터링된 선박 배열
|
|
|
|
|
*/
|
|
|
|
|
getVisibleShips: () => {
|
|
|
|
|
const state = get();
|
|
|
|
|
if (!state.isShipVisible) return [];
|
|
|
|
|
|
|
|
|
|
const { features, darkSignalIds, kindVisibility, sourceVisibility, darkSignalVisible } = state;
|
|
|
|
|
const result = [];
|
|
|
|
|
|
|
|
|
|
features.forEach((ship, featureId) => {
|
|
|
|
|
// 다크시그널은 독립 필터 (선종/신호원/국적 필터 무시)
|
|
|
|
|
if (darkSignalIds.has(featureId)) {
|
|
|
|
|
if (darkSignalVisible) result.push(ship);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 선박 종류 필터
|
|
|
|
|
if (!kindVisibility[ship.signalKindCode]) return;
|
|
|
|
|
|
|
|
|
|
// 신호원 필터
|
|
|
|
|
if (!sourceVisibility[ship.signalSourceCode]) return;
|
|
|
|
|
|
|
|
|
|
result.push(ship);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선택된 선박 정보
|
|
|
|
|
* @returns {Object|null} 선박 데이터 또는 null
|
|
|
|
|
*/
|
|
|
|
|
getSelectedShip: () => {
|
|
|
|
|
const { features, selectedShipId } = get();
|
|
|
|
|
return selectedShipId ? features.get(selectedShipId) : null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선택된 모든 선박 정보 (하이라이트 표시용)
|
|
|
|
|
* selectedShipIds(박스선택) + detailModals(상세모달) 통합
|
|
|
|
|
* @returns {Array} 선박 데이터 배열
|
|
|
|
|
*/
|
|
|
|
|
getSelectedShips: () => {
|
|
|
|
|
const { features, selectedShipIds, detailModals } = get();
|
|
|
|
|
const result = [];
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
|
|
|
const ship = features.get(fid);
|
|
|
|
|
if (ship && !seen.has(fid)) {
|
|
|
|
|
result.push(ship);
|
|
|
|
|
seen.add(fid);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
detailModals.forEach((m) => {
|
|
|
|
|
if (m.ship && !seen.has(m.id)) {
|
|
|
|
|
result.push(m.ship);
|
|
|
|
|
seen.add(m.id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* CSV 다운로드용 선박 목록 (필터 적용)
|
|
|
|
|
* - 레이더 항상 제외
|
|
|
|
|
* - 통합 모드: isPriority만 포함
|
|
|
|
|
* - 다크시그널: 독립 필터 적용
|
|
|
|
|
* - 일반: 선종/신호원/국적 필터 적용
|
|
|
|
|
* @returns {Array} 다운로드용 선박 배열 (downloadTargetId 포함)
|
|
|
|
|
*/
|
|
|
|
|
getDownloadShips: () => {
|
|
|
|
|
const state = get();
|
|
|
|
|
const {
|
|
|
|
|
features, darkSignalIds, kindVisibility, sourceVisibility, nationalVisibility,
|
|
|
|
|
isIntegrate, darkSignalVisible,
|
|
|
|
|
} = state;
|
|
|
|
|
const result = [];
|
|
|
|
|
|
|
|
|
|
features.forEach((ship, featureId) => {
|
|
|
|
|
// 레이더 항상 제외
|
|
|
|
|
if (ship.signalSourceCode === '000005') return;
|
|
|
|
|
|
|
|
|
|
// 통합 모드: isPriority만 포함
|
|
|
|
|
if (isIntegrate && ship.integrate && !ship.isPriority) return;
|
|
|
|
|
|
|
|
|
|
const downloadTargetId = isIntegrate ? ship.targetId : ship.originalTargetId;
|
|
|
|
|
|
|
|
|
|
// 다크시그널: 독립 필터
|
|
|
|
|
if (darkSignalIds.has(featureId)) {
|
|
|
|
|
if (darkSignalVisible) {
|
|
|
|
|
result.push({ ...ship, downloadTargetId });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 선종 필터
|
|
|
|
|
if (!kindVisibility[ship.signalKindCode]) return;
|
|
|
|
|
// 신호원 필터
|
|
|
|
|
if (!sourceVisibility[ship.signalSourceCode]) return;
|
|
|
|
|
// 국적 필터
|
|
|
|
|
const mapped = mapNationalCode(ship.nationalCode);
|
|
|
|
|
if (!nationalVisibility[mapped]) return;
|
|
|
|
|
|
|
|
|
|
result.push({ ...ship, downloadTargetId });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
export default useShipStore;
|