ship-gis/src/stores/shipStore.js

1102 lines
33 KiB
JavaScript
Raw Normal View 히스토리

/**
* 선박 데이터 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');
}
// =====================
// 타임아웃 체크 쓰로틀 간격
// 참조: mda-react-front/src/common/deck.ts (271-331)
// =====================
const TIMEOUT_CHECK_INTERVAL_MS = 5000; // 5초
// =====================
// Incremental 카운트 레지스트리 (스토어 외부)
// =====================
//
// 기존 방식: 5초마다 features 전체 O(n) 순회하며 카운트 재계산
// 최적화: 변경된 선박만 카운트 증감 O(batch), 5초 주기는 타임아웃 체크에만 사용
//
// - categories: 각 featureId가 마지막으로 기여한 카운트 카테고리
// 'dark' = 다크시그널 카운트, signalKindCode = 선종 카운트, null = 카운트 미포함
// - kindCounts: 선종별 누적 카운트 (incremental 유지)
// - darkSignalCount: 다크시그널 누적 카운트
// =====================
const countRegistry = {
categories: new Map(), // featureId → 'dark' | signalKindCode | null
kindCounts: { ...initialKindCounts },
darkSignalCount: 0,
totalCount: 0,
};
/** 마지막 타임아웃 체크 시간 */
let lastTimeoutCheckTime = 0;
/**
* 선박 1건의 카운트 카테고리 판정
* 처리 순서 (메인 프로젝트 동일):
* darkSignalIds.has 'dark'
* 단독 레이더 null (카운트 제외)
* !isAnyEquipmentActive 'dark'
* isPriority 필터 + 선종/신호원/국적 필터 signalKindCode | null
*
* ②③④ 타임아웃 판정은 processTimeoutsThrottled에서 별도 처리
*
* @param {Object} ship - 선박 데이터
* @param {string} featureId
* @param {Set} darkSignalIds
* @param {boolean} isIntegrate
* @param {Object} kindVisibility
* @param {Object} sourceVisibility
* @param {Object} nationalVisibility
* @returns {string|null} 'dark' | signalKindCode | null
*/
function resolveShipCategory(ship, featureId, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) {
if (darkSignalIds.has(featureId)) return 'dark';
if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) return null;
if (!isAnyEquipmentActive(ship)) return 'dark';
if (isIntegrate && ship.integrate && !ship.isPriority) return null;
if (!kindVisibility[ship.signalKindCode]) return null;
if (!sourceVisibility[ship.signalSourceCode]) return null;
const mapped = mapNationalCode(ship.nationalCode);
if (!nationalVisibility[mapped]) return null;
return ship.signalKindCode || null;
}
/**
* 단일 featureId의 카운트 증감 (delta)
* @param {string} featureId
* @param {string|null} newCategory - 카테고리
*/
function updateSingleCount(featureId, newCategory) {
const oldCategory = countRegistry.categories.get(featureId) || null;
if (oldCategory === newCategory) return;
// 이전 카테고리 카운트 감소
if (oldCategory === 'dark') {
countRegistry.darkSignalCount--;
} else if (oldCategory && countRegistry.kindCounts[oldCategory] !== undefined) {
countRegistry.kindCounts[oldCategory]--;
}
// 새 카테고리 카운트 증가
if (newCategory === 'dark') {
countRegistry.darkSignalCount++;
} else if (newCategory && countRegistry.kindCounts[newCategory] !== undefined) {
countRegistry.kindCounts[newCategory]++;
}
if (newCategory) {
countRegistry.categories.set(featureId, newCategory);
} else {
countRegistry.categories.delete(featureId);
}
}
/**
* featureId를 카운트에서 제거
* @param {string} featureId
*/
function removeFromCount(featureId) {
updateSingleCount(featureId, null);
}
/**
* countRegistry의 totalCount 재계산
*/
function recalcTotal() {
countRegistry.totalCount = Object.values(countRegistry.kindCounts).reduce((a, b) => a + b, 0);
}
/**
* 전체 카운트 재계산 (필터 변경, 통합모드 전환 )
* targetId 중복 제거 포함
* @param {Map} features
* @param {Set} darkSignalIds
* @param {boolean} isIntegrate
* @param {Object} kindVisibility
* @param {Object} sourceVisibility
* @param {Object} nationalVisibility
*/
function fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) {
// 레지스트리 초기화
countRegistry.categories.clear();
Object.keys(countRegistry.kindCounts).forEach(k => { countRegistry.kindCounts[k] = 0; });
countRegistry.darkSignalCount = 0;
const seenTargetIds = new Set();
features.forEach((ship, featureId) => {
let category = resolveShipCategory(ship, featureId, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility);
// targetId 중복 제거 (다크시그널은 개별 카운트이므로 제외)
if (category && category !== 'dark' && ship.targetId) {
if (seenTargetIds.has(ship.targetId)) {
category = null;
} else {
seenTargetIds.add(ship.targetId);
}
}
if (category === 'dark') {
countRegistry.darkSignalCount++;
countRegistry.categories.set(featureId, category);
} else if (category && countRegistry.kindCounts[category] !== undefined) {
countRegistry.kindCounts[category]++;
countRegistry.categories.set(featureId, category);
}
// null → categories에 저장하지 않음
});
recalcTotal();
}
/**
* 초기 선박 종류별 카운트
*/
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,
};
/**
* 초기 선박 종류별 표시 설정
*/
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)
// =====================
/** (featureId -> shipData), featureId = signalSourceCode + targetId
* 성능 최적화: Map 인스턴스를 직접 mutate하고 featuresVersion으로 변경 감지
* (5000 기준 new Map() 전체 복사 제거 배치당 O(batch) 발생) */
features: new Map(),
/** features (Zustand )
* features Map은 동일 인스턴스를 유지하면서 내부만 변경하므로,
* 구독자가 변경을 감지할 있도록 버전 번호를 증가시킨다. */
featuresVersion: 0,
/** ID Set (features , )
* 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds
* 성능 최적화: Set 인스턴스를 직접 mutate하고 darkSignalVersion으로 변경 감지 */
darkSignalIds: new Set(),
/** darkSignalIds 변경 버전 카운터 */
darkSignalVersion: 0,
/** 선박 종류별 카운트 */
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) => {
// ※ 성능 최적화 #1: Map/Set을 직접 mutate (O(n) 전체 복사 제거)
// ※ 성능 최적화 #2: Incremental count (변경된 선박만 카운트 증감)
const state = get();
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state;
let darkChanged = false;
const deletedIds = [];
const changedIds = [];
ships.forEach((ship) => {
const featureId = ship.featureId;
if (!featureId) return;
// 좌표가 없으면 스킵
if (!ship.longitude || !ship.latitude) {
return;
}
const hasActive = isAnyEquipmentActive(ship);
// 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제)
if (!ship.lost && !hasActive) {
features.delete(featureId);
if (darkSignalIds.delete(featureId)) darkChanged = true;
deletedIds.push(featureId);
return;
}
// 다크시그널 상태 판정
if (hasActive) {
if (darkSignalIds.delete(featureId)) darkChanged = true;
} else {
if (!darkSignalIds.has(featureId)) {
darkSignalIds.add(featureId);
darkChanged = true;
}
}
features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) });
changedIds.push(featureId);
});
// 버전 카운터 증가
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
}));
// Incremental count: 삭제된 선박은 카운트에서 제거, 변경된 선박은 카테고리 재판정
deletedIds.forEach(fid => removeFromCount(fid));
changedIds.forEach(fid => {
const ship = features.get(fid);
if (!ship) return;
const category = resolveShipCategory(ship, fid, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility);
updateSingleCount(fid, category);
});
recalcTotal();
set({
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
});
// 타임아웃 체크 (5초 주기)
get().processTimeoutsThrottled();
},
/**
* 타임아웃 체크 (5 주기)
* features 전체를 순회하여 타임아웃된 선박을 삭제/다크시그널 전환
* 카운트는 영향받는 선박만 incremental 업데이트
*
* 처리 순서 (메인 프로젝트 동일):
* 레이더(000005)+비통합 timeout? delete
* LOST=0 + INSHORE timeout delete
* LOST=1 + OFFSHORE timeout darkSignal 전환
* !isAnyEquipmentActive darkSignal 전환
*/
processTimeoutsThrottled: () => {
const now = Date.now();
if (now - lastTimeoutCheckTime < TIMEOUT_CHECK_INTERVAL_MS) return;
lastTimeoutCheckTime = now;
const state = get();
const { features, darkSignalIds } = state;
const newDarkIds = [];
const deleteIds = [];
features.forEach((ship, featureId) => {
// 이미 다크시그널 → 타임아웃 체크 불필요
if (darkSignalIds.has(featureId)) return;
// ② 단독 레이더(비통합) → 타임아웃이면 삭제
if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) {
if (now - ship.receivedTimestamp > RADAR_TIMEOUT_MS) deleteIds.push(featureId);
return;
}
const elapsed = now - ship.receivedTimestamp;
// ③ 영해안 (LOST≠1) + INSHORE 타임아웃 → 삭제
if (!ship.lost && elapsed > INSHORE_TIMEOUT_MS) {
deleteIds.push(featureId);
return;
}
// ④ 영해밖 (LOST=1) + OFFSHORE 타임아웃 → 다크시그널 전환
if (ship.lost && elapsed > OFFSHORE_TIMEOUT_MS) {
newDarkIds.push(featureId);
return;
}
// ⑤ 모든 장비 비활성 → 즉시 다크시그널 전환
if (!isAnyEquipmentActive(ship)) {
newDarkIds.push(featureId);
}
});
if (newDarkIds.length === 0 && deleteIds.length === 0) return;
// features/darkSignalIds 직접 mutate
newDarkIds.forEach(fid => darkSignalIds.add(fid));
deleteIds.forEach(fid => {
features.delete(fid);
darkSignalIds.delete(fid);
});
// Incremental count: 영향받는 선박만 카운트 조정
deleteIds.forEach(fid => removeFromCount(fid));
newDarkIds.forEach(fid => updateSingleCount(fid, 'dark'));
recalcTotal();
set((s) => ({
featuresVersion: s.featuresVersion + 1,
darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
}));
},
/**
* 카운트 전체 재계산 (필터 변경, 통합모드 전환 )
* targetId 중복 제거 포함 전체 O(n) 순회
*/
recalculateCounts: () => {
const state = get();
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state;
fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility);
set({
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
});
},
/**
* 단일 선박 추가/업데이트
* @param {Object} ship - 선박 데이터
*/
addOrUpdateFeature: (ship) => {
get().mergeFeatures([ship]);
},
/**
* 선박 삭제
* @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId)
*/
deleteFeatureById: (featureId) => {
const state = get();
state.features.delete(featureId);
const darkChanged = state.darkSignalIds.delete(featureId);
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId,
}));
// Incremental count: 삭제된 선박 카운트 제거
removeFromCount(featureId);
recalcTotal();
set({
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
});
},
/**
* 여러 선박 삭제
* @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId)
*/
deleteFeaturesByIds: (featureIds) => {
const state = get();
let darkChanged = false;
featureIds.forEach((featureId) => {
state.features.delete(featureId);
if (state.darkSignalIds.delete(featureId)) darkChanged = true;
});
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId,
}));
// Incremental count: 삭제된 선박들 카운트 제거
featureIds.forEach(fid => removeFromCount(fid));
recalcTotal();
set({
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
});
},
/**
* 선박 종류별 표시 토글
* 필터 변경 카운트 즉시 재계산
* @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: () => {
const state = get();
// Incremental count: 각 다크시그널 선박 카운트 제거
state.darkSignalIds.forEach((fid) => {
removeFromCount(fid);
state.features.delete(fid);
});
state.darkSignalIds.clear();
recalcTotal();
set((s) => ({
featuresVersion: s.featuresVersion + 1,
darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: { ...countRegistry.kindCounts },
totalCount: countRegistry.totalCount,
darkSignalCount: countRegistry.darkSignalCount,
}));
},
/**
* 선박 표시 전체 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: () => {
// countRegistry 초기화
countRegistry.categories.clear();
Object.keys(countRegistry.kindCounts).forEach(k => { countRegistry.kindCounts[k] = 0; });
countRegistry.darkSignalCount = 0;
countRegistry.totalCount = 0;
set((s) => ({
features: new Map(),
featuresVersion: s.featuresVersion + 1,
darkSignalIds: new Set(),
darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: { ...initialKindCounts },
selectedShipId: null,
selectedShipIds: [],
contextMenu: null,
totalCount: 0,
darkSignalCount: 0,
}));
},
/**
* 변경 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;