diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..e6fc57b1 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,27 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + settings: { + react: { version: 'detect' }, + }, +}; diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 681c3ced..32176c3b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,6 +1,6 @@ #!/bin/bash #============================================================================== -# pre-commit hook (React JavaScript) +# pre-commit hook (React TypeScript) # ESLint 검증 — 실패 시 커밋 차단 #============================================================================== @@ -19,7 +19,7 @@ fi # ESLint 검증 (설정 파일이 있는 경우만) if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then echo "pre-commit: ESLint 검증 중..." - npx eslint src/ --ext .js,.jsx --quiet 2>&1 + npx eslint src/ --ext .ts,.tsx --quiet 2>&1 LINT_RESULT=$? if [ $LINT_RESULT -ne 0 ]; then diff --git a/.gitignore b/.gitignore index ba1152d6..60710fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,5 @@ Desktop.ini .claude/settings.local.json .claude/scripts/ -# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외) -**/*.ts -**/*.tsx -# tracking VesselListManager (참조용) -src/tracking/components/VesselListManager/ +# TypeScript config (vite.config.ts 등은 추적) +# tsconfig*.json은 추적 diff --git a/.yarn-offline-cache/@types-json-schema-7.0.15.tgz b/.yarn-offline-cache/@types-json-schema-7.0.15.tgz new file mode 100644 index 00000000..7c621c81 Binary files /dev/null and b/.yarn-offline-cache/@types-json-schema-7.0.15.tgz differ diff --git a/.yarn-offline-cache/@types-node-22.19.11.tgz b/.yarn-offline-cache/@types-node-22.19.11.tgz new file mode 100644 index 00000000..2f7259ac Binary files /dev/null and b/.yarn-offline-cache/@types-node-22.19.11.tgz differ diff --git a/.yarn-offline-cache/@types-prop-types-15.7.15.tgz b/.yarn-offline-cache/@types-prop-types-15.7.15.tgz new file mode 100644 index 00000000..ba94e526 Binary files /dev/null and b/.yarn-offline-cache/@types-prop-types-15.7.15.tgz differ diff --git a/.yarn-offline-cache/@types-react-18.3.28.tgz b/.yarn-offline-cache/@types-react-18.3.28.tgz new file mode 100644 index 00000000..21760b60 Binary files /dev/null and b/.yarn-offline-cache/@types-react-18.3.28.tgz differ diff --git a/.yarn-offline-cache/@types-react-dom-18.3.7.tgz b/.yarn-offline-cache/@types-react-dom-18.3.7.tgz new file mode 100644 index 00000000..fc672676 Binary files /dev/null and b/.yarn-offline-cache/@types-react-dom-18.3.7.tgz differ diff --git a/.yarn-offline-cache/@types-semver-7.7.1.tgz b/.yarn-offline-cache/@types-semver-7.7.1.tgz new file mode 100644 index 00000000..3ca24583 Binary files /dev/null and b/.yarn-offline-cache/@types-semver-7.7.1.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-eslint-plugin-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-eslint-plugin-6.21.0.tgz new file mode 100644 index 00000000..7b43fb08 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-eslint-plugin-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-parser-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-parser-6.21.0.tgz new file mode 100644 index 00000000..aee18d8c Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-parser-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-scope-manager-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-scope-manager-6.21.0.tgz new file mode 100644 index 00000000..b69d3557 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-scope-manager-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-type-utils-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-type-utils-6.21.0.tgz new file mode 100644 index 00000000..ebbc6e4d Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-type-utils-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-types-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-types-6.21.0.tgz new file mode 100644 index 00000000..1dfcec16 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-types-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-typescript-estree-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-typescript-estree-6.21.0.tgz new file mode 100644 index 00000000..0cbf4047 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-typescript-estree-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-utils-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-utils-6.21.0.tgz new file mode 100644 index 00000000..4889e119 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-utils-6.21.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-visitor-keys-6.21.0.tgz b/.yarn-offline-cache/@typescript-eslint-visitor-keys-6.21.0.tgz new file mode 100644 index 00000000..fe6dbda5 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-visitor-keys-6.21.0.tgz differ diff --git a/.yarn-offline-cache/array-union-2.1.0.tgz b/.yarn-offline-cache/array-union-2.1.0.tgz new file mode 100644 index 00000000..449fdaa2 Binary files /dev/null and b/.yarn-offline-cache/array-union-2.1.0.tgz differ diff --git a/.yarn-offline-cache/brace-expansion-2.0.2.tgz b/.yarn-offline-cache/brace-expansion-2.0.2.tgz new file mode 100644 index 00000000..b76e79f0 Binary files /dev/null and b/.yarn-offline-cache/brace-expansion-2.0.2.tgz differ diff --git a/.yarn-offline-cache/braces-3.0.3.tgz b/.yarn-offline-cache/braces-3.0.3.tgz new file mode 100644 index 00000000..7ca6e9b7 Binary files /dev/null and b/.yarn-offline-cache/braces-3.0.3.tgz differ diff --git a/.yarn-offline-cache/csstype-3.2.3.tgz b/.yarn-offline-cache/csstype-3.2.3.tgz new file mode 100644 index 00000000..3c1935ba Binary files /dev/null and b/.yarn-offline-cache/csstype-3.2.3.tgz differ diff --git a/.yarn-offline-cache/dir-glob-3.0.1.tgz b/.yarn-offline-cache/dir-glob-3.0.1.tgz new file mode 100644 index 00000000..2e074f07 Binary files /dev/null and b/.yarn-offline-cache/dir-glob-3.0.1.tgz differ diff --git a/.yarn-offline-cache/fast-glob-3.3.3.tgz b/.yarn-offline-cache/fast-glob-3.3.3.tgz new file mode 100644 index 00000000..b8be9bbe Binary files /dev/null and b/.yarn-offline-cache/fast-glob-3.3.3.tgz differ diff --git a/.yarn-offline-cache/fill-range-7.1.1.tgz b/.yarn-offline-cache/fill-range-7.1.1.tgz new file mode 100644 index 00000000..7224940d Binary files /dev/null and b/.yarn-offline-cache/fill-range-7.1.1.tgz differ diff --git a/.yarn-offline-cache/glob-parent-5.1.2.tgz b/.yarn-offline-cache/glob-parent-5.1.2.tgz new file mode 100644 index 00000000..c0d2bb7d Binary files /dev/null and b/.yarn-offline-cache/glob-parent-5.1.2.tgz differ diff --git a/.yarn-offline-cache/globby-11.1.0.tgz b/.yarn-offline-cache/globby-11.1.0.tgz new file mode 100644 index 00000000..3f3b9e5c Binary files /dev/null and b/.yarn-offline-cache/globby-11.1.0.tgz differ diff --git a/.yarn-offline-cache/is-number-7.0.0.tgz b/.yarn-offline-cache/is-number-7.0.0.tgz new file mode 100644 index 00000000..8b150f2a Binary files /dev/null and b/.yarn-offline-cache/is-number-7.0.0.tgz differ diff --git a/.yarn-offline-cache/merge2-1.4.1.tgz b/.yarn-offline-cache/merge2-1.4.1.tgz new file mode 100644 index 00000000..12ee72c7 Binary files /dev/null and b/.yarn-offline-cache/merge2-1.4.1.tgz differ diff --git a/.yarn-offline-cache/micromatch-4.0.8.tgz b/.yarn-offline-cache/micromatch-4.0.8.tgz new file mode 100644 index 00000000..bff9e390 Binary files /dev/null and b/.yarn-offline-cache/micromatch-4.0.8.tgz differ diff --git a/.yarn-offline-cache/minimatch-9.0.3.tgz b/.yarn-offline-cache/minimatch-9.0.3.tgz new file mode 100644 index 00000000..81dff922 Binary files /dev/null and b/.yarn-offline-cache/minimatch-9.0.3.tgz differ diff --git a/.yarn-offline-cache/path-type-4.0.0.tgz b/.yarn-offline-cache/path-type-4.0.0.tgz new file mode 100644 index 00000000..fa914d00 Binary files /dev/null and b/.yarn-offline-cache/path-type-4.0.0.tgz differ diff --git a/.yarn-offline-cache/picomatch-2.3.1.tgz b/.yarn-offline-cache/picomatch-2.3.1.tgz new file mode 100644 index 00000000..33143b7f Binary files /dev/null and b/.yarn-offline-cache/picomatch-2.3.1.tgz differ diff --git a/.yarn-offline-cache/semver-7.7.4.tgz b/.yarn-offline-cache/semver-7.7.4.tgz new file mode 100644 index 00000000..559ec6b2 Binary files /dev/null and b/.yarn-offline-cache/semver-7.7.4.tgz differ diff --git a/.yarn-offline-cache/slash-3.0.0.tgz b/.yarn-offline-cache/slash-3.0.0.tgz new file mode 100644 index 00000000..aa40ca65 Binary files /dev/null and b/.yarn-offline-cache/slash-3.0.0.tgz differ diff --git a/.yarn-offline-cache/to-regex-range-5.0.1.tgz b/.yarn-offline-cache/to-regex-range-5.0.1.tgz new file mode 100644 index 00000000..b62f27fd Binary files /dev/null and b/.yarn-offline-cache/to-regex-range-5.0.1.tgz differ diff --git a/.yarn-offline-cache/ts-api-utils-1.4.3.tgz b/.yarn-offline-cache/ts-api-utils-1.4.3.tgz new file mode 100644 index 00000000..70d140f8 Binary files /dev/null and b/.yarn-offline-cache/ts-api-utils-1.4.3.tgz differ diff --git a/.yarn-offline-cache/typescript-5.7.3.tgz b/.yarn-offline-cache/typescript-5.7.3.tgz new file mode 100644 index 00000000..86b1ff14 Binary files /dev/null and b/.yarn-offline-cache/typescript-5.7.3.tgz differ diff --git a/.yarn-offline-cache/undici-types-6.21.0.tgz b/.yarn-offline-cache/undici-types-6.21.0.tgz new file mode 100644 index 00000000..2e6a7395 Binary files /dev/null and b/.yarn-offline-cache/undici-types-6.21.0.tgz differ diff --git a/index.html b/index.html index 21e282dd..6d59e12d 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,6 @@
- + diff --git a/package.json b/package.json index f07f7ddf..485bc703 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,15 @@ "type": "module", "scripts": { "dev": "vite --port 3000", - "build": "vite build", - "build:dev": "vite build --mode dev", - "build:qa": "vite build --mode qa", - "build:prod": "vite build", + "build": "tsc -b && vite build", + "build:dev": "tsc -b && vite build --mode dev", + "build:qa": "tsc -b && vite build --mode qa", + "build:prod": "tsc -b && vite build", "preview": "vite preview --port 3000", "preview:dev": "vite preview --mode dev --port 3000", "preview:qa": "vite preview --mode qa --port 3000", - "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0" + "type-check": "tsc -b --noEmit", + "lint": "eslint src --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { "@deck.gl/core": "^9.2.6", @@ -34,12 +35,18 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.0.1", "eslint": "^8.44.0", "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.1", "sass": "^1.77.8", + "typescript": "~5.7.2", "vite": "^5.2.10" } } diff --git a/src/App.jsx b/src/App.tsx similarity index 100% rename from src/App.jsx rename to src/App.tsx diff --git a/src/api/aisTargetApi.js b/src/api/aisTargetApi.ts similarity index 73% rename from src/api/aisTargetApi.js rename to src/api/aisTargetApi.ts index 5dc9a795..a981e7cb 100644 --- a/src/api/aisTargetApi.js +++ b/src/api/aisTargetApi.ts @@ -3,17 +3,44 @@ * SNP-Batch 서버의 AIS 데이터를 HTTP 폴링으로 조회 */ import axios from 'axios'; +import type { ShipFeature } from '../types/ship'; // dev: Vite 프록시 (/snp-api → 211.208.115.83:8041) // prod: 환경변수로 직접 지정 -const BASE_URL = import.meta.env.VITE_API_URL || '/snp-api'; +const BASE_URL: string = import.meta.env.VITE_API_URL || '/snp-api'; + +/** AIS Target API 응답 단건 인터페이스 */ +interface AisTargetResponse { + mmsi?: number | string; + imo?: number | string; + name?: string; + callsign?: string; + vesselType?: string; + lat?: number; + lon?: number; + heading?: number; + sog?: number; + cog?: number; + rot?: number; + length?: number; + width?: number; + draught?: number | string; + destination?: string; + eta?: string; + status?: string; + messageTimestamp?: string; + receivedDate?: string; + source?: string; + classType?: string; + signalKindCode?: string; +} /** * AIS 타겟 검색 (최근 N분 데이터) * @param {number} minutes - 조회 기간 (분) - * @returns {Promise} AIS 타겟 데이터 배열 + * @returns {Promise} AIS 타겟 데이터 배열 */ -export async function searchAisTargets(minutes = 60) { +export async function searchAisTargets(minutes: number = 60): Promise { const res = await axios.get(`${BASE_URL}/api/ais-target/search`, { params: { minutes }, timeout: 30000, @@ -30,10 +57,10 @@ export async function searchAisTargets(minutes = 60) { * destination, eta, status, messageTimestamp, receivedDate, * source, classType * - * @param {Object} aisTarget - API 응답 단건 - * @returns {Object} shipStore 호환 feature 객체 + * @param {AisTargetResponse} aisTarget - API 응답 단건 + * @returns {ShipFeature} shipStore 호환 feature 객체 */ -export function aisTargetToFeature(aisTarget) { +export function aisTargetToFeature(aisTarget: AisTargetResponse): ShipFeature { const mmsi = String(aisTarget.mmsi || ''); // 백엔드에서 signalKindCode를 직접 제공, 없으면 vesselType 기반 fallback const signalKindCode = aisTarget.signalKindCode || mapVesselTypeToKindCode(aisTarget.vesselType); @@ -109,7 +136,7 @@ export function aisTargetToFeature(aisTarget) { /** * vesselType 문자열 → 선종 코드 매핑 */ -function mapVesselTypeToKindCode(vesselType) { +function mapVesselTypeToKindCode(vesselType: string | undefined): string { if (!vesselType) return '000027'; // 일반 const vt = vesselType.toLowerCase(); @@ -125,11 +152,11 @@ function mapVesselTypeToKindCode(vesselType) { /** * ISO 타임스탬프 → "YYYYMMDDHHmmss" 형식 변환 */ -function formatTimestamp(isoString) { +function formatTimestamp(isoString: string | undefined): string { if (!isoString) return ''; try { const d = new Date(isoString); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } catch { return ''; diff --git a/src/api/commonApi.js b/src/api/commonApi.ts similarity index 70% rename from src/api/commonApi.js rename to src/api/commonApi.ts index b3629b52..271d8699 100644 --- a/src/api/commonApi.js +++ b/src/api/commonApi.ts @@ -5,13 +5,20 @@ import { fetchWithAuth } from './fetchWithAuth'; const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search'; +/** 공통코드 아이템 인터페이스 */ +export interface CommonCodeItem { + commonCodeTypeName: string; + commonCodeTypeNumber: string; + commonCodeEtc: string; +} + /** * 공통코드 목록 조회 * * @param {string} commonCodeTypeNumber - 공통코드 유형 번호 - * @returns {Promise>} + * @returns {Promise} */ -export async function fetchCommonCodeList(commonCodeTypeNumber) { +export async function fetchCommonCodeList(commonCodeTypeNumber: string): Promise { try { const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, { method: 'POST', diff --git a/src/api/favoriteApi.js b/src/api/favoriteApi.ts similarity index 57% rename from src/api/favoriteApi.js rename to src/api/favoriteApi.ts index b1ff9a59..8b26d9e6 100644 --- a/src/api/favoriteApi.js +++ b/src/api/favoriteApi.ts @@ -1,10 +1,22 @@ import { fetchWithAuth } from './fetchWithAuth'; +/** 관심선박 API 응답 아이템 */ +export interface FavoriteShipItem { + signalSourceCode?: string; + targetId?: string; + [key: string]: unknown; +} + +/** 관심구역 API 응답 아이템 */ +export interface RealmItem { + [key: string]: unknown; +} + /** * 관심선박 목록 조회 - * @returns {Promise} 관심선박 목록 + * @returns {Promise} 관심선박 목록 */ -export async function fetchFavoriteShips() { +export async function fetchFavoriteShips(): Promise { const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); @@ -13,9 +25,9 @@ export async function fetchFavoriteShips() { /** * 관심구역 목록 조회 - * @returns {Promise} 관심구역 목록 + * @returns {Promise} 관심구역 목록 */ -export async function fetchRealms() { +export async function fetchRealms(): Promise { const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/api/fetchWithAuth.js b/src/api/fetchWithAuth.ts similarity index 87% rename from src/api/fetchWithAuth.js rename to src/api/fetchWithAuth.ts index 59befcb7..39151b94 100644 --- a/src/api/fetchWithAuth.js +++ b/src/api/fetchWithAuth.ts @@ -7,7 +7,7 @@ import { SESSION_TIMEOUT_MS } from '../types/constants'; * - 사후 체크: 4011 응답 감지 (세션 만료) * - credentials: 'include' 자동 설정 */ -export async function fetchWithAuth(url, options = {}) { +export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { // 로컬 개발: 세션 타임아웃 체크 우회 if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') { const loginDate = localStorage.getItem('loginDate'); @@ -32,7 +32,7 @@ export async function fetchWithAuth(url, options = {}) { throw new Error('Session expired (4011)'); } } catch (e) { - if (e.message.includes('Session expired')) throw e; + if (e instanceof Error && e.message.includes('Session expired')) throw e; } } } diff --git a/src/api/signalApi.js b/src/api/signalApi.ts similarity index 83% rename from src/api/signalApi.js rename to src/api/signalApi.ts index da1f874a..93810886 100644 --- a/src/api/signalApi.js +++ b/src/api/signalApi.ts @@ -4,14 +4,15 @@ */ import { parsePipeMessage, rowToShipObject } from '../common/stompClient'; +import type { ShipFeature } from '../types/ship'; /** * 12분 이내 전체 선박 신호 조회 * STOMP 구독 전에 호출하여 초기 선박 데이터 로드 * - * @returns {Promise} 선박 데이터 배열 + * @returns {Promise} 선박 데이터 배열 */ -export async function fetchAllSignals() { +export async function fetchAllSignals(): Promise { try { const response = await fetch('/signal-api/all/12'); @@ -30,10 +31,10 @@ export async function fetchAllSignals() { } // 각 행을 선박 객체로 변환 - const ships = rawData.map((row) => { + const ships: ShipFeature[] = rawData.map((row: string | string[]) => { // row가 문자열이면 파이프로 파싱, 배열이면 그대로 사용 const parsed = typeof row === 'string' ? parsePipeMessage(row) : row; - return rowToShipObject(parsed); + return rowToShipObject(parsed) as ShipFeature; }); // 좌표가 있는 선박만 필터링 @@ -54,7 +55,7 @@ export async function fetchAllSignals() { * * @returns {Promise} 파이프 구분 문자열 배열 */ -export async function fetchAllSignalsRaw() { +export async function fetchAllSignalsRaw(): Promise { try { const response = await fetch('/signal-api/all/12'); @@ -73,7 +74,7 @@ export async function fetchAllSignalsRaw() { } // 문자열 배열로 변환 (각 행이 이미 문자열이면 그대로, 배열이면 파이프로 조인) - const rawLines = rawData.map((row) => { + const rawLines: string[] = rawData.map((row: string | string[]) => { if (typeof row === 'string') { return row; } @@ -81,7 +82,7 @@ export async function fetchAllSignalsRaw() { return row.join('|'); } return ''; - }).filter(line => line.trim()); + }).filter((line: string) => line.trim()); console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`); diff --git a/src/api/trackApi.js b/src/api/trackApi.ts similarity index 66% rename from src/api/trackApi.js rename to src/api/trackApi.ts index a710d591..e3ad750a 100644 --- a/src/api/trackApi.js +++ b/src/api/trackApi.ts @@ -9,21 +9,73 @@ */ import useShipStore from '../stores/shipStore'; import { fetchWithAuth } from './fetchWithAuth'; +import type { ShipFeature } from '../types/ship'; /** API 엔드포인트 (메인 프로젝트와 동일) */ const API_ENDPOINT = '/api/v2/tracks/vessels'; +/** 선박 식별자 (항적 조회용) */ +export interface VesselIdentifier { + sigSrcCd: string; + targetId: string; +} + +/** 항적 통계 */ +interface TrackStats { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; +} + +/** 가공된 항적 데이터 */ +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId: string; + geometry: number[][]; + timestampsMs: number[]; + speeds: number[]; + stats: TrackStats; +} + +/** 항적 조회 요청 파라미터 */ +interface TrackQueryParams { + startTime: string; + endTime: string; + vessels: VesselIdentifier[]; + isIntegration?: boolean; +} + +/** API 원시 응답 항적 */ +interface RawTrack { + vesselId?: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + integrationTargetId?: string; + geometry?: number[][]; + timestamps?: (string | number)[]; + speeds?: number[]; + totalDistance?: number; + avgSpeed?: number; + maxSpeed?: number; + pointCount?: number; +} + /** * 항적 데이터 조회 * - * @param {Object} params - * @param {string} params.startTime - 조회 시작 시간 (ISO 8601, e.g. '2026-01-01T00:00:00') - * @param {string} params.endTime - 조회 종료 시간 (ISO 8601) - * @param {Array<{ sigSrcCd: string, targetId: string }>} params.vessels - 조회 대상 선박 - * @param {boolean} [params.isIntegration=false] - 통합 조회 여부 - * @returns {Promise} ProcessedTrack 배열 + * @param {TrackQueryParams} params + * @returns {Promise} ProcessedTrack 배열 */ -export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) { +export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }: TrackQueryParams): Promise { try { const body = { startTime, @@ -45,7 +97,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati const result = await response.json(); // v2 API는 배열을 직접 반환 - const rawTracks = Array.isArray(result) ? result : (result?.data || []); + const rawTracks: RawTrack[] = Array.isArray(result) ? result : (result?.data || []); if (!Array.isArray(rawTracks)) { console.warn('[fetchTrackQuery] Invalid response format:', result); @@ -55,7 +107,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati // 가공: CompactVesselTrack → ProcessedTrack const processed = rawTracks .map((raw) => processTrack(raw)) - .filter((t) => t !== null); + .filter((t): t is ProcessedTrack => t !== null); console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`); return processed; @@ -69,10 +121,10 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati * API 응답 데이터를 ProcessedTrack으로 변환 * 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks * - * @param {Object} raw - API 응답의 개별 항적 데이터 - * @returns {Object|null} ProcessedTrack + * @param {RawTrack} raw - API 응답의 개별 항적 데이터 + * @returns {ProcessedTrack|null} ProcessedTrack */ -function processTrack(raw) { +function processTrack(raw: RawTrack): ProcessedTrack | null { if (!raw || !raw.geometry || raw.geometry.length === 0) return null; const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`; @@ -115,11 +167,11 @@ function processTrack(raw) { /** * 실시간 선박 데이터에서 매칭되는 선박 찾기 - * @param {string} targetId - * @param {string} sigSrcCd - * @returns {Object|null} + * @param {string|undefined} targetId + * @param {string|undefined} sigSrcCd + * @returns {ShipFeature|null} */ -function findLiveShipData(targetId, sigSrcCd) { +function findLiveShipData(targetId: string | undefined, sigSrcCd: string | undefined): ShipFeature | null { if (!targetId) return null; const features = useShipStore.getState().features; @@ -132,7 +184,7 @@ function findLiveShipData(targetId, sigSrcCd) { } // featureId로 못 찾으면 originalTargetId로 검색 - let found = null; + let found: ShipFeature | null = null; features.forEach((ship) => { if (ship.originalTargetId === targetId) { found = ship; @@ -144,10 +196,10 @@ function findLiveShipData(targetId, sigSrcCd) { /** * 선박 객체에서 항적 조회용 파라미터 추출 - * @param {Object} ship - shipStore의 선박 데이터 - * @returns {{ sigSrcCd: string, targetId: string }} + * @param {ShipFeature} ship - shipStore의 선박 데이터 + * @returns {VesselIdentifier} */ -export function extractVesselIdentifier(ship) { +export function extractVesselIdentifier(ship: ShipFeature): VesselIdentifier { return { sigSrcCd: ship.signalSourceCode || '', targetId: ship.originalTargetId || ship.targetId || '', @@ -159,8 +211,8 @@ export function extractVesselIdentifier(ship) { * @param {Date} date * @returns {string} 'YYYY-MM-DDTHH:mm:ss' */ -export function toLocalISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +export function toLocalISOString(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } @@ -170,14 +222,14 @@ export function toLocalISOString(date) { * 각 위치에 00000이면 해당 장비 없음 * * @param {string} targetId - 통합 TARGET_ID - * @returns {Array<{ sigSrcCd: string, targetId: string }>} + * @returns {VesselIdentifier[]} */ -export function parseIntegratedTargetId(targetId) { +export function parseIntegratedTargetId(targetId: string): VesselIdentifier[] { if (!targetId) return []; const parts = targetId.split('_'); // 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF - const equipmentMap = [ + const equipmentMap: { sigSrcCd: string; index: number }[] = [ { sigSrcCd: '000001', index: 0 }, // AIS { sigSrcCd: '000003', index: 1 }, // VPASS { sigSrcCd: '000002', index: 2 }, // ENAV @@ -185,7 +237,7 @@ export function parseIntegratedTargetId(targetId) { { sigSrcCd: '000016', index: 4 }, // D_MF_HF ]; - const vessels = []; + const vessels: VesselIdentifier[] = []; equipmentMap.forEach(({ sigSrcCd, index }) => { const id = parts[index]; if (id && id !== '00000' && id !== '0' && id !== '') { @@ -201,10 +253,10 @@ export function parseIntegratedTargetId(targetId) { * 통합선박: TARGET_ID 파싱 → 모든 장비 (레이더 제외) * 단일선박: 기본 identifier 반환 * - * @param {Object} ship - shipStore 선박 데이터 - * @returns {Array<{ sigSrcCd: string, targetId: string }>} + * @param {ShipFeature} ship - shipStore 선박 데이터 + * @returns {VesselIdentifier[]} */ -export function buildVesselListForQuery(ship) { +export function buildVesselListForQuery(ship: ShipFeature): VesselIdentifier[] { if (ship.integrate && ship.targetId && ship.targetId.includes('_')) { return parseIntegratedTargetId(ship.targetId); } diff --git a/src/api/userSettingApi.js b/src/api/userSettingApi.ts similarity index 61% rename from src/api/userSettingApi.js rename to src/api/userSettingApi.ts index b6118b5a..329e02b2 100644 --- a/src/api/userSettingApi.js +++ b/src/api/userSettingApi.ts @@ -4,11 +4,24 @@ import { USER_SETTING_FILTER } from '../types/constants'; const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search'; const SAVE_ENDPOINT = '/api/cmn/personal/settings/save'; +/** 필터 설정 아이템 */ +export interface FilterSettingItem { + settingCode: string; + settingValue: string; + [key: string]: unknown; +} + +/** 필터 저장 요청 아이템 */ +export interface FilterSaveItem { + code: string; + value: string; +} + /** * 필터 설정 조회 - * @returns {Promise} 설정 배열 또는 null (저장된 설정 없음) + * @returns {Promise} 설정 배열 또는 null (저장된 설정 없음) */ -export async function fetchUserFilter() { +export async function fetchUserFilter(): Promise { const url = `${SEARCH_ENDPOINT}?type=${USER_SETTING_FILTER}`; const response = await fetchWithAuth(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -19,9 +32,9 @@ export async function fetchUserFilter() { /** * 필터 설정 저장 - * @param {Array<{code: string, value: string}>} settings + * @param {FilterSaveItem[]} settings */ -export async function saveUserFilter(settings) { +export async function saveUserFilter(settings: FilterSaveItem[]): Promise { const response = await fetchWithAuth(SAVE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/areaSearch/components/AreaSearchPage.jsx b/src/areaSearch/components/AreaSearchPage.tsx similarity index 92% rename from src/areaSearch/components/AreaSearchPage.jsx rename to src/areaSearch/components/AreaSearchPage.tsx index 47e88013..b08bb7b6 100644 --- a/src/areaSearch/components/AreaSearchPage.jsx +++ b/src/areaSearch/components/AreaSearchPage.tsx @@ -6,6 +6,8 @@ import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore' import { fetchAreaSearch } from '../services/areaSearchApi'; import { fetchVesselContacts } from '../services/stsApi'; import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types'; +import type { AnalysisTab, Zone } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { showToast } from '../../components/common/Toast'; import { hideLiveShips, showLiveShips } from '../../utils/liveControl'; import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry'; @@ -16,12 +18,17 @@ import StsAnalysisTab from './StsAnalysisTab'; const DAYS_TO_MS = 24 * 60 * 60 * 1000; -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -export default function AreaSearchPage({ isOpen, onToggle }) { +interface AreaSearchPageProps { + isOpen: boolean; + onToggle: () => void; +} + +export default function AreaSearchPage({ isOpen, onToggle }: AreaSearchPageProps) { const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState('00:00'); const [endDate, setEndDate] = useState(''); @@ -67,7 +74,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { // ========== 탭 전환 ========== - const handleTabChange = useCallback((newTab) => { + const handleTabChange = useCallback((newTab: AnalysisTab) => { if (newTab === activeTab) return; const areaState = useAreaSearchStore.getState(); @@ -101,12 +108,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) { // ========== 날짜 핸들러 ========== - const handleStartDateChange = useCallback((newStartDate) => { + const handleStartDateChange = useCallback((newStartDate: string) => { setStartDate(newStartDate); const start = new Date(`${newStartDate}T${startTime}:00`); const end = new Date(`${endDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); if (diffDays < 0) { const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS); @@ -121,12 +128,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } }, [startTime, endDate, endTime]); - const handleEndDateChange = useCallback((newEndDate) => { + const handleEndDateChange = useCallback((newEndDate: string) => { setEndDate(newEndDate); const start = new Date(`${startDate}T${startTime}:00`); const end = new Date(`${newEndDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); if (diffDays < 0) { const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS); @@ -152,7 +159,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { setErrorMessage(''); useAreaSearchStore.getState().setLoading(true); - const polygons = zones.map((z) => ({ + const polygons = zones.map((z: Zone) => ({ id: z.id, name: z.name, coordinates: z.coordinates, @@ -177,7 +184,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { let minTime = Infinity; let maxTime = -Infinity; - result.tracks.forEach((t) => { + result.tracks.forEach((t: ProcessedTrack) => { if (t.timestampsMs.length > 0) { minTime = Math.min(minTime, t.timestampsMs[0]); maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]); @@ -190,7 +197,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } catch (error) { console.error('[AreaSearch] 조회 실패:', error); useAreaSearchStore.getState().setLoading(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); @@ -228,7 +235,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { let minTime = Infinity; let maxTime = -Infinity; - result.tracks.forEach((t) => { + result.tracks.forEach((t: ProcessedTrack) => { if (t.timestampsMs.length > 0) { minTime = Math.min(minTime, t.timestampsMs[0]); maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]); @@ -241,7 +248,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } catch (error) { console.error('[STS] 조회 실패:', error); useStsStore.getState().setLoading(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); diff --git a/src/areaSearch/components/AreaSearchTab.jsx b/src/areaSearch/components/AreaSearchTab.tsx similarity index 92% rename from src/areaSearch/components/AreaSearchTab.jsx rename to src/areaSearch/components/AreaSearchTab.tsx index 39a6d1e3..8a29a17d 100644 --- a/src/areaSearch/components/AreaSearchTab.jsx +++ b/src/areaSearch/components/AreaSearchTab.tsx @@ -12,13 +12,20 @@ import { useAreaSearchStore } from '../stores/areaSearchStore'; import { SEARCH_MODE_LABELS, } from '../types/areaSearch.types'; +import type { SearchMode } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import ZoneDrawPanel from './ZoneDrawPanel'; import VesselDetailModal from './VesselDetailModal'; import { exportSearchResultToCSV } from '../utils/csvExport'; -export default function AreaSearchTab({ isLoading, errorMessage }) { - const [detailVesselId, setDetailVesselId] = useState(null); +interface AreaSearchTabProps { + isLoading: boolean; + errorMessage: string; +} + +export default function AreaSearchTab({ isLoading, errorMessage }: AreaSearchTabProps) { + const [detailVesselId, setDetailVesselId] = useState(null); const zones = useAreaSearchStore((s) => s.zones); const searchMode = useAreaSearchStore((s) => s.searchMode); @@ -30,11 +37,11 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId); const setSearchMode = useAreaSearchStore((s) => s.setSearchMode); - const handleToggleVessel = useCallback((vesselId) => { + const handleToggleVessel = useCallback((vesselId: string) => { useAreaSearchStore.getState().toggleVesselEnabled(vesselId); }, []); - const handleHighlightVessel = useCallback((vesselId) => { + const handleHighlightVessel = useCallback((vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, []); @@ -42,7 +49,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { exportSearchResultToCSV(tracks, hitDetails, zones); }, [tracks, hitDetails, zones]); - const listRef = useRef(null); + const listRef = useRef(null); useEffect(() => { if (!highlightedVesselId || !listRef.current) return; @@ -73,7 +80,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { checked={searchMode === mode} onChange={() => { if (!useAreaSearchStore.getState().confirmAndClearResults()) return; - setSearchMode(mode); + setSearchMode(mode as SearchMode); }} disabled={isLoading} /> @@ -105,7 +112,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
    - {tracks.map((track) => { + {tracks.map((track: ProcessedTrack) => { const isDisabled = disabledVesselIds.has(track.vesselId); const isHighlighted = highlightedVesselId === track.vesselId; const color = getShipKindColor(track.shipKindCode); diff --git a/src/areaSearch/components/AreaSearchTimeline.jsx b/src/areaSearch/components/AreaSearchTimeline.tsx similarity index 91% rename from src/areaSearch/components/AreaSearchTimeline.jsx rename to src/areaSearch/components/AreaSearchTimeline.tsx index cc948085..c1366594 100644 --- a/src/areaSearch/components/AreaSearchTimeline.jsx +++ b/src/areaSearch/components/AreaSearchTimeline.tsx @@ -1,13 +1,13 @@ /** * 항적분석 타임라인 재생 컨트롤 - * 참조: src/replay/components/ReplayTimeline.jsx (간소화) + * 참조: src/replay/components/ReplayTimeline.tsx (간소화) * * - 재생/일시정지/정지 * - 배속 조절 (1x ~ 1000x) * - 프로그레스 바 (range slider) * - 드래그 가능한 헤더 */ -import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo, ChangeEvent } from 'react'; import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { useStsStore } from '../stores/stsStore'; @@ -21,10 +21,10 @@ import './AreaSearchTimeline.scss'; const PATH_LABEL = '항적'; const TRAIL_LABEL = '궤적'; -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms || ms <= 0) return '--:--:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } @@ -68,18 +68,18 @@ export default function AreaSearchTimeline() { }, [currentTime, startTime, endTime]); const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); + const speedMenuRef = useRef(null); // 드래그 const [isDragging, setIsDragging] = useState(false); const [hasDragged, setHasDragged] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const containerRef = useRef(null); + const containerRef = useRef(null); useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -87,7 +87,7 @@ export default function AreaSearchTimeline() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [showSpeedMenu]); - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const parent = containerRef.current.parentElement; @@ -103,7 +103,7 @@ export default function AreaSearchTimeline() { }, [hasDragged]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!isDragging || !containerRef.current) return; const parent = containerRef.current.parentElement; if (!parent) return; @@ -135,12 +135,12 @@ export default function AreaSearchTimeline() { const handleStop = useCallback(() => { stop(); }, [stop]); - const handleSpeedChange = useCallback((speed) => { + const handleSpeedChange = useCallback((speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, [setPlaybackSpeed]); - const handleSliderChange = useCallback((e) => { + const handleSliderChange = useCallback((e: ChangeEvent) => { setCurrentTime(parseFloat(e.target.value)); }, [setCurrentTime]); @@ -232,7 +232,7 @@ export default function AreaSearchTimeline() { value={currentTime} onChange={handleSliderChange} disabled={!hasData} - style={{ '--progress': `${progress}%` }} + style={{ '--progress': `${progress}%` } as React.CSSProperties} /> diff --git a/src/areaSearch/components/AreaSearchTooltip.jsx b/src/areaSearch/components/AreaSearchTooltip.tsx similarity index 84% rename from src/areaSearch/components/AreaSearchTooltip.jsx rename to src/areaSearch/components/AreaSearchTooltip.tsx index 6b61d72b..13b870fc 100644 --- a/src/areaSearch/components/AreaSearchTooltip.jsx +++ b/src/areaSearch/components/AreaSearchTooltip.tsx @@ -6,26 +6,28 @@ import { useMemo } from 'react'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone, HitDetail } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import './AreaSearchTooltip.scss'; const OFFSET_X = 14; const OFFSET_Y = -20; -/** nationalCode → 국기 SVG URL */ -function getNationalFlagUrl(nationalCode) { +/** nationalCode -> 국기 SVG URL */ +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; return `/ship/image/small/${nationalCode}.svg`; } -export function formatTimestamp(ms) { +export function formatTimestamp(ms: number | null | undefined): string { if (!ms) return '-'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -export function formatPosition(pos) { +export function formatPosition(pos: number[] | null | undefined): string | null { if (!pos || pos.length < 2) return null; const lon = pos[0]; const lat = pos[1]; @@ -41,8 +43,8 @@ export default function AreaSearchTooltip() { const zones = useAreaSearchStore((s) => s.zones); const zoneMap = useMemo(() => { - const map = new Map(); - zones.forEach((z, idx) => { + const map = new Map(); + zones.forEach((z: Zone, idx: number) => { map.set(z.id, z); map.set(z.name, z); map.set(idx, z); @@ -54,16 +56,16 @@ export default function AreaSearchTooltip() { if (!tooltip) return null; const { vesselId, x, y } = tooltip; - const track = tracks.find((t) => t.vesselId === vesselId); + const track = tracks.find((t: ProcessedTrack) => t.vesselId === vesselId); if (!track) return null; - const hits = hitDetails[vesselId] || []; + const hits: HitDetail[] = hitDetails[vesselId] || []; const kindName = getShipKindName(track.shipKindCode); const sourceName = getSignalSourceName(track.sigSrcCd); const flagUrl = getNationalFlagUrl(track.nationalCode); // 시간순 정렬 (구역 무관) - const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp); + const sortedHits = [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)); return (
    { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} diff --git a/src/areaSearch/components/StsAnalysisTab.jsx b/src/areaSearch/components/StsAnalysisTab.tsx similarity index 90% rename from src/areaSearch/components/StsAnalysisTab.jsx rename to src/areaSearch/components/StsAnalysisTab.tsx index 2483f004..5314496c 100644 --- a/src/areaSearch/components/StsAnalysisTab.jsx +++ b/src/areaSearch/components/StsAnalysisTab.tsx @@ -5,33 +5,37 @@ * - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리) * - 결과: StsContactList */ -import { useCallback, useState } from 'react'; +import { useCallback, useState, ChangeEvent } from 'react'; import './StsAnalysisTab.scss'; import { useStsStore } from '../stores/stsStore'; -import { useAreaSearchStore } from '../stores/areaSearchStore'; import { STS_LIMITS } from '../types/sts.types'; import ZoneDrawPanel from './ZoneDrawPanel'; import StsContactList from './StsContactList'; import StsContactDetailModal from './StsContactDetailModal'; -export default function StsAnalysisTab({ isLoading, errorMessage }) { +interface StsAnalysisTabProps { + isLoading: boolean; + errorMessage: string; +} + +export default function StsAnalysisTab({ isLoading, errorMessage }: StsAnalysisTabProps) { const queryCompleted = useStsStore((s) => s.queryCompleted); const groupedContacts = useStsStore((s) => s.groupedContacts); const summary = useStsStore((s) => s.summary); const minContactDuration = useStsStore((s) => s.minContactDurationMinutes); const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters); - const handleDurationChange = useCallback((e) => { + const handleDurationChange = useCallback((e: ChangeEvent) => { useStsStore.getState().setMinContactDuration(Number(e.target.value)); }, []); - const handleDistanceChange = useCallback((e) => { + const handleDistanceChange = useCallback((e: ChangeEvent) => { useStsStore.getState().setMaxContactDistance(Number(e.target.value)); }, []); - const [detailGroupIndex, setDetailGroupIndex] = useState(null); + const [detailGroupIndex, setDetailGroupIndex] = useState(null); - const handleDetailClick = useCallback((idx) => { + const handleDetailClick = useCallback((idx: number) => { setDetailGroupIndex(idx); }, []); diff --git a/src/areaSearch/components/StsContactDetailModal.jsx b/src/areaSearch/components/StsContactDetailModal.tsx similarity index 88% rename from src/areaSearch/components/StsContactDetailModal.jsx rename to src/areaSearch/components/StsContactDetailModal.tsx index d5d90f65..46697839 100644 --- a/src/areaSearch/components/StsContactDetailModal.jsx +++ b/src/areaSearch/components/StsContactDetailModal.tsx @@ -1,10 +1,10 @@ /** - * STS 접촉 쌍 상세 모달 — 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장 + * STS 접촉 쌍 상세 모달 -- 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장 * 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시 */ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import Map from 'ol/Map'; +import OlMap from 'ol/Map'; import View from 'ol/View'; import { XYZ } from 'ol/source'; import TileLayer from 'ol/layer/Tile'; @@ -21,6 +21,9 @@ import html2canvas from 'html2canvas'; import { useStsStore } from '../stores/stsStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { StsVessel, StsContact } from '../types/sts.types'; import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; import { @@ -32,13 +35,13 @@ import { import { mapLayerConfig } from '../../map/layers/baseLayer'; import './StsContactDetailModal.scss'; -function getNationalFlagUrl(nationalCode) { +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; return `/ship/image/small/${nationalCode}.svg`; } -function createZoneFeatures(zones) { - const features = []; +function createZoneFeatures(zones: Zone[]): Feature[] { + const features: Feature[] = []; zones.forEach((zone) => { const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); const polygon = new Polygon([coords3857]); @@ -68,7 +71,7 @@ function createZoneFeatures(zones) { return features; } -function createTrackFeature(track) { +function createTrackFeature(track: ProcessedTrack): Feature { const coords3857 = track.geometry.map((c) => fromLonLat(c)); const line = new LineString(coords3857); const feature = new Feature({ geometry: line }); @@ -82,14 +85,14 @@ function createTrackFeature(track) { return feature; } -function createContactMarkers(contacts) { - const features = []; +function createContactMarkers(contacts: StsContact[]): Feature[] { + const features: Feature[] = []; contacts.forEach((contact, idx) => { if (!contact.contactCenterPoint) return; const pos3857 = fromLonLat(contact.contactCenterPoint); - const riskColor = getContactRiskColor(contact.indicators); + const riskColor = getContactRiskColor(contact.indicators ?? null); const f = new Feature({ geometry: new Point(pos3857) }); f.setStyle(new Style({ @@ -131,14 +134,19 @@ function createContactMarkers(contacts) { const MODAL_WIDTH = 680; const MODAL_APPROX_HEIGHT = 780; -export default function StsContactDetailModal({ groupIndex, onClose }) { +interface StsContactDetailModalProps { + groupIndex: number; + onClose: () => void; +} + +export default function StsContactDetailModal({ groupIndex, onClose }: StsContactDetailModalProps) { const groupedContacts = useStsStore((s) => s.groupedContacts); const tracks = useStsStore((s) => s.tracks); const zones = useAreaSearchStore((s) => s.zones); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const contentRef = useRef(null); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); const [position, setPosition] = useState(() => ({ x: (window.innerWidth - MODAL_WIDTH) / 2, @@ -148,7 +156,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const dragging = useRef(false); const dragStart = useRef({ x: 0, y: 0 }); - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - posRef.current.x, @@ -158,7 +166,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { }, []); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, @@ -180,11 +188,11 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]); const vessel1Track = useMemo( - () => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId), + () => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel1?.vesselId), [tracks, group], ); const vessel2Track = useMemo( - () => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId), + () => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel2?.vesselId), [tracks, group], ); @@ -193,7 +201,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return; const tileSource = new XYZ({ - url: mapLayerConfig.darkLayer.source.getUrls()[0], + url: mapLayerConfig.darkLayer.source.getUrls()![0], minZoom: 6, maxZoom: 11, }); @@ -211,7 +219,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const markerSource = new VectorSource({ features: markerFeatures }); const markerLayer = new VectorLayer({ source: markerSource }); - const map = new Map({ + const map = new OlMap({ target: mapContainerRef.current, layers: [tileLayer, zoneLayer, trackLayer, markerLayer], view: new View({ center: [0, 0], zoom: 7 }), @@ -230,7 +238,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { mapRef.current = map; return () => { - map.setTarget(null); + map.setTarget(undefined); map.dispose(); mapRef.current = null; }; @@ -240,7 +248,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const el = contentRef.current; if (!el) return; - const modal = el.parentElement; + const modal = el.parentElement as HTMLElement; const saved = { elOverflow: el.style.overflow, modalMaxHeight: modal.style.maxHeight, @@ -261,7 +269,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { if (!blob) return; const url = URL.createObjectURL(blob); const link = document.createElement('a'); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); const now = new Date(); const v1Name = group?.vessel1?.vesselName || 'V1'; const v2Name = group?.vessel2?.vesselName || 'V2'; @@ -301,7 +309,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
    - + {'\u2194'}
    @@ -167,7 +174,7 @@ function GroupCard({ group, index, onDetailClick }) { {group.contacts.length > 1 && (
    접촉 이력 ({group.contacts.length}회) - {group.contacts.map((c, ci) => ( + {group.contacts.map((c: StsContact, ci: number) => (
    #{ci + 1} {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)} @@ -208,7 +215,12 @@ function GroupCard({ group, index, onDetailClick }) { ); } -function VesselDetail({ label, vessel }) { +interface VesselDetailProps { + label: string; + vessel: StsVessel; +} + +function VesselDetail({ label, vessel }: VesselDetailProps) { return (
    @@ -233,10 +245,14 @@ function VesselDetail({ label, vessel }) { ); } -export default function StsContactList({ onDetailClick }) { +interface StsContactListProps { + onDetailClick?: (idx: number) => void; +} + +export default function StsContactList({ onDetailClick }: StsContactListProps) { const groupedContacts = useStsStore((s) => s.groupedContacts); const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex); - const listRef = useRef(null); + const listRef = useRef(null); useEffect(() => { if (highlightedGroupIndex === null || !listRef.current) return; @@ -253,7 +269,7 @@ export default function StsContactList({ onDetailClick }) { return (
      - {groupedContacts.map((group, idx) => ( + {groupedContacts.map((group: StsGroupedContact, idx: number) => ( ))}
    diff --git a/src/areaSearch/components/VesselDetailModal.jsx b/src/areaSearch/components/VesselDetailModal.tsx similarity index 85% rename from src/areaSearch/components/VesselDetailModal.jsx rename to src/areaSearch/components/VesselDetailModal.tsx index 112aca3b..b178bb3a 100644 --- a/src/areaSearch/components/VesselDetailModal.jsx +++ b/src/areaSearch/components/VesselDetailModal.tsx @@ -1,9 +1,9 @@ /** - * 선박 상세 모달 — 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장 + * 선박 상세 모달 -- 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장 */ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import Map from 'ol/Map'; +import OlMap from 'ol/Map'; import View from 'ol/View'; import { XYZ } from 'ol/source'; import TileLayer from 'ol/layer/Tile'; @@ -19,18 +19,20 @@ import html2canvas from 'html2canvas'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone, HitDetail } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; import { mapLayerConfig } from '../../map/layers/baseLayer'; import './VesselDetailModal.scss'; -function getNationalFlagUrl(nationalCode) { +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; return `/ship/image/small/${nationalCode}.svg`; } -function createZoneFeatures(zones) { - const features = []; +function createZoneFeatures(zones: Zone[]): Feature[] { + const features: Feature[] = []; zones.forEach((zone) => { const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); const polygon = new Polygon([coords3857]); @@ -60,7 +62,7 @@ function createZoneFeatures(zones) { return features; } -function createTrackFeature(track) { +function createTrackFeature(track: ProcessedTrack): Feature { const coords3857 = track.geometry.map((c) => fromLonLat(c)); const line = new LineString(coords3857); const feature = new Feature({ geometry: line }); @@ -74,8 +76,8 @@ function createTrackFeature(track) { return feature; } -function createMarkerFeatures(sortedHits) { - const features = []; +function createMarkerFeatures(sortedHits: HitDetail[]): Feature[] { + const features: Feature[] = []; sortedHits.forEach((hit, idx) => { const seqNum = idx + 1; @@ -133,26 +135,32 @@ function createMarkerFeatures(sortedHits) { } /** - * 마커 텍스트 겹침 보정 — 포인트(원)는 그대로, 텍스트 offsetY만 조정 + * 마커 텍스트 겹침 보정 -- 포인트(원)는 그대로, 텍스트 offsetY만 조정 * 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치 */ -function adjustOverlappingLabels(features, resolution) { +function adjustOverlappingLabels(features: Feature[], resolution: number | undefined): void { if (!resolution || features.length < 2) return; const PROXIMITY_PX = 40; const proximityMap = resolution * PROXIMITY_PX; const LINE_HEIGHT_PX = 16; + interface MarkerItem { + feature: Feature; + x: number; + y: number; + } + // 피처별 좌표 추출 - const items = features.map((f) => { - const coord = f.getGeometry().getCoordinates(); + const items: MarkerItem[] = features.map((f) => { + const coord = (f.getGeometry() as Point).getCoordinates(); return { feature: f, x: coord[0], y: coord[1] }; }); // 근접 그룹 찾기 (Union-Find 방식) const parent = items.map((_, i) => i); - const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; - const union = (a, b) => { parent[find(a)] = find(b); }; + const find = (i: number): number => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; + const union = (a: number, b: number) => { parent[find(a)] = find(b); }; for (let i = 0; i < items.length; i++) { for (let j = i + 1; j < items.length; j++) { @@ -164,8 +172,8 @@ function adjustOverlappingLabels(features, resolution) { } } - // 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용) - const groups = {}; + // 그룹별 텍스트 offsetY 분산 + const groups: Record = {}; items.forEach((item, i) => { const root = find(i); if (!groups[root]) groups[root] = []; @@ -174,10 +182,10 @@ function adjustOverlappingLabels(features, resolution) { Object.values(groups).forEach((group) => { if (group.length < 2) return; - // 시퀀스 번호 순 정렬 후 IN→OUT 순서 + // 시퀀스 번호 순 정렬 후 IN->OUT 순서 group.sort((a, b) => { - const seqA = a.feature.get('_seqNum'); - const seqB = b.feature.get('_seqNum'); + const seqA = a.feature.get('_seqNum') as number; + const seqB = b.feature.get('_seqNum') as number; if (seqA !== seqB) return seqA - seqB; const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1; const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1; @@ -188,7 +196,7 @@ function adjustOverlappingLabels(features, resolution) { const startY = -totalHeight / 2 - 8; group.forEach((item, idx) => { - const style = item.feature.getStyle(); + const style = item.feature.getStyle() as Style; const textStyle = style.getText(); if (textStyle) { textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX); @@ -200,14 +208,19 @@ function adjustOverlappingLabels(features, resolution) { const MODAL_WIDTH = 680; const MODAL_APPROX_HEIGHT = 780; -export default function VesselDetailModal({ vesselId, onClose }) { +interface VesselDetailModalProps { + vesselId: string; + onClose: () => void; +} + +export default function VesselDetailModal({ vesselId, onClose }: VesselDetailModalProps) { const tracks = useAreaSearchStore((s) => s.tracks); const hitDetails = useAreaSearchStore((s) => s.hitDetails); const zones = useAreaSearchStore((s) => s.zones); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const contentRef = useRef(null); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); // 드래그 위치 관리 const [position, setPosition] = useState(() => ({ @@ -218,7 +231,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { const dragging = useRef(false); const dragStart = useRef({ x: 0, y: 0 }); - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - posRef.current.x, @@ -228,7 +241,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { }, []); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, @@ -249,14 +262,14 @@ export default function VesselDetailModal({ vesselId, onClose }) { }, []); const track = useMemo( - () => tracks.find((t) => t.vesselId === vesselId), + () => tracks.find((t: ProcessedTrack) => t.vesselId === vesselId), [tracks, vesselId], ); - const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]); + const hits: HitDetail[] = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]); const zoneMap = useMemo(() => { - const lookup = {}; - zones.forEach((z, idx) => { + const lookup: Record = {}; + zones.forEach((z: Zone, idx: number) => { lookup[z.id] = z; lookup[z.name] = z; lookup[idx] = z; @@ -266,7 +279,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { }, [zones]); const sortedHits = useMemo( - () => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp), + () => [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)), [hits], ); @@ -275,7 +288,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { if (!mapContainerRef.current || !track) return; const tileSource = new XYZ({ - url: mapLayerConfig.darkLayer.source.getUrls()[0], + url: mapLayerConfig.darkLayer.source.getUrls()![0], minZoom: 6, maxZoom: 11, }); @@ -291,7 +304,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { const markerSource = new VectorSource({ features: markerFeatures }); const markerLayer = new VectorLayer({ source: markerSource }); - const map = new Map({ + const map = new OlMap({ target: mapContainerRef.current, layers: [tileLayer, zoneLayer, trackLayer, markerLayer], view: new View({ center: [0, 0], zoom: 7 }), @@ -315,7 +328,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { mapRef.current = map; return () => { - map.setTarget(null); + map.setTarget(undefined); map.dispose(); mapRef.current = null; }; @@ -325,7 +338,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { const el = contentRef.current; if (!el) return; - const modal = el.parentElement; + const modal = el.parentElement as HTMLElement; const saved = { elOverflow: el.style.overflow, modalMaxHeight: modal.style.maxHeight, @@ -347,7 +360,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { if (!blob) return; const url = URL.createObjectURL(blob); const link = document.createElement('a'); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); const now = new Date(); const name = track?.shipName || track?.targetId || 'vessel'; link.href = url; @@ -383,7 +396,7 @@ export default function VesselDetailModal({ vesselId, onClose }) { {kindName} {flagUrl && ( - 국기 { e.target.style.display = 'none'; }} /> + 국기 { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} diff --git a/src/areaSearch/components/ZoneDrawPanel.jsx b/src/areaSearch/components/ZoneDrawPanel.tsx similarity index 85% rename from src/areaSearch/components/ZoneDrawPanel.jsx rename to src/areaSearch/components/ZoneDrawPanel.tsx index 58bd374b..7ab67944 100644 --- a/src/areaSearch/components/ZoneDrawPanel.jsx +++ b/src/areaSearch/components/ZoneDrawPanel.tsx @@ -6,8 +6,14 @@ import { ZONE_DRAW_TYPES, ZONE_COLORS, } from '../types/areaSearch.types'; +import type { ZoneDrawType, Zone } from '../types/areaSearch.types'; -export default function ZoneDrawPanel({ disabled, maxZones }) { +interface ZoneDrawPanelProps { + disabled?: boolean; + maxZones?: number; +} + +export default function ZoneDrawPanel({ disabled, maxZones }: ZoneDrawPanelProps) { const effectiveMaxZones = maxZones ?? MAX_ZONES; const zones = useAreaSearchStore((s) => s.zones); const activeDrawType = useAreaSearchStore((s) => s.activeDrawType); @@ -21,13 +27,13 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { const canAddZone = zones.length < effectiveMaxZones; - const handleDrawClick = useCallback((type) => { + const handleDrawClick = useCallback((type: ZoneDrawType) => { if (!canAddZone || disabled) return; if (!confirmAndClearResults()) return; setActiveDrawType(activeDrawType === type ? null : type); }, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]); - const handleZoneClick = useCallback((zoneId) => { + const handleZoneClick = useCallback((zoneId: string) => { if (disabled) return; if (selectedZoneId === zoneId) { deselectZone(); @@ -37,26 +43,26 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { } }, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]); - const handleRemoveZone = useCallback((e, zoneId) => { + const handleRemoveZone = useCallback((e: React.MouseEvent, zoneId: string) => { e.stopPropagation(); if (!confirmAndClearResults()) return; removeZone(zoneId); }, [removeZone, confirmAndClearResults]); // 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적) - const dragIndexRef = useRef(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + const dragIndexRef = useRef(null); + const [dragOverIndex, setDragOverIndex] = useState(null); - const handleDragStart = useCallback((e, index) => { + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { dragIndexRef.current = index; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); requestAnimationFrame(() => { - e.target.classList.add('dragging'); + (e.target as HTMLElement).classList.add('dragging'); }); }, []); - const handleDragOver = useCallback((e, index) => { + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragIndexRef.current !== null && dragIndexRef.current !== index) { @@ -64,7 +70,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { } }, []); - const handleDrop = useCallback((e, toIndex) => { + const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => { e.preventDefault(); const fromIndex = dragIndexRef.current; if (fromIndex !== null && fromIndex !== toIndex) { @@ -127,7 +133,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { {/* 구역 목록 */} {zones.length > 0 && (
      - {zones.map((zone, index) => { + {zones.map((zone: Zone, index: number) => { const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; return (
    • ; + shipKindCodeFilter: Set; + highlightedVesselId: string | null; +} + +export default function useAreaSearchLayer(): void { + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); // 정적 레이어 캐시 (필터/하이라이트 변경 시에만 갱신) - const staticLayerCacheRef = useRef({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null }); // React 구독: 필터/상태 (비빈번 변경만) const queryCompleted = useAreaSearchStore((s) => s.queryCompleted); @@ -36,7 +53,7 @@ export default function useAreaSearchLayer() { const showPaths = useAreaSearchStore((s) => s.showPaths); const showTrail = useAreaSearchStore((s) => s.showTrail); const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter); - // currentTime — React 구독 제거, zustand.subscribe로 대체 + // currentTime -- React 구독 제거, zustand.subscribe로 대체 /** * 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음) @@ -48,14 +65,15 @@ export default function useAreaSearchLayer() { const ct = useAreaSearchAnimationStore.getState().currentTime; const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct); const filteredPositions = allPositions.filter( - (p) => shipKindCodeFilter.has(p.shipKindCode), + (p: VesselPosition) => shipKindCodeFilter.has(p.shipKindCode), ); - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; - // 1. TripsLayer 궤적 (동적 — currentTime 의존) + // 1. TripsLayer 궤적 (동적 -- currentTime 의존) if (showTrail && tripsDataRef.current.length > 0) { - const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId)); + const iconVesselIds = new Set(filteredPositions.map((p: VesselPosition) => p.vesselId)); const filteredTripsData = tripsDataRef.current.filter( (d) => iconVesselIds.has(d.vesselId), ); @@ -65,8 +83,9 @@ export default function useAreaSearchLayer() { new TripsLayer({ id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL, data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry + getPath: (d: TripsDataItem) => d.path, + getTimestamps: (d: TripsDataItem) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -80,7 +99,7 @@ export default function useAreaSearchLayer() { } } - // 2. 정적 PathLayer (캐싱 — 필터/하이라이트 변경 시에만 재생성) + // 2. 정적 PathLayer (캐싱 -- 필터/하이라이트 변경 시에만 재생성) if (showPaths) { const deps = staticLayerCacheRef.current.deps; const needsRebuild = !deps @@ -90,7 +109,7 @@ export default function useAreaSearchLayer() { || deps.highlightedVesselId !== highlightedVesselId; if (needsRebuild) { - const filteredTracks = tracks.filter((t) => + const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode), ); staticLayerCacheRef.current = { @@ -98,7 +117,7 @@ export default function useAreaSearchLayer() { tracks: filteredTracks, showPoints: false, highlightedVesselId, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH }, @@ -114,14 +133,14 @@ export default function useAreaSearchLayer() { currentPositions: filteredPositions, showVirtualShip: filteredPositions.length > 0, showLabels: filteredPositions.length > 0, - onIconHover: (shipData, x, y) => { + onIconHover: (shipData, _x, _y) => { if (shipData) { useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId); } else { useAreaSearchStore.getState().setHighlightedVesselId(null); } }, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, layerIds: { @@ -156,17 +175,17 @@ export default function useAreaSearchLayer() { startTimeRef.current = sTime; tripsDataRef.current = tracks - .filter((t) => t.geometry.length >= 2) - .map((track) => ({ + .filter((t: ProcessedTrack) => t.geometry.length >= 2) + .map((track: ProcessedTrack) => ({ vesselId: track.vesselId, shipKindCode: track.shipKindCode, path: track.geometry, - timestamps: track.timestampsMs.map((t) => t - sTime), + timestamps: track.timestampsMs.map((t: number) => t - sTime), })); }, [queryCompleted, tracks]); /** - * currentTime 구독 (zustand.subscribe — React 리렌더 바이패스) + * currentTime 구독 (zustand.subscribe -- React 리렌더 바이패스) * 재생 중: ~10fps 쓰로틀 (RENDER_INTERVAL_MS) * seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지) */ @@ -176,7 +195,7 @@ export default function useAreaSearchLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAreaSearchAnimationStore.subscribe( (s) => s.currentTime, diff --git a/src/areaSearch/hooks/useStsLayer.js b/src/areaSearch/hooks/useStsLayer.ts similarity index 74% rename from src/areaSearch/hooks/useStsLayer.js rename to src/areaSearch/hooks/useStsLayer.ts index 4c0d3225..c2baea6d 100644 --- a/src/areaSearch/hooks/useStsLayer.js +++ b/src/areaSearch/hooks/useStsLayer.ts @@ -15,6 +15,9 @@ import { useStsStore } from '../stores/stsStore'; import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'; import { STS_LAYER_IDS } from '../types/sts.types'; import { getContactRiskColor } from '../types/sts.types'; +import type { StsGroupedContact, StsIndicators } from '../types/sts.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { VesselPosition } from '../types/areaSearch.types'; import { registerStsLayers, unregisterStsLayers, @@ -25,11 +28,39 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; const TRAIL_LENGTH_MS = 3600000; const RENDER_INTERVAL_MS = 100; -export default function useStsLayer() { - const tripsDataRef = useRef([]); +interface TripsDataItem { + vesselId: string; + shipKindCode: string; + path: number[][]; + timestamps: number[]; +} + +interface EnabledContact { + contactCenterPoint?: number[]; + indicators?: StsIndicators; + _groupIdx: number; + [key: string]: unknown; +} + +interface ContactLayerCacheDeps { + groupedContacts: StsGroupedContact[]; + disabledGroupIndices: Set; + highlightedGroupIndex: number | null; +} + +interface StaticLayerCacheDeps { + tracks: ProcessedTrack[]; + disabledGroupIndices: Set; + highlightedGroupIndex: number | null; +} + +export default function useStsLayer(): void { + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); - const staticLayerCacheRef = useRef({ layers: [], deps: null }); - const contactLayerCacheRef = useRef({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const contactLayerCacheRef = useRef<{ layers: any[]; deps: ContactLayerCacheDeps | null }>({ layers: [], deps: null }); // React 구독: 그룹 기반 const queryCompleted = useStsStore((s) => s.queryCompleted); @@ -52,11 +83,12 @@ export default function useStsLayer() { if (!needsRebuild) return contactLayerCacheRef.current.layers; - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; // disabled가 아닌 그룹의 모든 하위 contacts를 flat - const enabledContacts = []; - groupedContacts.forEach((group, gIdx) => { + const enabledContacts: EnabledContact[] = []; + groupedContacts.forEach((group: StsGroupedContact, gIdx: number) => { if (disabledGroupIndices.has(gIdx)) return; group.contacts.forEach((c) => { enabledContacts.push({ @@ -79,9 +111,10 @@ export default function useStsLayer() { new ScatterplotLayer({ id: STS_LAYER_IDS.CONTACT_POINT, data: enabledContacts.filter((c) => c.contactCenterPoint), - getPosition: (d) => d.contactCenterPoint, - getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500, - getFillColor: (d) => getContactRiskColor(d.indicators), + // @ts-expect-error Deck.gl runtime accepts number[] for Position + getPosition: (d: EnabledContact) => d.contactCenterPoint as number[], + getRadius: (d: EnabledContact) => d._groupIdx === highlightedGroupIndex ? 800 : 500, + getFillColor: (d: EnabledContact) => getContactRiskColor(d.indicators ?? null), radiusMinPixels: 4, radiusMaxPixels: 12, pickable: true, @@ -107,11 +140,12 @@ export default function useStsLayer() { const ct = useAreaSearchAnimationStore.getState().currentTime; const allPositions = useStsStore.getState().getCurrentPositions(ct); - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; // 1. TripsLayer 궤적 if (showTrail && tripsDataRef.current.length > 0) { - const iconVesselIds = new Set(allPositions.map((p) => p.vesselId)); + const iconVesselIds = new Set(allPositions.map((p: VesselPosition) => p.vesselId)); const filteredTripsData = tripsDataRef.current.filter( (d) => iconVesselIds.has(d.vesselId), ); @@ -121,8 +155,9 @@ export default function useStsLayer() { new TripsLayer({ id: STS_LAYER_IDS.TRIPS_TRAIL, data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry + getPath: (d: TripsDataItem) => d.path, + getTimestamps: (d: TripsDataItem) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -141,7 +176,7 @@ export default function useStsLayer() { const disabledVesselIds = useStsStore.getState().getDisabledVesselIds(); // 접촉 쌍의 양쪽 선박 항적 하이라이트 - let stsHighlightedVesselIds = null; + let stsHighlightedVesselIds: Set | null = null; if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) { const g = groupedContacts[highlightedGroupIndex]; stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]); @@ -154,21 +189,21 @@ export default function useStsLayer() { || deps.highlightedGroupIndex !== highlightedGroupIndex; if (needsRebuild) { - const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId)); + const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId)); staticLayerCacheRef.current = { layers: createStaticTrackLayers({ tracks: filteredTracks, showPoints: false, highlightedVesselIds: stsHighlightedVesselIds, layerIds: { path: STS_LAYER_IDS.TRACK_PATH }, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { if (!vesselId) { useStsStore.getState().setHighlightedGroupIndex(null); return; } const groups = useStsStore.getState().groupedContacts; const idx = groups.findIndex( - (g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId, + (g: StsGroupedContact) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId, ); useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null); }, @@ -220,12 +255,12 @@ export default function useStsLayer() { startTimeRef.current = sTime; tripsDataRef.current = tracks - .filter((t) => t.geometry.length >= 2) - .map((track) => ({ + .filter((t: ProcessedTrack) => t.geometry.length >= 2) + .map((track: ProcessedTrack) => ({ vesselId: track.vesselId, shipKindCode: track.shipKindCode, path: track.geometry, - timestamps: track.timestampsMs.map((t) => t - sTime), + timestamps: track.timestampsMs.map((t: number) => t - sTime), })); }, [queryCompleted, tracks]); @@ -238,7 +273,7 @@ export default function useStsLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAreaSearchAnimationStore.subscribe( (s) => s.currentTime, diff --git a/src/areaSearch/hooks/useZoneDraw.js b/src/areaSearch/hooks/useZoneDraw.ts similarity index 80% rename from src/areaSearch/hooks/useZoneDraw.js rename to src/areaSearch/hooks/useZoneDraw.ts index a79f1cbe..0f75b73b 100644 --- a/src/areaSearch/hooks/useZoneDraw.js +++ b/src/areaSearch/hooks/useZoneDraw.ts @@ -15,15 +15,21 @@ import { createBox } from 'ol/interaction/Draw'; import { Style, Fill, Stroke } from 'ol/style'; import { transform } from 'ol/proj'; import { fromCircle } from 'ol/geom/Polygon'; +import type OlMap from 'ol/Map'; +import type { Coordinate } from 'ol/coordinate'; +import type Feature from 'ol/Feature'; +import type { Geometry, Circle as OlCircleGeom, Polygon } from 'ol/geom'; +import type { DrawEvent } from 'ol/interaction/Draw'; import { useMapStore } from '../../stores/mapStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types'; +import type { ZoneDrawType, Zone, CircleMeta } from '../types/areaSearch.types'; import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs'; /** * 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장 */ -function toWgs84Polygon(coords3857) { +function toWgs84Polygon(coords3857: Coordinate[]): number[][] { const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326')); // 폐곡선 보장 (첫점 == 끝점) if (coords4326.length > 0) { @@ -39,7 +45,7 @@ function toWgs84Polygon(coords3857) { /** * 구역 인덱스에 맞는 OL 스타일 생성 */ -function createZoneStyle(index) { +function createZoneStyle(index: number): Style { const color = ZONE_COLORS[index] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -47,10 +53,10 @@ function createZoneStyle(index) { }); } -export default function useZoneDraw() { +export default function useZoneDraw(): void { const map = useMapStore((s) => s.map); - const drawRef = useRef(null); - const mapRef = useRef(null); + const drawRef = useRef(null); + const mapRef = useRef(null); // map ref 동기화 (클린업에서 사용) useEffect(() => { @@ -72,10 +78,10 @@ export default function useZoneDraw() { // 기존 zones가 있으면 동기화 const { zones } = useAreaSearchStore.getState(); - zones.forEach((zone) => { + zones.forEach((zone: Zone) => { if (!zone.olFeature) return; zone.olFeature.setStyle(createZoneStyle(zone.colorIndex)); - source.addFeature(zone.olFeature); + source.addFeature(zone.olFeature as Feature); }); return () => { @@ -93,15 +99,15 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.zones, - (zones) => { + (zones: Zone[]) => { const source = getZoneSource(); if (!source) return; source.clear(); - zones.forEach((zone) => { + zones.forEach((zone: Zone) => { if (!zone.olFeature) return; zone.olFeature.setStyle(createZoneStyle(zone.colorIndex)); - source.addFeature(zone.olFeature); + source.addFeature(zone.olFeature as Feature); }); }, ); @@ -112,7 +118,7 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.showZones, - (show) => { + (show: boolean) => { const layer = getZoneLayer(); if (layer) layer.setVisible(show); }, @@ -121,7 +127,7 @@ export default function useZoneDraw() { }, []); // Draw 인터랙션 생성 함수 - const setupDraw = useCallback((currentMap, drawType) => { + const setupDraw = useCallback((currentMap: OlMap, drawType: ZoneDrawType | null) => { // 기존 인터랙션 제거 if (drawRef.current) { currentMap.removeInteraction(drawRef.current); @@ -137,7 +143,7 @@ export default function useZoneDraw() { // OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데, // 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여 // "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨. - let draw; + let draw: Draw; if (drawType === ZONE_DRAW_TYPES.BOX) { draw = new Draw({ type: 'Circle', geometryFunction: createBox() }); } else if (drawType === ZONE_DRAW_TYPES.CIRCLE) { @@ -146,22 +152,23 @@ export default function useZoneDraw() { draw = new Draw({ type: 'Polygon' }); } - draw.on('drawend', (evt) => { + draw.on('drawend', (evt: DrawEvent) => { const feature = evt.feature; - let geom = feature.getGeometry(); + let geom = feature.getGeometry()!; const typeName = drawType; // Circle → Polygon 변환 (center/radius 보존) - let circleMeta = null; + let circleMeta: CircleMeta | null = null; if (drawType === ZONE_DRAW_TYPES.CIRCLE) { - circleMeta = { center: geom.getCenter(), radius: geom.getRadius() }; - const polyGeom = fromCircle(geom, 64); + const circleGeom = geom as OlCircleGeom; + circleMeta = { center: circleGeom.getCenter() as [number, number], radius: circleGeom.getRadius() }; + const polyGeom = fromCircle(circleGeom, 64); feature.setGeometry(polyGeom); geom = polyGeom; } // EPSG:3857 → 4326 좌표 추출 - const coords3857 = geom.getCoordinates()[0]; + const coords3857 = (geom as Polygon).getCoordinates()[0]; const coordinates = toWgs84Polygon(coords3857); // 최소 4점 확인 @@ -187,7 +194,7 @@ export default function useZoneDraw() { type: typeName, source: 'draw', coordinates, - olFeature: feature, + olFeature: feature as Feature, circleMeta, }); // addZone → activeDrawType: null → subscription → removeInteraction @@ -204,7 +211,7 @@ export default function useZoneDraw() { const unsub = useAreaSearchStore.subscribe( (s) => s.activeDrawType, - (drawType) => { + (drawType: ZoneDrawType | null) => { setupDraw(map, drawType); }, ); @@ -227,7 +234,7 @@ export default function useZoneDraw() { // ESC 키로 그리기 취소 useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { const { activeDrawType } = useAreaSearchStore.getState(); if (activeDrawType) { @@ -243,15 +250,15 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.zones, - (zones, prevZones) => { + (zones: Zone[], prevZones: Zone[]) => { if (!prevZones || zones.length >= prevZones.length) return; const source = getZoneSource(); if (!source) return; - const currentIds = new Set(zones.map((z) => z.id)); - prevZones.forEach((z) => { + const currentIds = new Set(zones.map((z: Zone) => z.id)); + prevZones.forEach((z: Zone) => { if (!currentIds.has(z.id) && z.olFeature) { - try { source.removeFeature(z.olFeature); } catch { /* already removed */ } + try { source.removeFeature(z.olFeature as Feature); } catch { /* already removed */ } } }); }, diff --git a/src/areaSearch/hooks/useZoneEdit.js b/src/areaSearch/hooks/useZoneEdit.ts similarity index 78% rename from src/areaSearch/hooks/useZoneEdit.js rename to src/areaSearch/hooks/useZoneEdit.ts index 89a16d5f..528848e7 100644 --- a/src/areaSearch/hooks/useZoneEdit.js +++ b/src/areaSearch/hooks/useZoneEdit.ts @@ -14,15 +14,21 @@ import { Modify, Translate } from 'ol/interaction'; import Collection from 'ol/Collection'; import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'; import { transform } from 'ol/proj'; +import type OlMap from 'ol/Map'; +import type Feature from 'ol/Feature'; +import type { Geometry, Polygon } from 'ol/geom'; +import type { Coordinate } from 'ol/coordinate'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; import { useMapStore } from '../../stores/mapStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types'; +import type { Zone, CircleMeta } from '../types/areaSearch.types'; import { getZoneSource } from '../utils/zoneLayerRefs'; import BoxResizeInteraction from '../interactions/BoxResizeInteraction'; import CircleResizeInteraction from '../interactions/CircleResizeInteraction'; /** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */ -function toWgs84Polygon(coords3857) { +function toWgs84Polygon(coords3857: Coordinate[]): number[][] { const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326')); if (coords4326.length > 0) { const first = coords4326[0]; @@ -35,7 +41,7 @@ function toWgs84Polygon(coords3857) { } /** 선택된 구역의 하이라이트 스타일 */ -function createSelectedStyle(colorIndex) { +function createSelectedStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }), @@ -57,7 +63,7 @@ const MODIFY_STYLE = new Style({ }); /** 기본 구역 스타일 복원 */ -function createNormalStyle(colorIndex) { +function createNormalStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -66,7 +72,7 @@ function createNormalStyle(colorIndex) { } /** 호버 스타일 (스트로크 강조) */ -function createHoverStyle(colorIndex) { +function createHoverStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -78,7 +84,7 @@ function createHoverStyle(colorIndex) { } /** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */ -function pointToSegmentDist(p, a, b) { +function pointToSegmentDist(p: number[], a: number[], b: number[]): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lenSq = dx * dx + dy * dy; @@ -91,8 +97,8 @@ function pointToSegmentDist(p, a, b) { const HANDLE_TOLERANCE = 12; /** Polygon 꼭짓점/변 근접 검사 */ -function isNearPolygonHandle(map, pixel, feature) { - const coords = feature.getGeometry().getCoordinates()[0]; +function isNearPolygonHandle(map: OlMap, pixel: number[], feature: Feature): boolean { + const coords = (feature.getGeometry() as Polygon).getCoordinates()[0]; const n = coords.length - 1; for (let i = 0; i < n; i++) { const vp = map.getPixelFromCoordinate(coords[i]); @@ -103,7 +109,7 @@ function isNearPolygonHandle(map, pixel, feature) { for (let i = 0; i < n; i++) { const p1 = map.getPixelFromCoordinate(coords[i]); const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]); - if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < HANDLE_TOLERANCE) { return true; } } @@ -111,12 +117,12 @@ function isNearPolygonHandle(map, pixel, feature) { } /** Feature에서 좌표를 추출하여 store에 동기화 */ -function syncZoneToStore(zoneId, feature, zone) { - const geom = feature.getGeometry(); +function syncZoneToStore(zoneId: string, feature: Feature, zone: Zone): void { + const geom = feature.getGeometry() as Polygon; const coords3857 = geom.getCoordinates()[0]; const coords4326 = toWgs84Polygon(coords3857); - let circleMeta; + let circleMeta: CircleMeta | undefined; if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) { // 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산 const center = computeCentroid(coords3857); @@ -129,7 +135,7 @@ function syncZoneToStore(zoneId, feature, zone) { } /** 다각형 중심점 계산 */ -function computeCentroid(coords) { +function computeCentroid(coords: Coordinate[]): [number, number] { let sumX = 0, sumY = 0; const n = coords.length - 1; // 마지막(닫힘) 좌표 제외 for (let i = 0; i < n; i++) { @@ -139,17 +145,17 @@ function computeCentroid(coords) { return [sumX / n, sumY / n]; } -export default function useZoneEdit() { +export default function useZoneEdit(): void { const map = useMapStore((s) => s.map); - const mapRef = useRef(null); - const modifyRef = useRef(null); - const translateRef = useRef(null); - const customResizeRef = useRef(null); - const selectedCollectionRef = useRef(new Collection()); - const clickListenerRef = useRef(null); - const contextMenuRef = useRef(null); - const keydownRef = useRef(null); - const hoveredZoneIdRef = useRef(null); + const mapRef = useRef(null); + const modifyRef = useRef(null); + const translateRef = useRef(null); + const customResizeRef = useRef(null); + const selectedCollectionRef = useRef(new Collection>()); + const clickListenerRef = useRef<((evt: MapBrowserEvent) => void) | null>(null); + const contextMenuRef = useRef<((e: MouseEvent) => void) | null>(null); + const keydownRef = useRef<((e: KeyboardEvent) => void) | null>(null); + const hoveredZoneIdRef = useRef(null); useEffect(() => { mapRef.current = map; }, [map]); @@ -164,13 +170,13 @@ export default function useZoneEdit() { }, []); /** 선택된 구역에 대해 인터랙션 설정 */ - const setupInteractions = useCallback((currentMap, zone) => { + const setupInteractions = useCallback((currentMap: OlMap, zone: Zone) => { removeInteractions(); if (!zone || !zone.olFeature) return; - const feature = zone.olFeature; + const feature = zone.olFeature as Feature; const collection = selectedCollectionRef.current; - collection.push(feature); + collection.push(feature as Feature); // 선택 스타일 적용 feature.setStyle(createSelectedStyle(zone.colorIndex)); @@ -180,11 +186,11 @@ export default function useZoneEdit() { translate.on('translateend', () => { // Circle의 경우 center 업데이트 if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) { - const coords = feature.getGeometry().getCoordinates()[0]; + const coords = feature.getGeometry()!.getCoordinates()[0]; const newCenter = computeCentroid(coords); - customResizeRef.current.setCenter(newCenter); + (customResizeRef.current as CircleResizeInteraction).setCenter(newCenter); } - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }); currentMap.addInteraction(translate); translateRef.current = translate; @@ -197,7 +203,7 @@ export default function useZoneEdit() { deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체) }); modify.on('modifyend', () => { - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }); currentMap.addInteraction(modify); modifyRef.current = modify; @@ -205,19 +211,19 @@ export default function useZoneEdit() { } else if (zone.type === ZONE_DRAW_TYPES.BOX) { const boxResize = new BoxResizeInteraction({ feature, - onResize: () => syncZoneToStore(zone.id, feature, zone), + onResize: () => syncZoneToStore(zone.id, feature as Feature, zone), }); currentMap.addInteraction(boxResize); customResizeRef.current = boxResize; } else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) { - const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]); + const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry()!.getCoordinates()[0]); const circleResize = new CircleResizeInteraction({ feature, center, - onResize: (f) => { + onResize: (f: Feature) => { // 리사이즈 후 circleMeta 업데이트 - const coords = f.getGeometry().getCoordinates()[0]; + const coords = f.getGeometry()!.getCoordinates()[0]; const newCenter = computeCentroid(coords); const dx = coords[0][0] - newCenter[0]; const dy = coords[0][1] - newCenter[1]; @@ -232,9 +238,9 @@ export default function useZoneEdit() { }, [removeInteractions]); /** 구역 선택 해제 시 스타일 복원 */ - const restoreStyle = useCallback((zoneId) => { + const restoreStyle = useCallback((zoneId: string) => { const { zones } = useAreaSearchStore.getState(); - const zone = zones.find(z => z.id === zoneId); + const zone = zones.find((z: Zone) => z.id === zoneId); if (zone && zone.olFeature) { zone.olFeature.setStyle(createNormalStyle(zone.colorIndex)); } @@ -244,11 +250,11 @@ export default function useZoneEdit() { useEffect(() => { if (!map) return; - let prevSelectedId = null; + let prevSelectedId: string | null = null; const unsub = useAreaSearchStore.subscribe( (s) => s.selectedZoneId, - (zoneId) => { + (zoneId: string | null) => { // 이전 선택 스타일 복원 if (prevSelectedId) restoreStyle(prevSelectedId); prevSelectedId = zoneId; @@ -259,7 +265,7 @@ export default function useZoneEdit() { } const { zones } = useAreaSearchStore.getState(); - const zone = zones.find(z => z.id === zoneId); + const zone = zones.find((z: Zone) => z.id === zoneId); if (zone) { setupInteractions(map, zone); } @@ -290,7 +296,7 @@ export default function useZoneEdit() { useEffect(() => { if (!map) return; - const handleClick = (evt) => { + const handleClick = (evt: MapBrowserEvent) => { // Drawing 중이면 무시 if (useAreaSearchStore.getState().activeDrawType) return; @@ -301,24 +307,24 @@ export default function useZoneEdit() { if (!source) return; // 클릭 지점의 feature 탐색 - let clickedZone = null; + let clickedZone: Zone | undefined; const { zones } = useAreaSearchStore.getState(); map.forEachFeatureAtPixel(evt.pixel, (feature) => { if (clickedZone) return; // 이미 찾았으면 무시 - const zone = zones.find(z => z.olFeature === feature); + const zone = zones.find((z: Zone) => z.olFeature === feature); if (zone) clickedZone = zone; }, { layerFilter: (layer) => layer.getSource() === source }); const { selectedZoneId } = useAreaSearchStore.getState(); if (clickedZone) { - if (clickedZone.id === selectedZoneId) return; // 이미 선택됨 + if ((clickedZone as Zone).id === selectedZoneId) return; // 이미 선택됨 // 결과 표시 중이면 confirmAndClearResults if (!useAreaSearchStore.getState().confirmAndClearResults()) return; - useAreaSearchStore.getState().selectZone(clickedZone.id); + useAreaSearchStore.getState().selectZone((clickedZone as Zone).id); } else { // 빈 영역 클릭 → 선택 해제 if (selectedZoneId) { @@ -340,17 +346,17 @@ export default function useZoneEdit() { useEffect(() => { if (!map) return; - const handleContextMenu = (e) => { + const handleContextMenu = (e: MouseEvent) => { const { selectedZoneId, zones } = useAreaSearchStore.getState(); if (!selectedZoneId) return; - const zone = zones.find(z => z.id === selectedZoneId); + const zone = zones.find((z: Zone) => z.id === selectedZoneId); if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return; const feature = zone.olFeature; if (!feature) return; - const geom = feature.getGeometry(); + const geom = feature.getGeometry() as Polygon; const coords = geom.getCoordinates()[0]; const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외 if (vertexCount <= 3) return; // 최소 삼각형 유지 @@ -380,7 +386,7 @@ export default function useZoneEdit() { newCoords[newCoords.length - 1] = [...newCoords[0]]; } geom.setCoordinates([newCoords]); - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }; const viewport = map.getViewport(); @@ -395,7 +401,7 @@ export default function useZoneEdit() { // 키보드: ESC → 선택 해제, Delete → 구역 삭제 useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: KeyboardEvent) => { const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState(); if (e.key === 'Escape' && selectedZoneId && !activeDrawType) { @@ -423,7 +429,7 @@ export default function useZoneEdit() { const viewport = map.getViewport(); - const handlePointerMove = (evt) => { + const handlePointerMove = (evt: MapBrowserEvent) => { if (evt.dragging) return; // Drawing 중이면 호버 해제 @@ -443,11 +449,11 @@ export default function useZoneEdit() { // 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서 if (selectedZoneId) { - const zone = zones.find(z => z.id === selectedZoneId); + const zone = zones.find((z: Zone) => z.id === selectedZoneId); if (zone && zone.olFeature) { // Box/Circle: isOverHandle if (customResizeRef.current && customResizeRef.current.isOverHandle) { - const handle = customResizeRef.current.isOverHandle(map, evt.pixel); + const handle = customResizeRef.current.isOverHandle(map, evt.pixel as unknown as number[]); if (handle) { viewport.style.cursor = handle.cursor; return; @@ -456,7 +462,7 @@ export default function useZoneEdit() { // Polygon: 꼭짓점/변 근접 if (zone.type === ZONE_DRAW_TYPES.POLYGON) { - if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) { + if (isNearPolygonHandle(map, evt.pixel as unknown as number[], zone.olFeature as Feature)) { viewport.style.cursor = 'crosshair'; return; } @@ -476,19 +482,20 @@ export default function useZoneEdit() { } // 2. 비선택 구역 호버 - let hoveredZone = null; + let hoveredZone: Zone | undefined; map.forEachFeatureAtPixel(evt.pixel, (feature) => { if (hoveredZone) return; - const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId); + const zone = zones.find((z: Zone) => z.olFeature === feature && z.id !== selectedZoneId); if (zone) hoveredZone = zone; }, { layerFilter: (l) => l.getSource() === source }); if (hoveredZone) { + const hz = hoveredZone as Zone; viewport.style.cursor = 'pointer'; - if (hoveredZoneIdRef.current !== hoveredZone.id) { + if (hoveredZoneIdRef.current !== hz.id) { if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current); - hoveredZoneIdRef.current = hoveredZone.id; - hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex)); + hoveredZoneIdRef.current = hz.id; + hz.olFeature!.setStyle(createHoverStyle(hz.colorIndex)); } } else { viewport.style.cursor = ''; diff --git a/src/areaSearch/interactions/BoxResizeInteraction.js b/src/areaSearch/interactions/BoxResizeInteraction.ts similarity index 62% rename from src/areaSearch/interactions/BoxResizeInteraction.js rename to src/areaSearch/interactions/BoxResizeInteraction.ts index 6e72a818..2a5b9410 100644 --- a/src/areaSearch/interactions/BoxResizeInteraction.js +++ b/src/areaSearch/interactions/BoxResizeInteraction.ts @@ -6,12 +6,17 @@ * - 변 드래그: 반대쪽 변 고정, 1축 리사이즈 */ import PointerInteraction from 'ol/interaction/Pointer'; +import type Feature from 'ol/Feature'; +import type { Polygon } from 'ol/geom'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type OlMap from 'ol/Map'; +import type { Coordinate } from 'ol/coordinate'; const CORNER_TOLERANCE = 16; const EDGE_TOLERANCE = 12; /** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */ -function pointToSegmentDist(p, a, b) { +function pointToSegmentDist(p: number[], a: number[], b: number[]): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lenSq = dx * dx + dy * dy; @@ -21,34 +26,57 @@ function pointToSegmentDist(p, a, b) { return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); } +interface BoxResizeInteractionOptions { + feature: Feature; + onResize?: (feature: Feature) => void; +} + +interface HandleResult { + cursor: string; +} + +interface BBox { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + export default class BoxResizeInteraction extends PointerInteraction { - constructor(options) { + private feature_: Feature; + private onResize_: ((feature: Feature) => void) | null; + // corner mode + private mode_: 'corner' | 'edge' | null; + private anchorCoord_: Coordinate | null; + // edge mode + private edgeIndex_: number | null; + private bbox_: BBox | null; + + constructor(options: BoxResizeInteractionOptions) { super({ - handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt), - handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt), - handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt), + handleDownEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDown.call(this, evt), + handleDragEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDrag.call(this, evt), + handleUpEvent: () => BoxResizeInteraction.prototype._handleUp.call(this), }); this.feature_ = options.feature; this.onResize_ = options.onResize || null; // corner mode this.mode_ = null; // 'corner' | 'edge' - this.draggedIndex_ = null; this.anchorCoord_ = null; // edge mode this.edgeIndex_ = null; this.bbox_ = null; } - _handleDown(evt) { - const pixel = evt.pixel; - const coords = this.feature_.getGeometry().getCoordinates()[0]; + private _handleDown(evt: MapBrowserEvent): boolean { + const pixel = evt.pixel as unknown as number[]; + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 1. 모서리 감지 (우선) for (let i = 0; i < 4; i++) { const vp = evt.map.getPixelFromCoordinate(coords[i]); if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) { this.mode_ = 'corner'; - this.draggedIndex_ = i; this.anchorCoord_ = coords[(i + 2) % 4]; return true; } @@ -59,11 +87,11 @@ export default class BoxResizeInteraction extends PointerInteraction { const j = (i + 1) % 4; const p1 = evt.map.getPixelFromCoordinate(coords[i]); const p2 = evt.map.getPixelFromCoordinate(coords[j]); - if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { this.mode_ = 'edge'; this.edgeIndex_ = i; - const xs = coords.slice(0, 4).map(c => c[0]); - const ys = coords.slice(0, 4).map(c => c[1]); + const xs = coords.slice(0, 4).map((c: Coordinate) => c[0]); + const ys = coords.slice(0, 4).map((c: Coordinate) => c[1]); this.bbox_ = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), @@ -75,21 +103,21 @@ export default class BoxResizeInteraction extends PointerInteraction { return false; } - _handleDrag(evt) { + private _handleDrag(evt: MapBrowserEvent): void { const coord = evt.coordinate; if (this.mode_ === 'corner') { - const anchor = this.anchorCoord_; + const anchor = this.anchorCoord_!; const minX = Math.min(coord[0], anchor[0]); const maxX = Math.max(coord[0], anchor[0]); const minY = Math.min(coord[1], anchor[1]); const maxY = Math.max(coord[1], anchor[1]); - this.feature_.getGeometry().setCoordinates([[ + this.feature_.getGeometry()!.setCoordinates([[ [minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY], ]]); } else if (this.mode_ === 'edge') { - let { minX, maxX, minY, maxY } = this.bbox_; - // Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL) + let { minX, maxX, minY, maxY } = this.bbox_!; + // Edge 0: top(TL->TR), 1: right(TR->BR), 2: bottom(BR->BL), 3: left(BL->TL) switch (this.edgeIndex_) { case 0: maxY = coord[1]; break; case 1: maxX = coord[0]; break; @@ -98,16 +126,15 @@ export default class BoxResizeInteraction extends PointerInteraction { } const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX); const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY); - this.feature_.getGeometry().setCoordinates([[ + this.feature_.getGeometry()!.setCoordinates([[ [x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2], ]]); } } - _handleUp() { + private _handleUp(): boolean { if (this.mode_) { this.mode_ = null; - this.draggedIndex_ = null; this.anchorCoord_ = null; this.edgeIndex_ = null; this.bbox_ = null; @@ -119,10 +146,9 @@ export default class BoxResizeInteraction extends PointerInteraction { /** * 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인 - * @returns {{ cursor: string }} | null */ - isOverHandle(map, pixel) { - const coords = this.feature_.getGeometry().getCoordinates()[0]; + isOverHandle(map: OlMap, pixel: number[]): HandleResult | null { + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 모서리 감지 const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize']; @@ -139,7 +165,7 @@ export default class BoxResizeInteraction extends PointerInteraction { const j = (i + 1) % 4; const p1 = map.getPixelFromCoordinate(coords[i]); const p2 = map.getPixelFromCoordinate(coords[j]); - if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { return { cursor: edgeCursors[i] }; } } diff --git a/src/areaSearch/interactions/CircleResizeInteraction.js b/src/areaSearch/interactions/CircleResizeInteraction.ts similarity index 61% rename from src/areaSearch/interactions/CircleResizeInteraction.js rename to src/areaSearch/interactions/CircleResizeInteraction.ts index f6331ceb..de4f102f 100644 --- a/src/areaSearch/interactions/CircleResizeInteraction.js +++ b/src/areaSearch/interactions/CircleResizeInteraction.ts @@ -10,16 +10,35 @@ import PointerInteraction from 'ol/interaction/Pointer'; import { fromCircle } from 'ol/geom/Polygon'; import OlCircle from 'ol/geom/Circle'; +import type Feature from 'ol/Feature'; +import type { Polygon } from 'ol/geom'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type OlMap from 'ol/Map'; const PIXEL_TOLERANCE = 16; const MIN_RADIUS = 100; // 최소 반지름 (미터) +interface CircleResizeInteractionOptions { + feature: Feature; + center: [number, number]; // EPSG:3857 [x, y] + onResize?: (feature: Feature) => void; +} + +interface HandleResult { + cursor: string; +} + export default class CircleResizeInteraction extends PointerInteraction { - constructor(options) { + private feature_: Feature; + private center_: [number, number]; + private onResize_: ((feature: Feature) => void) | null; + private dragging_: boolean; + + constructor(options: CircleResizeInteractionOptions) { super({ - handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt), - handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt), - handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt), + handleDownEvent: (evt: MapBrowserEvent) => CircleResizeInteraction.prototype._handleDown.call(this, evt), + handleDragEvent: (evt: MapBrowserEvent) => CircleResizeInteraction.prototype._handleDrag.call(this, evt), + handleUpEvent: () => CircleResizeInteraction.prototype._handleUp.call(this), }); this.feature_ = options.feature; this.center_ = options.center; // EPSG:3857 [x, y] @@ -28,9 +47,9 @@ export default class CircleResizeInteraction extends PointerInteraction { } /** 중심~포인터 픽셀 거리와 표시 반지름 비교 */ - _isNearEdge(map, pixel) { + private _isNearEdge(map: OlMap, pixel: number[]): boolean { const centerPixel = map.getPixelFromCoordinate(this.center_); - const coords = this.feature_.getGeometry().getCoordinates()[0]; + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; const edgePixel = map.getPixelFromCoordinate(coords[0]); const radiusPixels = Math.hypot( edgePixel[0] - centerPixel[0], @@ -43,15 +62,15 @@ export default class CircleResizeInteraction extends PointerInteraction { return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE; } - _handleDown(evt) { - if (this._isNearEdge(evt.map, evt.pixel)) { + private _handleDown(evt: MapBrowserEvent): boolean { + if (this._isNearEdge(evt.map, evt.pixel as unknown as number[])) { this.dragging_ = true; return true; } return false; } - _handleDrag(evt) { + private _handleDrag(evt: MapBrowserEvent): void { if (!this.dragging_) return; const coord = evt.coordinate; const dx = coord[0] - this.center_[0]; @@ -63,7 +82,7 @@ export default class CircleResizeInteraction extends PointerInteraction { this.feature_.setGeometry(polyGeom); } - _handleUp() { + private _handleUp(): boolean { if (this.dragging_) { this.dragging_ = false; if (this.onResize_) this.onResize_(this.feature_); @@ -73,15 +92,14 @@ export default class CircleResizeInteraction extends PointerInteraction { } /** 외부에서 center 업데이트 (Translate 후) */ - setCenter(center) { + setCenter(center: [number, number]): void { this.center_ = center; } /** * 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인 - * @returns {{ cursor: string }} | null */ - isOverHandle(map, pixel) { + isOverHandle(map: OlMap, pixel: number[]): HandleResult | null { if (this._isNearEdge(map, pixel)) { return { cursor: 'nesw-resize' }; } diff --git a/src/areaSearch/services/areaSearchApi.js b/src/areaSearch/services/areaSearchApi.ts similarity index 73% rename from src/areaSearch/services/areaSearchApi.js rename to src/areaSearch/services/areaSearchApi.ts index 16dd25d1..1ea943d7 100644 --- a/src/areaSearch/services/areaSearchApi.js +++ b/src/areaSearch/services/areaSearchApi.ts @@ -6,14 +6,35 @@ */ import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi'; import { fetchWithAuth } from '../../api/fetchWithAuth'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { HitDetail } from '../types/areaSearch.types'; const API_ENDPOINT = '/api/v2/tracks/area-search'; +interface AreaSearchPolygon { + id: string; + name: string; + coordinates: number[][]; +} + +interface AreaSearchParams { + startTime: string; + endTime: string; + mode: string; + polygons: AreaSearchPolygon[]; +} + +interface AreaSearchResult { + tracks: ProcessedTrack[]; + hitDetails: Record; + summary: { totalVessels: number; processingTimeMs?: number } | null; +} + /** * 타임스탬프 기반 위치 보간 (이진 탐색) * track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat]을 계산 */ -function interpolatePositionAtTime(track, targetTime) { +function interpolatePositionAtTime(track: ProcessedTrack, targetTime: number | null): number[] | null { const { timestampsMs, geometry } = track; if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null; @@ -47,15 +68,8 @@ function interpolatePositionAtTime(track, targetTime) { /** * 구역 기반 항적 검색 - * - * @param {Object} params - * @param {string} params.startTime ISO 8601 시작 시간 - * @param {string} params.endTime ISO 8601 종료 시간 - * @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL' - * @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons - * @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>} */ -export async function fetchAreaSearch(params) { +export async function fetchAreaSearch(params: AreaSearchParams): Promise { const request = { startTime: params.startTime, endTime: params.endTime, @@ -76,20 +90,21 @@ export async function fetchAreaSearch(params) { const result = await response.json(); const rawTracks = Array.isArray(result.tracks) ? result.tracks : []; - const tracks = convertToProcessedTracks(rawTracks); + const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[]; // vesselId → track 빠른 조회용 const trackMap = new Map(tracks.map((t) => [t.vesselId, t])); // hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간 const rawHitDetails = result.hitDetails || {}; - const hitDetails = {}; + const hitDetails: Record = {}; for (const [vesselId, hits] of Object.entries(rawHitDetails)) { const track = trackMap.get(vesselId); - hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => { - const toMs = (ts) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit: any) => { + const toMs = (ts: string | number | null | undefined): number | null => { if (!ts) return null; - const num = typeof ts === 'number' ? ts : parseInt(ts, 10); + const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10); return num < 10000000000 ? num * 1000 : num; }; const entryMs = toMs(hit.entryTimestamp); diff --git a/src/areaSearch/services/stsApi.js b/src/areaSearch/services/stsApi.ts similarity index 63% rename from src/areaSearch/services/stsApi.js rename to src/areaSearch/services/stsApi.ts index b3f27905..ff8c80c4 100644 --- a/src/areaSearch/services/stsApi.js +++ b/src/areaSearch/services/stsApi.ts @@ -6,30 +6,45 @@ */ import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi'; import { fetchWithAuth } from '../../api/fetchWithAuth'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { StsContact } from '../types/sts.types'; +import type { StsSummary } from '../stores/stsStore'; const API_ENDPOINT = '/api/v2/tracks/vessel-contacts'; +interface StsSearchPolygon { + id: string; + name: string; + coordinates: number[][]; +} + +interface StsSearchParams { + startTime: string; + endTime: string; + polygon: StsSearchPolygon; + minContactDurationMinutes: number; + maxContactDistanceMeters: number; +} + +interface StsSearchResult { + contacts: StsContact[]; + tracks: ProcessedTrack[]; + summary: StsSummary | null; +} + /** * Unix 초/밀리초 → 밀리초 변환 */ -function toMs(ts) { +function toMs(ts: string | number | null | undefined): number | null { if (!ts) return null; - const num = typeof ts === 'number' ? ts : parseInt(ts, 10); + const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10); return num < 10000000000 ? num * 1000 : num; } /** * STS 접촉 검출 API 호출 - * - * @param {Object} params - * @param {string} params.startTime ISO 8601 - * @param {string} params.endTime ISO 8601 - * @param {{id: string, name: string, coordinates: number[][]}} params.polygon 단일 폴리곤 - * @param {number} params.minContactDurationMinutes 30~360 - * @param {number} params.maxContactDistanceMeters 50~5000 - * @returns {Promise<{contacts: Array, tracks: Array, summary: Object}>} */ -export async function fetchVesselContacts(params) { +export async function fetchVesselContacts(params: StsSearchParams): Promise { const request = { startTime: params.startTime, endTime: params.endTime, @@ -52,11 +67,13 @@ export async function fetchVesselContacts(params) { // tracks 변환 const rawTracks = Array.isArray(result.tracks) ? result.tracks : []; - const tracks = convertToProcessedTracks(rawTracks); + const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[]; // contacts: timestamp 초→밀리초 변환 - const rawContacts = Array.isArray(result.contacts) ? result.contacts : []; - const contacts = rawContacts.map((c) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawContacts = Array.isArray(result.contacts) ? result.contacts : [] as any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contacts: StsContact[] = rawContacts.map((c: any) => ({ ...c, contactStartTimestamp: toMs(c.contactStartTimestamp), contactEndTimestamp: toMs(c.contactEndTimestamp), diff --git a/src/areaSearch/stores/areaSearchAnimationStore.js b/src/areaSearch/stores/areaSearchAnimationStore.ts similarity index 80% rename from src/areaSearch/stores/areaSearchAnimationStore.js rename to src/areaSearch/stores/areaSearchAnimationStore.ts index ed542c1a..fe2251ba 100644 --- a/src/areaSearch/stores/areaSearchAnimationStore.js +++ b/src/areaSearch/stores/areaSearchAnimationStore.ts @@ -9,11 +9,28 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; -let animationFrameId = null; -let lastFrameTime = null; +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; -export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => { - const animate = () => { +interface AreaSearchAnimationState { + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + + play: () => void; + pause: () => void; + stop: () => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + setTimeRange: (start: number, end: number) => void; + getProgress: () => number; + reset: () => void; +} + +export const useAreaSearchAnimationStore = create()(subscribeWithSelector((set, get) => { + const animate = (): void => { const state = get(); if (!state.isPlaying) return; diff --git a/src/areaSearch/stores/areaSearchStore.js b/src/areaSearch/stores/areaSearchStore.ts similarity index 71% rename from src/areaSearch/stores/areaSearchStore.js rename to src/areaSearch/stores/areaSearchStore.ts index 529205f9..ab168b5e 100644 --- a/src/areaSearch/stores/areaSearchStore.js +++ b/src/areaSearch/stores/areaSearchStore.ts @@ -9,12 +9,50 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types'; +import type { + AnalysisTab, + SearchMode, + ZoneDrawType, + Zone, + CircleMeta, + AreaSearchTooltip, + HitDetail, + VesselPosition, +} from '../types/areaSearch.types'; import { showLiveShips } from '../../utils/liveControl'; +// ========== ProcessedTrack 인터페이스 (trackQueryApi에서 반환하는 형태) ========== + +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId?: string; + geometry: number[][]; + timestampsMs: number[]; + speeds: number[]; + stats: { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; + }; +} + +// ========== Summary 인터페이스 ========== + +export interface AreaSearchSummary { + totalVessels: number; + processingTimeMs?: number; +} + /** * 두 지점 사이 선박 위치를 시간 기반 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -25,7 +63,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 두 지점 간 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: number[], p2: number[]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -39,9 +77,81 @@ let zoneIdCounter = 0; // 커서 기반 선형 탐색용 (vesselId → lastIndex) // 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback -const positionCursors = new Map(); +const positionCursors = new Map(); -export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ +interface AreaSearchState { + // 탭 상태 + activeTab: AnalysisTab; + + // 검색 조건 + zones: Zone[]; + searchMode: SearchMode; + + // 검색 결과 + tracks: ProcessedTrack[]; + hitDetails: Record; + summary: AreaSearchSummary | null; + + // UI 상태 + isLoading: boolean; + queryCompleted: boolean; + disabledVesselIds: Set; + highlightedVesselId: string | null; + showZones: boolean; + activeDrawType: ZoneDrawType | null; + areaSearchTooltip: AreaSearchTooltip | null; + selectedZoneId: string | null; + _lastZoneAddedAt: number; + + // 필터 상태 + showPaths: boolean; + showTrail: boolean; + shipKindCodeFilter: Set; + + // 구역 관리 + addZone: (zone: Omit & { circleMeta?: CircleMeta | null }) => void; + removeZone: (zoneId: string) => void; + clearZones: () => void; + reorderZones: (fromIndex: number, toIndex: number) => void; + + // 탭 전환 + setActiveTab: (tab: AnalysisTab) => void; + + // 검색 조건 + setSearchMode: (mode: SearchMode) => void; + setActiveDrawType: (type: ZoneDrawType | null) => void; + setShowZones: (show: boolean) => void; + + // 구역 편집 + selectZone: (zoneId: string) => void; + deselectZone: () => void; + updateZoneGeometry: (zoneId: string, coordinates4326: number[][], circleMeta?: CircleMeta) => void; + confirmAndClearResults: () => boolean; + + // 검색 결과 + setTracks: (tracks: ProcessedTrack[]) => void; + setHitDetails: (hitDetails: Record) => void; + setSummary: (summary: AreaSearchSummary | null) => void; + setLoading: (loading: boolean) => void; + + // 선박 토글 + toggleVesselEnabled: (vesselId: string) => void; + setHighlightedVesselId: (vesselId: string | null) => void; + setAreaSearchTooltip: (tooltip: AreaSearchTooltip | null) => void; + + // 필터 토글 + setShowPaths: (show: boolean) => void; + setShowTrail: (show: boolean) => void; + toggleShipKindCode: (code: string) => void; + getEnabledTracks: () => ProcessedTrack[]; + getCurrentPositions: (currentTime: number) => VesselPosition[]; + + // 초기화 + clearResults: () => void; + reset: () => void; +} + +export const useAreaSearchStore = create()(subscribeWithSelector((set, get) => ({ // 탭 상태 activeTab: ANALYSIS_TABS.AREA, @@ -57,7 +167,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ // UI 상태 isLoading: false, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, showZones: true, activeDrawType: null, @@ -81,7 +191,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ let colorIndex = 0; while (usedColors.has(colorIndex)) colorIndex++; - const newZone = { + const newZone: Zone = { ...zone, id: `zone-${++zoneIdCounter}`, name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`, @@ -94,7 +204,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ removeZone: (zoneId) => { const { zones, selectedZoneId } = get(); const filtered = zones.filter(z => z.id !== zoneId); - const updates = { zones: filtered }; + const updates: Partial = { zones: filtered }; if (selectedZoneId === zoneId) updates.selectedZoneId = null; set(updates); }, @@ -130,7 +240,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ const { zones } = get(); const updated = zones.map(z => { if (z.id !== zoneId) return z; - const patch = { ...z, coordinates: coordinates4326 }; + const patch: Zone = { ...z, coordinates: coordinates4326 }; if (circleMeta !== undefined) patch.circleMeta = circleMeta; return patch; }); @@ -140,7 +250,6 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ /** * 조회 조건 변경 시 결과 초기화 확인 * 결과가 없으면 true 반환, 있으면 confirm 후 초기화 - * @returns {boolean} 진행 허용 여부 */ confirmAndClearResults: () => { const { queryCompleted } = get(); @@ -209,7 +318,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: (currentTime) => { const { tracks, disabledVesselIds } = get(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach(track => { if (disabledVesselIds.has(track.vesselId)) return; @@ -247,7 +356,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let position, heading, speed; + let position: number[], heading: number, speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -281,7 +390,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ hitDetails: {}, summary: null, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, areaSearchTooltip: null, showPaths: true, @@ -301,7 +410,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ summary: null, isLoading: false, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, showZones: true, activeDrawType: null, diff --git a/src/areaSearch/stores/stsStore.js b/src/areaSearch/stores/stsStore.ts similarity index 74% rename from src/areaSearch/stores/stsStore.js rename to src/areaSearch/stores/stsStore.ts index 1cad93ae..8e85a15c 100644 --- a/src/areaSearch/stores/stsStore.js +++ b/src/areaSearch/stores/stsStore.ts @@ -9,8 +9,11 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { STS_DEFAULTS } from '../types/sts.types'; +import type { StsContact, StsGroupedContact } from '../types/sts.types'; +import type { ProcessedTrack } from './areaSearchStore'; +import type { VesselPosition } from '../types/areaSearch.types'; -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -18,7 +21,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio]; } -function calculateHeading(p1, p2) { +function calculateHeading(p1: number[], p2: number[]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -28,11 +31,18 @@ function calculateHeading(p1, p2) { return angle; } +export interface StsSummary { + totalContactPairs: number; + totalVesselsInvolved: number; + totalVesselsInPolygon: number; + processingTimeMs?: number; +} + /** * contacts 배열을 선박 쌍 기준으로 그룹핑 */ -function groupContactsByPair(contacts) { - const groupMap = new Map(); +function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] { + const groupMap = new Map(); contacts.forEach((contact) => { const v1Id = contact.vessel1.vesselId; @@ -45,13 +55,19 @@ function groupContactsByPair(contacts) { vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2, vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1, contacts: [], + totalDurationMinutes: 0, + avgDistanceMeters: 0, + minDistanceMeters: 0, + maxDistanceMeters: 0, + totalContactPointCount: 0, + indicators: {}, }); } - groupMap.get(pairKey).contacts.push(contact); + groupMap.get(pairKey)!.contacts.push(contact); }); return [...groupMap.values()].map((group) => { - group.contacts.sort((a, b) => a.contactStartTimestamp - b.contactStartTimestamp); + group.contacts.sort((a, b) => (a.contactStartTimestamp ?? 0) - (b.contactStartTimestamp ?? 0)); // 합산 통계 group.totalDurationMinutes = group.contacts.reduce( @@ -89,9 +105,55 @@ function groupContactsByPair(contacts) { }); } -const positionCursors = new Map(); +const positionCursors = new Map(); -export const useStsStore = create(subscribeWithSelector((set, get) => ({ +interface StsState { + // STS 파라미터 + minContactDurationMinutes: number; + maxContactDistanceMeters: number; + + // 결과 + contacts: StsContact[]; + groupedContacts: StsGroupedContact[]; + tracks: ProcessedTrack[]; + summary: StsSummary | null; + + // UI + isLoading: boolean; + queryCompleted: boolean; + highlightedGroupIndex: number | null; + disabledGroupIndices: Set; + expandedGroupIndex: number | null; + + // 필터 상태 + showPaths: boolean; + showTrail: boolean; + + // 파라미터 설정 + setMinContactDuration: (val: number) => void; + setMaxContactDistance: (val: number) => void; + + // 결과 설정 + setResults: (result: { contacts: StsContact[]; tracks: ProcessedTrack[]; summary: StsSummary | null }) => void; + setLoading: (loading: boolean) => void; + + // 그룹 UI + setHighlightedGroupIndex: (idx: number | null) => void; + setExpandedGroupIndex: (idx: number | null) => void; + toggleGroupEnabled: (idx: number) => void; + + // 필터 + setShowPaths: (show: boolean) => void; + setShowTrail: (show: boolean) => void; + getDisabledVesselIds: () => Set; + getCurrentPositions: (currentTime: number) => VesselPosition[]; + + // 초기화 + clearResults: () => void; + reset: () => void; +} + +export const useStsStore = create()(subscribeWithSelector((set, get) => ({ // STS 파라미터 minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION, maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE, @@ -106,7 +168,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ isLoading: false, queryCompleted: false, highlightedGroupIndex: null, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), expandedGroupIndex: null, // 필터 상태 @@ -129,7 +191,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ tracks, summary, queryCompleted: true, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, }); @@ -160,7 +222,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ getDisabledVesselIds: () => { const { groupedContacts, disabledGroupIndices } = get(); - const ids = new Set(); + const ids = new Set(); disabledGroupIndices.forEach((idx) => { const g = groupedContacts[idx]; if (g) { @@ -174,7 +236,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ getCurrentPositions: (currentTime) => { const { tracks } = get(); const disabledVesselIds = get().getDisabledVesselIds(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach((track) => { if (disabledVesselIds.has(track.vesselId)) return; @@ -209,7 +271,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let position, heading, speed; + let position: number[], heading: number, speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -244,7 +306,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ tracks: [], summary: null, queryCompleted: false, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, showPaths: true, @@ -263,7 +325,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ summary: null, isLoading: false, queryCompleted: false, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, showPaths: true, diff --git a/src/areaSearch/types/areaSearch.types.js b/src/areaSearch/types/areaSearch.types.ts similarity index 56% rename from src/areaSearch/types/areaSearch.types.js rename to src/areaSearch/types/areaSearch.types.ts index ae6af06f..206f125b 100644 --- a/src/areaSearch/types/areaSearch.types.js +++ b/src/areaSearch/types/areaSearch.types.ts @@ -2,12 +2,17 @@ * 항적분석(구역 검색) 상수 및 타입 정의 */ +import type Feature from 'ol/Feature'; +import type { Geometry } from 'ol/geom'; + // ========== 분석 탭 ========== export const ANALYSIS_TABS = { AREA: 'area', STS: 'sts', -}; +} as const; + +export type AnalysisTab = typeof ANALYSIS_TABS[keyof typeof ANALYSIS_TABS]; // ========== 검색 모드 ========== @@ -15,9 +20,11 @@ export const SEARCH_MODES = { ANY: 'ANY', ALL: 'ALL', SEQUENTIAL: 'SEQUENTIAL', -}; +} as const; -export const SEARCH_MODE_LABELS = { +export type SearchMode = typeof SEARCH_MODES[keyof typeof SEARCH_MODES]; + +export const SEARCH_MODE_LABELS: Record = { [SEARCH_MODES.ANY]: 'ANY (합집합)', [SEARCH_MODES.ALL]: 'ALL (교집합)', [SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)', @@ -31,11 +38,19 @@ export const ZONE_DRAW_TYPES = { POLYGON: 'Polygon', BOX: 'Box', CIRCLE: 'Circle', -}; +} as const; + +export type ZoneDrawType = typeof ZONE_DRAW_TYPES[keyof typeof ZONE_DRAW_TYPES]; export const ZONE_NAMES = ['A', 'B', 'C']; -export const ZONE_COLORS = [ +export interface ZoneColor { + fill: [number, number, number, number]; + stroke: [number, number, number, number]; + label: string; +} + +export const ZONE_COLORS: ZoneColor[] = [ { fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' }, { fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' }, { fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' }, @@ -49,7 +64,7 @@ export const QUERY_MAX_DAYS = 7; * 조회 가능 기간 계산 (D-7 ~ D-1) * 인메모리 캐시 기반, 오늘 데이터 없음 */ -export function getQueryDateRange() { +export function getQueryDateRange(): { startDate: Date; endDate: Date } { const now = new Date(); const endDate = new Date(now); @@ -80,7 +95,7 @@ import { SIGNAL_KIND_CODE_BUOY, } from '../../types/constants'; -export const ALL_SHIP_KIND_CODES = [ +export const ALL_SHIP_KIND_CODES: string[] = [ SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_KCGV, SIGNAL_KIND_CODE_PASSENGER, @@ -98,4 +113,58 @@ export const AREA_SEARCH_LAYER_IDS = { TRIPS_TRAIL: 'area-search-trips-trail', VIRTUAL_SHIP: 'area-search-virtual-ship-layer', VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer', -}; +} as const; + +// ========== Zone 인터페이스 ========== + +export interface CircleMeta { + center: [number, number]; + radius: number; +} + +export interface Zone { + id: string; + name: string; + type: ZoneDrawType; + source: string; + coordinates: number[][]; + colorIndex: number; + olFeature?: Feature; + circleMeta: CircleMeta | null; +} + +// ========== 툴팁 ========== + +export interface AreaSearchTooltip { + vesselId: string; + x: number; + y: number; +} + +// ========== HitDetail ========== + +export interface HitDetail { + polygonId: string; + polygonName?: string; + visitIndex: number; + entryTimestamp: number | null; + exitTimestamp: number | null; + entryPosition: number[] | null; + exitPosition: number[] | null; +} + +// ========== VesselPosition ========== + +export interface VesselPosition { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + lon: number; + lat: number; + heading: number; + speed: number; + timestamp: number; +} diff --git a/src/areaSearch/types/sts.types.js b/src/areaSearch/types/sts.types.ts similarity index 61% rename from src/areaSearch/types/sts.types.js rename to src/areaSearch/types/sts.types.ts index 103fa0dc..b542ac52 100644 --- a/src/areaSearch/types/sts.types.js +++ b/src/areaSearch/types/sts.types.ts @@ -8,14 +8,14 @@ import { getShipKindName } from '../../tracking/types/trackQuery.types'; export const STS_DEFAULTS = { MIN_CONTACT_DURATION: 60, MAX_CONTACT_DISTANCE: 500, -}; +} as const; export const STS_LIMITS = { DURATION_MIN: 30, DURATION_MAX: 360, DISTANCE_MIN: 50, DISTANCE_MAX: 5000, -}; +} as const; // ========== 레이어 ID ========== @@ -26,22 +26,71 @@ export const STS_LAYER_IDS = { TRIPS_TRAIL: 'sts-trips-trail-layer', VIRTUAL_SHIP: 'sts-virtual-ship-layer', VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer', -}; +} as const; // ========== Indicator 라벨 ========== -export const INDICATOR_LABELS = { +export const INDICATOR_LABELS: Record = { lowSpeedContact: '저속', differentVesselTypes: '이종', differentNationalities: '외국적', nightTimeContact: '야간', }; +// ========== STS Contact / Vessel 인터페이스 ========== + +export interface StsVessel { + vesselId: string; + vesselName?: string; + shipKindCode?: string; + nationalCode?: string; + estimatedAvgSpeedKnots?: number | null; + insidePolygonDurationMinutes?: number; + insidePolygonStartTs?: number | null; + insidePolygonEndTs?: number | null; +} + +export interface StsIndicators { + lowSpeedContact?: boolean; + differentVesselTypes?: boolean; + differentNationalities?: boolean; + nightTimeContact?: boolean; + [key: string]: boolean | undefined; +} + +export interface StsContact { + vessel1: StsVessel; + vessel2: StsVessel; + contactStartTimestamp: number | null; + contactEndTimestamp: number | null; + contactDurationMinutes?: number; + contactPointCount?: number; + avgDistanceMeters?: number; + minDistanceMeters: number; + maxDistanceMeters: number; + contactCenterPoint?: number[]; + indicators?: StsIndicators; +} + +export interface StsGroupedContact { + pairKey: string; + vessel1: StsVessel; + vessel2: StsVessel; + contacts: StsContact[]; + totalDurationMinutes: number; + avgDistanceMeters: number; + minDistanceMeters: number; + maxDistanceMeters: number; + contactCenterPoint?: number[]; + totalContactPointCount: number; + indicators: StsIndicators; +} + /** * indicator 뱃지에 맥락 정보를 포함한 텍스트 생성 * 예: "저속 1.2/0.8kn", "이종 어선↔화물선" */ -export function getIndicatorDetail(key, contact) { +export function getIndicatorDetail(key: string, contact: StsContact): string { const { vessel1, vessel2 } = contact; switch (key) { @@ -73,7 +122,7 @@ export function getIndicatorDetail(key, contact) { /** * 거리 포맷 (미터 → 읽기 좋은 형태) */ -export function formatDistance(meters) { +export function formatDistance(meters: number | null | undefined): string { if (meters == null) return '-'; if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`; return `${Math.round(meters)}m`; @@ -82,7 +131,7 @@ export function formatDistance(meters) { /** * 시간 포맷 (분 → 시분) */ -export function formatDuration(minutes) { +export function formatDuration(minutes: number | null | undefined): string { if (minutes == null) return '-'; if (minutes < 60) return `${minutes}분`; const h = Math.floor(minutes / 60); @@ -96,7 +145,7 @@ export function formatDuration(minutes) { * contact의 indicators 활성 개수에 따라 위험도 색상 반환 * 3+: 빨강, 2: 주황, 1: 노랑, 0: 회색 */ -export function getContactRiskColor(indicators) { +export function getContactRiskColor(indicators: StsIndicators | null | undefined): [number, number, number, number] { if (!indicators) return [150, 150, 150, 200]; const count = Object.values(indicators).filter(Boolean).length; if (count >= 3) return [231, 76, 60, 220]; diff --git a/src/areaSearch/utils/areaSearchLayerRegistry.js b/src/areaSearch/utils/areaSearchLayerRegistry.ts similarity index 54% rename from src/areaSearch/utils/areaSearchLayerRegistry.js rename to src/areaSearch/utils/areaSearchLayerRegistry.ts index 1ac952e4..1ee63fe3 100644 --- a/src/areaSearch/utils/areaSearchLayerRegistry.js +++ b/src/areaSearch/utils/areaSearchLayerRegistry.ts @@ -1,19 +1,21 @@ /** * 항적분석 레이어 전역 레지스트리 - * 참조: src/replay/utils/replayLayerRegistry.js + * 참조: src/replay/utils/replayLayerRegistry.ts * * useAreaSearchLayer 훅이 레이어를 등록하면 * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerAreaSearchLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerAreaSearchLayers(layers: Layer[]): void { window.__areaSearchLayers__ = layers; } -export function getAreaSearchLayers() { +export function getAreaSearchLayers(): Layer[] { return window.__areaSearchLayers__ || []; } -export function unregisterAreaSearchLayers() { +export function unregisterAreaSearchLayers(): void { window.__areaSearchLayers__ = []; } diff --git a/src/areaSearch/utils/csvExport.js b/src/areaSearch/utils/csvExport.ts similarity index 81% rename from src/areaSearch/utils/csvExport.js rename to src/areaSearch/utils/csvExport.ts index 8605263e..ccfddd68 100644 --- a/src/areaSearch/utils/csvExport.js +++ b/src/areaSearch/utils/csvExport.ts @@ -4,15 +4,17 @@ */ import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { HitDetail, Zone } from '../types/areaSearch.types'; -function formatTimestamp(ms) { +function formatTimestamp(ms: number | null): string { if (!ms) return ''; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -function formatPosition(pos) { +function formatPosition(pos: number[] | null): string { if (!pos || pos.length < 2) return ''; const lon = pos[0]; const lat = pos[1]; @@ -21,7 +23,7 @@ function formatPosition(pos) { return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`; } -function escapeCsvField(value) { +function escapeCsvField(value: string | number): string { const str = String(value ?? ''); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; @@ -32,16 +34,20 @@ function escapeCsvField(value) { /** * 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원) * - * @param {Array} tracks ProcessedTrack 배열 - * @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] } - * @param {Array} zones 구역 배열 + * @param tracks ProcessedTrack 배열 + * @param hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] } + * @param zones 구역 배열 */ -export function exportSearchResultToCSV(tracks, hitDetails, zones) { +export function exportSearchResultToCSV( + tracks: ProcessedTrack[], + hitDetails: Record, + zones: Zone[], +): void { // 구역별 최대 방문 횟수 계산 - const maxVisitsPerZone = {}; + const maxVisitsPerZone: Record = {}; zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; }); Object.values(hitDetails).forEach((hits) => { - const countByZone = {}; + const countByZone: Record = {}; (Array.isArray(hits) ? hits : []).forEach((h) => { countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1; }); @@ -56,7 +62,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { '포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)', ]; - const zoneHeaders = []; + const zoneHeaders: string[] = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; if (max === 1) { @@ -78,7 +84,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { // 데이터 행 생성 const rows = tracks.map((track) => { - const baseRow = [ + const baseRow: (string | number)[] = [ getSignalSourceName(track.sigSrcCd), track.targetId || '', track.shipName || '', @@ -91,7 +97,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { ]; const hits = hitDetails[track.vesselId] || []; - const zoneData = []; + const zoneData: string[] = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; const zoneHits = hits @@ -129,7 +135,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { const url = URL.createObjectURL(blob); const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`; const link = document.createElement('a'); diff --git a/src/areaSearch/utils/stsLayerRegistry.js b/src/areaSearch/utils/stsLayerRegistry.ts similarity index 53% rename from src/areaSearch/utils/stsLayerRegistry.js rename to src/areaSearch/utils/stsLayerRegistry.ts index 60fce214..812cbf2c 100644 --- a/src/areaSearch/utils/stsLayerRegistry.js +++ b/src/areaSearch/utils/stsLayerRegistry.ts @@ -1,19 +1,21 @@ /** * STS 분석 레이어 전역 레지스트리 - * 참조: src/areaSearch/utils/areaSearchLayerRegistry.js + * 참조: src/areaSearch/utils/areaSearchLayerRegistry.ts * * useStsLayer 훅이 레이어를 등록하면 * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerStsLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerStsLayers(layers: Layer[]): void { window.__stsLayers__ = layers; } -export function getStsLayers() { +export function getStsLayers(): Layer[] { return window.__stsLayers__ || []; } -export function unregisterStsLayers() { +export function unregisterStsLayers(): void { window.__stsLayers__ = []; } diff --git a/src/areaSearch/utils/zoneLayerRefs.js b/src/areaSearch/utils/zoneLayerRefs.js deleted file mode 100644 index 81e9a4dc..00000000 --- a/src/areaSearch/utils/zoneLayerRefs.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 구역 VectorSource/VectorLayer 모듈 스코프 참조 - * useZoneDraw와 useZoneEdit 간 공유 - */ - -let _source = null; -let _layer = null; - -export function setZoneSource(source) { _source = source; } -export function getZoneSource() { return _source; } -export function setZoneLayer(layer) { _layer = layer; } -export function getZoneLayer() { return _layer; } diff --git a/src/areaSearch/utils/zoneLayerRefs.ts b/src/areaSearch/utils/zoneLayerRefs.ts new file mode 100644 index 00000000..e468c08a --- /dev/null +++ b/src/areaSearch/utils/zoneLayerRefs.ts @@ -0,0 +1,17 @@ +/** + * 구역 VectorSource/VectorLayer 모듈 스코프 참조 + * useZoneDraw와 useZoneEdit 간 공유 + */ + +import type VectorSource from 'ol/source/Vector'; +import type VectorLayer from 'ol/layer/Vector'; +import type Feature from 'ol/Feature'; +import type { Geometry } from 'ol/geom'; + +let _source: VectorSource | null = null; +let _layer: VectorLayer> | null = null; + +export function setZoneSource(source: VectorSource | null): void { _source = source; } +export function getZoneSource(): VectorSource | null { return _source; } +export function setZoneLayer(layer: VectorLayer> | null): void { _layer = layer; } +export function getZoneLayer(): VectorLayer> | null { return _layer; } diff --git a/src/assets/data/shiptype.js b/src/assets/data/shiptype.ts similarity index 99% rename from src/assets/data/shiptype.js rename to src/assets/data/shiptype.ts index adcacd13..1d1b77f0 100644 --- a/src/assets/data/shiptype.js +++ b/src/assets/data/shiptype.ts @@ -1,4 +1,4 @@ -export const shipTypeMap = new Map(); +export const shipTypeMap: Map = new Map(); shipTypeMap.set('0', 'Not available'); shipTypeMap.set('1', 'Reserved for future use'); diff --git a/src/common/stompClient.js b/src/common/stompClient.ts similarity index 74% rename from src/common/stompClient.js rename to src/common/stompClient.ts index 03cc2fa4..43a95251 100644 --- a/src/common/stompClient.js +++ b/src/common/stompClient.ts @@ -3,9 +3,54 @@ * 참조: mda-react-front/src/common/stompClient.ts * 참조: mda-react-front/src/map/MapUpdater.tsx */ -import { Client } from '@stomp/stompjs'; +import { Client, IFrame, StompSubscription } from '@stomp/stompjs'; import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants'; +/** 선박 데이터 객체 (stompClient에서 파싱) */ +export interface ShipObject { + featureId: string; + targetId: string; + originalTargetId: string; + signalSourceCode: string; + shipName: string; + shipType: string; + longitude: number; + latitude: number; + sog: number; + cog: number; + receivedTime: string; + signalKindCode: string; + lost: boolean; + integrate: boolean; + isPriority: boolean; + hazardousCategory: string; + nationalCode: string; + imo: string; + draught: string; + dimA: string; + dimB: string; + dimC: string; + dimD: string; + ais: string | undefined; + vpass: string | undefined; + enav: string | undefined; + vtsAis: string | undefined; + dMfHf: string | undefined; + vtsRadar: string | undefined; + _raw: string[]; +} + +/** STOMP 연결 콜백 */ +interface StompCallbacks { + onConnect?: (frame: IFrame) => void; + onDisconnect?: () => void; + onError?: (frame: IFrame) => void; +} + +/** 선박 카운트 메시지 (JSON) */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ShipCountData = any; + /** * STOMP 클라이언트 인스턴스 * 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect) @@ -17,7 +62,7 @@ export const signalStompClient = new Client({ brokerURL, reconnectDelay: 10000, connectionTimeout: 5000, - debug: (str) => { + debug: (str: string) => { // STOMP 디버그 로그 (연결 관련 메시지만 출력) if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) { console.log('[STOMP Debug]', str); @@ -31,7 +76,7 @@ export const signalStompClient = new Client({ * @param {string} msgString - 파이프 구분 문자열 * @returns {Array} 파싱된 배열 */ -export function parsePipeMessage(msgString) { +export function parsePipeMessage(msgString: string): string[] { return msgString.split('|'); } @@ -40,9 +85,9 @@ export function parsePipeMessage(msgString) { * 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget() * * @param {Array} row - 파싱된 메시지 배열 (38개 요소) - * @returns {Object} 선박 데이터 객체 + * @returns {ShipObject} 선박 데이터 객체 */ -export function rowToShipObject(row) { +export function rowToShipObject(row: string[]): ShipObject { const idx = SHIP_MSG_INDEX; const targetId = row[idx.TARGET_ID] || ''; @@ -122,10 +167,10 @@ export function rowToShipObject(row) { * @param {Function} callbacks.onDisconnect - 연결 해제 시 * @param {Function} callbacks.onError - 에러 발생 시 */ -export function connectStomp(callbacks = {}) { +export function connectStomp(callbacks: StompCallbacks = {}): void { const { onConnect, onDisconnect, onError } = callbacks; - signalStompClient.onConnect = (frame) => { + signalStompClient.onConnect = (frame: IFrame) => { console.log('[STOMP] Connected'); onConnect?.(frame); }; @@ -135,7 +180,7 @@ export function connectStomp(callbacks = {}) { onDisconnect?.(); }; - signalStompClient.onStompError = (frame) => { + signalStompClient.onStompError = (frame: IFrame) => { console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error'); onError?.(frame); }; @@ -146,7 +191,7 @@ export function connectStomp(callbacks = {}) { /** * STOMP 연결 해제 */ -export function disconnectStomp() { +export function disconnectStomp(): void { if (signalStompClient.connected) { signalStompClient.deactivate(); } @@ -157,9 +202,9 @@ export function disconnectStomp() { * - 개발: /topic/ship (실시간) * - 프로덕션: /topic/ship-throttled-60s (위성망 대응) * @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열) - * @returns {Object} 구독 객체 (unsubscribe 호출용) + * @returns {StompSubscription} 구독 객체 (unsubscribe 호출용) */ -export function subscribeShips(onMessage) { +export function subscribeShips(onMessage: (ships: ShipObject[]) => void): StompSubscription { // 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀) const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); @@ -172,9 +217,9 @@ export function subscribeShips(onMessage) { return signalStompClient.subscribe(topic, (message) => { try { const body = message.body; - const lines = body.split('\n').filter(line => line.trim()); + const lines = body.split('\n').filter((line: string) => line.trim()); - const ships = lines.map(line => { + const ships = lines.map((line: string) => { const row = parsePipeMessage(line); return rowToShipObject(row); }); @@ -190,9 +235,9 @@ export function subscribeShips(onMessage) { * 선박 토픽 구독 (Raw 문자열 반환, Worker용) * - Web Worker에서 파싱을 수행할 때 사용 * @param {Function} onMessage - 메시지 수신 콜백 (파이프 구분 문자열 배열) - * @returns {Object} 구독 객체 (unsubscribe 호출용) + * @returns {StompSubscription} 구독 객체 (unsubscribe 호출용) */ -export function subscribeShipsRaw(onMessage) { +export function subscribeShipsRaw(onMessage: (lines: string[]) => void): StompSubscription { const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); const topic = throttleSeconds > 0 @@ -205,7 +250,7 @@ export function subscribeShipsRaw(onMessage) { try { const body = message.body; // 파싱 없이 줄 단위로 분리만 해서 전달 - const lines = body.split('\n').filter(line => line.trim()); + const lines = body.split('\n').filter((line: string) => line.trim()); onMessage(lines); } catch (error) { console.error('[STOMP] Ship message parse error:', error); @@ -216,9 +261,9 @@ export function subscribeShipsRaw(onMessage) { /** * 선박 삭제 토픽 구독 * @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId) - * @returns {Object} 구독 객체 + * @returns {StompSubscription} 구독 객체 */ -export function subscribeShipDelete(onDelete) { +export function subscribeShipDelete(onDelete: (featureId: string) => void): StompSubscription { console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`); return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => { @@ -239,9 +284,9 @@ export function subscribeShipDelete(onDelete) { /** * 선박 카운트 토픽 구독 * @param {Function} onCount - 카운트 메시지 수신 콜백 - * @returns {Object} 구독 객체 + * @returns {StompSubscription} 구독 객체 */ -export function subscribeShipCount(onCount) { +export function subscribeShipCount(onCount: (counts: ShipCountData) => void): StompSubscription { return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => { try { const counts = JSON.parse(message.body); diff --git a/src/components/auth/SessionGuard.jsx b/src/components/auth/SessionGuard.tsx similarity index 83% rename from src/components/auth/SessionGuard.jsx rename to src/components/auth/SessionGuard.tsx index 40c83d30..904659d5 100644 --- a/src/components/auth/SessionGuard.jsx +++ b/src/components/auth/SessionGuard.tsx @@ -1,10 +1,14 @@ -import { useEffect } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { useAuthStore } from '../../stores/authStore'; import './SessionGuard.scss'; const SKIP_AUTH = import.meta.env.VITE_DEV_SKIP_AUTH === 'true'; -export default function SessionGuard({ children }) { +interface SessionGuardProps { + children: ReactNode; +} + +export default function SessionGuard({ children }: SessionGuardProps) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isChecking = useAuthStore((s) => s.isChecking); diff --git a/src/components/common/AlertModal.jsx b/src/components/common/AlertModal.tsx similarity index 67% rename from src/components/common/AlertModal.jsx rename to src/components/common/AlertModal.tsx index ec7e8b09..0e3949c3 100644 --- a/src/components/common/AlertModal.jsx +++ b/src/components/common/AlertModal.tsx @@ -2,19 +2,24 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; import './AlertModal.scss'; -let showAlertFn = null; +interface AlertState { + message: string; + errorCode?: string; +} -export function showAlert(message, errorCode) { +let showAlertFn: ((message: string, errorCode?: string) => void) | null = null; + +export function showAlert(message: string, errorCode?: string): void { if (showAlertFn) { showAlertFn(message, errorCode); } } export function AlertModalContainer() { - const [alert, setAlert] = useState(null); + const [alert, setAlert] = useState(null); useState(() => { - showAlertFn = (message, errorCode) => { + showAlertFn = (message: string, errorCode?: string) => { setAlert({ message, errorCode }); }; @@ -31,7 +36,7 @@ export function AlertModalContainer() { return createPortal(
      -
      e.stopPropagation()}> +
      ) => e.stopPropagation()}>
      {alert.message}
      {alert.errorCode && (
      오류 코드: {alert.errorCode}
      diff --git a/src/components/common/LoadingOverlay.jsx b/src/components/common/LoadingOverlay.tsx similarity index 87% rename from src/components/common/LoadingOverlay.jsx rename to src/components/common/LoadingOverlay.tsx index 6939ce74..4ca5b14c 100644 --- a/src/components/common/LoadingOverlay.jsx +++ b/src/components/common/LoadingOverlay.tsx @@ -7,7 +7,11 @@ import { createPortal } from 'react-dom'; import './LoadingOverlay.scss'; -export default function LoadingOverlay({ message = '조회중...' }) { +interface LoadingOverlayProps { + message?: string; +} + +export default function LoadingOverlay({ message = '조회중...' }: LoadingOverlayProps) { return createPortal(
      diff --git a/src/components/common/Toast.jsx b/src/components/common/Toast.tsx similarity index 72% rename from src/components/common/Toast.jsx rename to src/components/common/Toast.tsx index 61bb66c4..72a69c2c 100644 --- a/src/components/common/Toast.jsx +++ b/src/components/common/Toast.tsx @@ -6,10 +6,16 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import './Toast.scss'; -// 토스트 메시지 표시 함수 (외부에서 호출용) -let showToastFn = null; +interface ToastData { + id: number; + message: string; + duration: number; +} -export function showToast(message, duration = 3000) { +// 토스트 메시지 표시 함수 (외부에서 호출용) +let showToastFn: ((message: string, duration: number) => void) | null = null; + +export function showToast(message: string, duration: number = 3000): void { if (showToastFn) { showToastFn(message, duration); } @@ -20,10 +26,10 @@ export function showToast(message, duration = 3000) { * App 최상위에 한 번만 마운트 */ export function ToastContainer() { - const [toasts, setToasts] = useState([]); + const [toasts, setToasts] = useState([]); useEffect(() => { - showToastFn = (message, duration) => { + showToastFn = (message: string, duration: number) => { const id = Date.now(); setToasts((prev) => [...prev, { id, message, duration }]); }; @@ -33,7 +39,7 @@ export function ToastContainer() { }; }, []); - const removeToast = (id) => { + const removeToast = (id: number) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }; @@ -52,12 +58,18 @@ export function ToastContainer() { ); } +interface ToastItemProps { + message: string; + duration: number; + onClose: () => void; +} + /** * 개별 토스트 아이템 */ -function ToastItem({ message, duration, onClose }) { - const [isVisible, setIsVisible] = useState(false); - const [isExiting, setIsExiting] = useState(false); +function ToastItem({ message, duration, onClose }: ToastItemProps) { + const [isVisible, setIsVisible] = useState(false); + const [isExiting, setIsExiting] = useState(false); useEffect(() => { // 마운트 후 바로 표시 diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.tsx similarity index 95% rename from src/components/layout/Header.jsx rename to src/components/layout/Header.tsx index ebc56310..bc6bd3a5 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.tsx @@ -16,9 +16,9 @@ const SAMPLE_ALERTS = [ */ export default function Header() { const user = useAuthStore((s) => s.user); - const alertIndexRef = useRef(0); + const alertIndexRef = useRef(0); - const handleAlarmClick = (e) => { + const handleAlarmClick = (e: React.MouseEvent) => { e.preventDefault(); const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length]; alertIndexRef.current++; diff --git a/src/components/layout/MainLayout.jsx b/src/components/layout/MainLayout.tsx similarity index 100% rename from src/components/layout/MainLayout.jsx rename to src/components/layout/MainLayout.tsx diff --git a/src/components/layout/SideNav.jsx b/src/components/layout/SideNav.tsx similarity index 74% rename from src/components/layout/SideNav.jsx rename to src/components/layout/SideNav.tsx index e5953b4a..7d9b4079 100644 --- a/src/components/layout/SideNav.jsx +++ b/src/components/layout/SideNav.tsx @@ -2,7 +2,14 @@ * 사이드 네비게이션 메뉴 */ -const gnbList = [ +interface GnbItem { + key: string; + className: string; + label: string; + path: string; +} + +const gnbList: GnbItem[] = [ { key: 'gnb1', className: 'gnb1', label: '선박', path: 'ship' }, { key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' }, { key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' }, @@ -10,7 +17,12 @@ const gnbList = [ { key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' }, ]; -export default function SideNav({ activeKey, onChange }) { +interface SideNavProps { + activeKey: string | null; + onChange: (key: string) => void; +} + +export default function SideNav({ activeKey, onChange }: SideNavProps) { return (
      @@ -383,7 +400,7 @@ export default function TopBar() { {/* 검색 결과 목록 */} {isSearchFocused && searchValue && results.length > 0 && (
        - {results.map((result) => ( + {results.map((result: SearchResult) => (
      • 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passIcon, @@ -42,10 +42,15 @@ const SHIP_KIND_ICONS = { [SIGNAL_KIND_CODE_BUOY]: bouyIcon, }; +interface LegendItemConfig { + code: string; + label: string; +} + /** * 범례 항목 설정 */ -const LEGEND_ITEMS = [ +const LEGEND_ITEMS: LegendItemConfig[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, { code: SIGNAL_KIND_CODE_CARGO, label: '화물선' }, @@ -56,10 +61,19 @@ const LEGEND_ITEMS = [ { code: SIGNAL_KIND_CODE_NORMAL, label: '기타' }, ]; +interface ReplayLegendItemProps { + code: string; + label: string; + count: number; + icon: string; + isVisible: boolean; + onToggle: (code: string) => void; +} + /** * 리플레이 범례 항목 */ -const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { +const ReplayLegendItem = memo(function ReplayLegendItem({ code, label, count, icon, isVisible, onToggle }: ReplayLegendItemProps) { const isBuoy = code === SIGNAL_KIND_CODE_BUOY; return ( @@ -82,17 +96,21 @@ const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle } /** * 리플레이 전용 범례 컴포넌트 */ -const ReplayLegend = memo(() => { +const ReplayLegend = memo(function ReplayLegend() { const { replayShipCounts, replayTotalCount, shipKindCodeFilter } = useReplayStore( - (state) => ({ + (state: { + replayShipCounts: Record; + replayTotalCount: number; + shipKindCodeFilter: Set; + }) => ({ replayShipCounts: state.replayShipCounts, replayTotalCount: state.replayTotalCount, shipKindCodeFilter: state.shipKindCodeFilter, }), shallow ); - const toggleShipKindCode = useReplayStore((state) => state.toggleShipKindCode); + const toggleShipKindCode = useReplayStore((state: { toggleShipKindCode: (code: string) => void }) => state.toggleShipKindCode); return (
        diff --git a/src/components/ship/ShipContextMenu.jsx b/src/components/ship/ShipContextMenu.tsx similarity index 78% rename from src/components/ship/ShipContextMenu.jsx rename to src/components/ship/ShipContextMenu.tsx index 7d159409..1855cba4 100644 --- a/src/components/ship/ShipContextMenu.jsx +++ b/src/components/ship/ShipContextMenu.tsx @@ -13,15 +13,29 @@ import { buildVesselListForQuery, deduplicateVessels, } from '../../tracking/services/trackQueryApi'; +import type { VesselQueryTarget } from '../../tracking/services/trackQueryApi'; +import type { ShipFeature } from '../../types/ship'; import './ShipContextMenu.scss'; +interface ContextMenuData { + x: number; + y: number; + ships: ShipFeature[]; +} + +interface MenuItem { + key: string; + label: string; + hasSubmenu?: boolean; +} + /** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */ -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -const MENU_ITEMS = [ +const MENU_ITEMS: MenuItem[] = [ { key: 'track', label: '항적조회' }, // TODO: 임시 배포용 - 미구현 기능 숨김 // { key: 'analysis', label: '항적분석' }, @@ -30,20 +44,20 @@ const MENU_ITEMS = [ ]; export default function ShipContextMenu() { - const contextMenu = useShipStore((s) => s.contextMenu); - const closeContextMenu = useShipStore((s) => s.closeContextMenu); - const setRadius = useTrackingModeStore((s) => s.setRadius); - const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip); - const currentRadius = useTrackingModeStore((s) => s.radiusNM); - const menuRef = useRef(null); - const [hoveredItem, setHoveredItem] = useState(null); + const contextMenu = useShipStore((s: { contextMenu: ContextMenuData | null }) => s.contextMenu); + const closeContextMenu = useShipStore((s: { closeContextMenu: () => void }) => s.closeContextMenu); + const setRadius = useTrackingModeStore((s: { setRadius: (r: number) => void }) => s.setRadius); + const selectTrackedShip = useTrackingModeStore((s: { selectTrackedShip: (id: string, ship: ShipFeature) => void }) => s.selectTrackedShip); + const currentRadius = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM); + const menuRef = useRef(null); + const [hoveredItem, setHoveredItem] = useState(null); // 외부 클릭 시 닫기 useEffect(() => { if (!contextMenu) return; - const handleClick = (e) => { - if (menuRef.current && !menuRef.current.contains(e.target)) { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; @@ -53,7 +67,7 @@ export default function ShipContextMenu() { }, [contextMenu, closeContextMenu]); // 반경 선택 핸들러 - const handleRadiusSelect = useCallback((radius) => { + const handleRadiusSelect = useCallback((radius: number) => { if (!contextMenu) return; const { ships } = contextMenu; @@ -67,7 +81,7 @@ export default function ShipContextMenu() { }, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]); // 메뉴 항목 클릭 - const handleAction = useCallback(async (key) => { + const handleAction = useCallback(async (key: string) => { if (!contextMenu) return; const { ships } = contextMenu; @@ -86,8 +100,8 @@ export default function ShipContextMenu() { const { isIntegrate, features } = useShipStore.getState(); - const allVessels = []; - const errors = []; + const allVessels: VesselQueryTarget[] = []; + const errors: string[] = []; ships.forEach(ship => { const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features); if (result.canQuery) allVessels.push(...result.vessels); @@ -134,7 +148,7 @@ export default function ShipContextMenu() { const { isIntegrate, features } = useShipStore.getState(); - const allVessels = []; + const allVessels: VesselQueryTarget[] = []; ships.forEach(ship => { const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features); if (result.canQuery) allVessels.push(...result.vessels); @@ -213,28 +227,28 @@ export default function ShipContextMenu() { style={{ left: adjustedX, top: adjustedY }} >
        {title}
        - {visibleMenuItems.map((item, index) => ( + {visibleMenuItems.map((_item, _index) => (
        handleAction(item.key)} - onMouseEnter={() => setHoveredItem(item.key)} + key={_item.key} + className={`ship-context-menu__item ${_item.hasSubmenu ? 'has-submenu' : ''}`} + onClick={() => handleAction(_item.key)} + onMouseEnter={() => setHoveredItem(_item.key)} onMouseLeave={() => setHoveredItem(null)} > - {item.label} - {item.hasSubmenu && } + {_item.label} + {_item.hasSubmenu && } {/* 반경설정 서브메뉴 */} - {item.key === 'radius' && hoveredItem === 'radius' && ( + {_item.key === 'radius' && hoveredItem === 'radius' && (
        - {RADIUS_OPTIONS.map((radius) => ( + {RADIUS_OPTIONS.map((radius: number) => (
        { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRadiusSelect(radius); }} diff --git a/src/components/ship/ShipDetailModal.jsx b/src/components/ship/ShipDetailModal.tsx similarity index 86% rename from src/components/ship/ShipDetailModal.jsx rename to src/components/ship/ShipDetailModal.tsx index 017e2e8a..f414540b 100644 --- a/src/components/ship/ShipDetailModal.jsx +++ b/src/components/ship/ShipDetailModal.tsx @@ -7,8 +7,10 @@ * - 선박 사진 갤러리 (없으면 기본 이미지) * - 새 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성 */ -import { useRef, useState, useCallback, useEffect } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; import useShipStore from '../../stores/shipStore'; +import type { DetailModal } from '../../stores/shipStore'; +import type { ShipFeature } from '../../types/ship'; import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore'; import { fetchVesselTracksV2, @@ -39,7 +41,7 @@ import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg'; import './ShipDetailModal.scss'; /** 선종코드 → 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, @@ -49,7 +51,7 @@ const SHIP_KIND_ICONS = { }; /** 선종 아이콘 URL 반환 */ -function getShipKindIcon(signalKindCode) { +function getShipKindIcon(signalKindCode: string): string { return SHIP_KIND_ICONS[signalKindCode] || etcIcon; } @@ -57,10 +59,8 @@ function getShipKindIcon(signalKindCode) { * 국기 아이콘 URL 반환 (서버 API) * 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag() * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 - * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') - * @returns {string} 국기 이미지 URL */ -function getNationalFlagUrl(nationalCode) { +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) return `/ship/image/small/${nationalCode}.svg`; @@ -69,10 +69,8 @@ function getNationalFlagUrl(nationalCode) { /** * receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환 * 입력 예: '20241123112300' 또는 '2024-11-23 11:23:00' 또는 '2024-11-23T11:23:00' - * @param {string} raw - * @returns {string} */ -function formatDateTime(raw) { +function formatDateTime(raw: string | undefined): string { if (!raw) return '-'; // 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환 @@ -97,10 +95,21 @@ function formatDateTime(raw) { return raw; } +/** 시간 범위 */ +interface TimeRange { + fromDate: string; + toDate: string; +} + +/** SignalFlags Props */ +interface SignalFlagsProps { + ship: ShipFeature; +} + /** * AVETDR 신호 플래그 표시 */ -function SignalFlags({ ship }) { +function SignalFlags({ ship }: SignalFlagsProps) { const isIntegrate = useShipStore((s) => s.isIntegrate); // 통합선박 판별: 언더스코어 또는 integrate 플래그 const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate); @@ -113,7 +122,7 @@ function SignalFlags({ ship }) { let isVisible = false; if (useIntegratedMode) { - const val = ship[config.dataKey]; + const val = ship[config.dataKey] as string | undefined; if (val === '1') { isVisible = true; isActive = true; } else if (val === '0') { isVisible = true; } } else { @@ -139,11 +148,16 @@ function SignalFlags({ ship }) { ); } +/** ShipGallery Props */ +interface ShipGalleryProps { + imageUrlList: string[] | undefined; +} + /** * 선박 사진 갤러리 * 이미지가 없으면 기본 이미지(default-ship.png) 표시 */ -function ShipGallery({ imageUrlList }) { +function ShipGallery({ imageUrlList }: ShipGalleryProps) { const [currentIndex, setCurrentIndex] = useState(0); const hasImages = imageUrlList && imageUrlList.length > 0; const images = hasImages ? imageUrlList : [defaultShipImg]; @@ -158,7 +172,7 @@ function ShipGallery({ imageUrlList }) { setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1)); }, [total]); - const handleIndicatorClick = useCallback((index) => { + const handleIndicatorClick = useCallback((index: number) => { setCurrentIndex(index); }, []); @@ -179,7 +193,7 @@ function ShipGallery({ imageUrlList }) { className="galleryImg" src={images[currentIndex]} alt="선박 이미지" - onError={(e) => { e.target.src = defaultShipImg; }} + onError={(e) => { (e.target as HTMLImageElement).src = defaultShipImg; }} />
        {canSlide && ( @@ -199,11 +213,15 @@ function ShipGallery({ imageUrlList }) { ); } +/** ShipDetailModal Props */ +interface ShipDetailModalProps { + modal: DetailModal; +} + /** * 단일 선박 상세 모달 - * @param {Object} props.modal - { ship, id, initialPos } */ -export default function ShipDetailModal({ modal }) { +export default function ShipDetailModal({ modal }: ShipDetailModalProps) { const closeDetailModal = useShipStore((s) => s.closeDetailModal); const updateModalPos = useShipStore((s) => s.updateModalPos); const isIntegrateMode = useShipStore((s) => s.isIntegrate); @@ -211,11 +229,11 @@ export default function ShipDetailModal({ modal }) { // 항적조회 패널 상태 const [showTrackPanel, setShowTrackPanel] = useState(false); const [isQuerying, setIsQuerying] = useState(false); - const [timeRange, setTimeRange] = useState(() => { + 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())}`; + const pad = (n: number) => String(n).padStart(2, '0'); + const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return { fromDate: toLocal(from), toDate: toLocal(now) }; }); @@ -226,7 +244,7 @@ export default function ShipDetailModal({ modal }) { const dragStart = useRef({ x: 0, y: 0 }); // 드래그 핸들러 - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - position.x, @@ -236,7 +254,7 @@ export default function ShipDetailModal({ modal }) { }, [position]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, @@ -263,13 +281,13 @@ 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'); + const toKstISOString = useCallback((date: Date): string => { + const pad = (n: number, 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 executeTrackQuery = useCallback(async (fromDate: string | Date, toDate: string | Date) => { const { ship } = modal; const startTime = new Date(fromDate); const endTime = new Date(toDate); @@ -277,7 +295,6 @@ export default function ShipDetailModal({ modal }) { if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return; if (startTime >= endTime) return; - const isIntegrated = isIntegratedTargetId(ship.targetId); // 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회 // isIntegration API 파라미터는 항상 '0' (개별 항적 반환) const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode); @@ -320,8 +337,8 @@ export default function ShipDetailModal({ modal }) { // 즉시 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 pad = (n: number) => String(n).padStart(2, '0'); + const toLocal = (d: Date) => `${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); @@ -366,9 +383,9 @@ export default function ShipDetailModal({ modal }) { {ship.nationalCode && ( 국기 { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} @@ -384,7 +401,7 @@ export default function ShipDetailModal({ modal }) {
        {/* gallery */} - + {/* body */}
        diff --git a/src/components/ship/ShipFilterPanel.jsx b/src/components/ship/ShipFilterPanel.tsx similarity index 57% rename from src/components/ship/ShipFilterPanel.jsx rename to src/components/ship/ShipFilterPanel.tsx index 539d821c..8cf99987 100644 --- a/src/components/ship/ShipFilterPanel.jsx +++ b/src/components/ship/ShipFilterPanel.tsx @@ -3,15 +3,21 @@ * - 선종별 표시/숨김 토글 * - 기존 DisplayComponent의 필터 탭을 민간화 버전으로 재구현 */ -import { useState, memo, useCallback } from 'react'; +import { useState, memo, useCallback, ReactNode } from 'react'; import useShipStore from '../../stores/shipStore'; import { SHIP_KIND_LIST } from '../../types/constants'; +interface SwitchGroupProps { + title: string; + children: ReactNode; + defaultOpen?: boolean; +} + /** * 스위치 그룹 헤더 + 접이식 본문 */ -const SwitchGroup = memo(({ title, children, defaultOpen = true }) => { - const [isOpen, setIsOpen] = useState(defaultOpen); +const SwitchGroup = memo(function SwitchGroup({ title, children, defaultOpen = true }: SwitchGroupProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); return (
        @@ -31,45 +37,66 @@ const SwitchGroup = memo(({ title, children, defaultOpen = true }) => { ); }); +interface ToggleSwitchProps { + label: string; + checked: boolean; + onChange: () => void; +} + /** * 개별 토글 스위치 (CSS 토글은 기존 common.css의 .switch 클래스 사용) */ -const ToggleSwitch = memo(({ label, checked, onChange }) => ( -
      • - {label} - -
      • -)); +const ToggleSwitch = memo(function ToggleSwitch({ label, checked, onChange }: ToggleSwitchProps) { + return ( +
      • + {label} + +
      • + ); +}); + +interface AllToggleProps { + label: string; + allChecked: boolean; + onToggleAll: () => void; +} /** * 전체 ON/OFF 토글 */ -const AllToggle = memo(({ label, allChecked, onToggleAll }) => ( -
      • - {label} - -
      • -)); +const AllToggle = memo(function AllToggle({ label, allChecked, onToggleAll }: AllToggleProps) { + return ( +
      • + {label} + +
      • + ); +}); + +interface ShipFilterPanelProps { + isOpen: boolean; + onToggle: () => void; +} /** * 선박 필터 패널 메인 컴포넌트 */ -export default function ShipFilterPanel({ isOpen, onToggle }) { - const kindVisibility = useShipStore((s) => s.kindVisibility); - const kindCounts = useShipStore((s) => s.kindCounts); - const toggleKindVisibility = useShipStore((s) => s.toggleKindVisibility); +export default function ShipFilterPanel({ isOpen, onToggle }: ShipFilterPanelProps) { + const kindVisibility = useShipStore((s: { kindVisibility: Record }) => s.kindVisibility); + const kindCounts = useShipStore((s: { kindCounts: Record }) => s.kindCounts); + const toggleKindVisibility = useShipStore((s: { toggleKindVisibility: (code: string) => void }) => s.toggleKindVisibility); // 선종 전체 토글 const allKindVisible = Object.values(kindVisibility).every(Boolean); const handleToggleAllKind = useCallback(() => { const nextValue = !allKindVisible; - SHIP_KIND_LIST.forEach(({ code }) => { + SHIP_KIND_LIST.forEach(({ code }: { code: string }) => { if (kindVisibility[code] !== nextValue) { toggleKindVisibility(code); } @@ -77,7 +104,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) { }, [allKindVisible, kindVisibility, toggleKindVisibility]); // 전체 선박 수 - const totalCount = Object.values(kindCounts).reduce((sum, v) => sum + v, 0); + const totalCount = Object.values(kindCounts).reduce((sum: number, v: number) => sum + v, 0); return (
        @@ -103,7 +130,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) { allChecked={allKindVisible} onToggleAll={handleToggleAllKind} /> - {SHIP_KIND_LIST.map(({ code, label }) => ( + {SHIP_KIND_LIST.map(({ code, label }: { code: string; label: string }) => ( 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passIcon, @@ -45,10 +45,15 @@ const SHIP_KIND_ICONS = { [SIGNAL_KIND_CODE_BUOY]: bouyIcon, }; +interface LegendItemConfig { + code: string; + label: string; +} + /** * 범례 항목 설정 */ -const LEGEND_ITEMS = [ +const LEGEND_ITEMS: LegendItemConfig[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, { code: SIGNAL_KIND_CODE_CARGO, label: '화물선' }, @@ -59,10 +64,19 @@ const LEGEND_ITEMS = [ { code: SIGNAL_KIND_CODE_NORMAL, label: '기타' }, ]; +interface LegendItemProps { + code: string; + label: string; + count: number; + icon: string; + isVisible: boolean; + onToggle: (code: string) => void; +} + /** * 선박 범례 항목 */ -const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { +const LegendItem = memo(function LegendItem({ code, label, count, icon, isVisible, onToggle }: LegendItemProps) { // 부이는 회전하지 않음 const isBuoy = code === SIGNAL_KIND_CODE_BUOY; @@ -83,15 +97,31 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { ); }); +interface AreaSearchCounts { + counts: Record; + total: number; +} + +interface AreaSearchTrack { + vesselId: string; + shipKindCode: string; +} + /** * 선박 범례 컴포넌트 */ -const ShipLegend = memo(() => { +const ShipLegend = memo(function ShipLegend() { // 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더 - // useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지 + // useShipStore() 전체 구독 -> featuresVersion 변경마다 리렌더되는 문제 방지 const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } = useShipStore( - (state) => ({ + (state: { + kindCounts: Record; + kindVisibility: Record; + isShipVisible: boolean; + totalCount: number; + isConnected: boolean; + }) => ({ kindCounts: state.kindCounts, kindVisibility: state.kindVisibility, isShipVisible: state.isShipVisible, @@ -100,19 +130,19 @@ const ShipLegend = memo(() => { }), shallow ); - const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility); - const toggleShipVisible = useShipStore((state) => state.toggleShipVisible); + const toggleKindVisibility = useShipStore((state: { toggleKindVisibility: (code: string) => void }) => state.toggleKindVisibility); + const toggleShipVisible = useShipStore((state: { toggleShipVisible: () => void }) => state.toggleShipVisible); // 항적분석 활성 시 결과 카운트 - const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted); - const areaSearchTracks = useAreaSearchStore((s) => s.tracks); - const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds); - const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter); - const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode); + const areaSearchCompleted = useAreaSearchStore((s: { queryCompleted: boolean }) => s.queryCompleted); + const areaSearchTracks = useAreaSearchStore((s: { tracks: AreaSearchTrack[] }) => s.tracks); + const areaSearchDisabledIds = useAreaSearchStore((s: { disabledVesselIds: Set }) => s.disabledVesselIds); + const areaSearchKindFilter = useAreaSearchStore((s: { shipKindCodeFilter: Set }) => s.shipKindCodeFilter); + const toggleAreaSearchKind = useAreaSearchStore((s: { toggleShipKindCode: (code: string) => void }) => s.toggleShipKindCode); - const areaSearchCounts = useMemo(() => { + const areaSearchCounts = useMemo((): AreaSearchCounts | null => { if (!areaSearchCompleted || areaSearchTracks.length === 0) return null; - const counts = {}; + const counts: Record = {}; let total = 0; areaSearchTracks.forEach((track) => { if (areaSearchDisabledIds.has(track.vesselId)) return; diff --git a/src/components/ship/ShipTooltip.jsx b/src/components/ship/ShipTooltip.tsx similarity index 73% rename from src/components/ship/ShipTooltip.jsx rename to src/components/ship/ShipTooltip.tsx index f2280b31..e36fa795 100644 --- a/src/components/ship/ShipTooltip.jsx +++ b/src/components/ship/ShipTooltip.tsx @@ -8,10 +8,24 @@ import './ShipTooltip.scss'; const OFFSET_X = 12; const OFFSET_Y = -40; -export default function ShipTooltip({ ship, x, y }) { +interface ShipData { + signalKindCode?: string; + sog?: number | string; + cog?: number | string; + shipName?: string; + targetId?: string; +} + +interface ShipTooltipProps { + ship: ShipData | null; + x: number; + y: number; +} + +export default function ShipTooltip({ ship, x, y }: ShipTooltipProps) { if (!ship) return null; - const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타'; + const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode as string] || '기타'; const sog = Number(ship.sog) || 0; const cog = Number(ship.cog) || 0; const isMoving = sog > SPEED_THRESHOLD; diff --git a/src/components/ship/TrackQueryModal.jsx b/src/components/ship/TrackQueryModal.tsx similarity index 90% rename from src/components/ship/TrackQueryModal.jsx rename to src/components/ship/TrackQueryModal.tsx index bde49c0f..15bdd7a8 100644 --- a/src/components/ship/TrackQueryModal.jsx +++ b/src/components/ship/TrackQueryModal.tsx @@ -11,12 +11,21 @@ * * 모달 모드에서는 재생/배속 컨트롤 없음 (Phase 2 확장점) */ -import { useRef, useState, useCallback, useEffect } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; import useTrackStore, { getShipKindTrackColor } from '../../stores/trackStore'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; +import type { ShipFeature } from '../../types/ship'; import { SHIP_KIND_LABELS, SIGNAL_FLAG_CONFIGS, SIGNAL_SOURCE_LABELS, TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../../types/constants'; import { showToast } from '../common/Toast'; import './TrackQueryModal.scss'; +/** TrackModal 인터페이스 (trackStore 내부 정의와 동일) */ +interface TrackModal { + ships: ShipFeature[]; + id: string; + isIntegrated: boolean; +} + /** 기본 조회 기간 (일) */ const DEFAULT_QUERY_DAYS = TRACK_QUERY_DEFAULT_DAYS; @@ -27,31 +36,36 @@ const MAX_QUERY_DAYS = TRACK_QUERY_MAX_DAYS; const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** datetime-local 입력용 포맷 */ -function toDateTimeLocal(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toDateTimeLocal(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** MM-DD HH:mm 형식 */ -function formatShortDateTime(ms) { +function formatShortDateTime(ms: number): string { if (!ms) return '--/-- --:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** YYYY-MM-DD HH:mm:ss 형식 */ -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms) return '-'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +/** TrackQueryModal Props */ +interface TrackQueryModalProps { + modal: TrackModal; +} + /** * 항적 조회 뷰어 패널 */ -export default function TrackQueryModal({ modal }) { +export default function TrackQueryModal({ modal }: TrackQueryModalProps) { const closeTrackModal = useTrackStore((s) => s.closeTrackModal); // 스토어 상태 구독 @@ -73,24 +87,24 @@ export default function TrackQueryModal({ modal }) { const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date())); // 시작일 변경 핸들러 - const handleStartChange = useCallback((e) => { + const handleStartChange = useCallback((e: React.ChangeEvent) => { setStartInput(e.target.value); }, []); // 종료일 변경 핸들러 - const handleEndChange = useCallback((e) => { + const handleEndChange = useCallback((e: React.ChangeEvent) => { setEndInput(e.target.value); }, []); // 조회 기간 검증 및 자동 조정 (blur 시 실행) - const validateAndAdjustDates = useCallback((changedField) => { + const validateAndAdjustDates = useCallback((changedField: 'start' | 'end') => { const startDate = new Date(startInput); const endDate = new Date(endInput); // 유효하지 않은 날짜면 무시 if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return; - const diffDays = (endDate - startDate) / DAYS_TO_MS; + const diffDays = (endDate.getTime() - startDate.getTime()) / DAYS_TO_MS; if (changedField === 'start') { // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 @@ -151,7 +165,7 @@ export default function TrackQueryModal({ modal }) { : 0; // 드래그 핸들러 - const handleDragStart = useCallback((e) => { + const handleDragStart = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - position.x, @@ -161,7 +175,7 @@ export default function TrackQueryModal({ modal }) { }, [position]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; setPosition({ x: e.clientX - dragStart.current.x, @@ -197,7 +211,7 @@ export default function TrackQueryModal({ modal }) { }, [startInput, endInput, modal.ships]); // 프로그레스 바 클릭 - const handleProgressClick = useCallback((e) => { + const handleProgressClick = useCallback((e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); useTrackStore.getState().setProgressByRatio(ratio); @@ -392,18 +406,23 @@ export default function TrackQueryModal({ modal }) { ); } +/** EquipmentFilter Props */ +interface EquipmentFilterProps { + ship: ShipFeature; +} + /** * 장비 필터 (통합선박 AVETDR) * 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts */ -function EquipmentFilter({ ship }) { +function EquipmentFilter({ ship }: EquipmentFilterProps) { const tracks = useTrackStore((s) => s.tracks); const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds); // 항적 데이터에 존재하는 장비만 표시 const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd)); - const handleToggle = useCallback((sigSrcCd) => { + const handleToggle = useCallback((sigSrcCd: string) => { useTrackStore.getState().toggleEquipment(sigSrcCd); }, []); @@ -461,10 +480,15 @@ function EquipmentFilter({ ship }) { ); } +/** VesselItem Props */ +interface VesselItemProps { + track: ProcessedTrack; +} + /** * 다중 선박 목록 아이템 */ -function VesselItem({ track }) { +function VesselItem({ track }: VesselItemProps) { const handleToggle = useCallback(() => { useTrackStore.getState().toggleVesselEnabled(track.vesselId); }, [track.vesselId]); diff --git a/src/hooks/useFavoriteData.js b/src/hooks/useFavoriteData.ts similarity index 83% rename from src/hooks/useFavoriteData.js rename to src/hooks/useFavoriteData.ts index 68dad97c..b0fbee2a 100644 --- a/src/hooks/useFavoriteData.js +++ b/src/hooks/useFavoriteData.ts @@ -5,6 +5,6 @@ * 비활성화: 내부망 API(/api/gis) 접근 불가 (민간화) * TODO: 외부 API 연동 시 복원 */ -export default function useFavoriteData() { +export default function useFavoriteData(): void { // noop — 내부망 /api/gis 의존 제거 } diff --git a/src/hooks/useRadiusFilter.js b/src/hooks/useRadiusFilter.ts similarity index 72% rename from src/hooks/useRadiusFilter.js rename to src/hooks/useRadiusFilter.ts index 85725c75..4a609566 100644 --- a/src/hooks/useRadiusFilter.js +++ b/src/hooks/useRadiusFilter.ts @@ -12,6 +12,44 @@ import useTrackingModeStore, { NM_TO_METERS, } from '../stores/trackingModeStore'; +/** 위치 좌표를 가진 선박 (최소 인터페이스) */ +interface ShipWithCoords { + longitude: number; + latitude: number; +} + +/** 반경 중심 좌표 */ +interface RadiusCenter { + lon: number; + lat: number; +} + +/** Bounding Box */ +interface BoundingBox { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** useRadiusFilter 반환 타입 */ +interface UseRadiusFilterReturn { + filterByRadius: (ships: T[]) => T[]; + filterFeaturesMapByRadius: (featuresMap: Map) => Map; + isShipInRadius: (ship: ShipWithCoords) => boolean; + isRadiusFilterActive: boolean; + radiusCenter: RadiusCenter | null; + radiusNM: number; + boundingBox: BoundingBox | null; +} + +/** 반경 필터 상태 (비훅 버전) */ +interface RadiusFilterState { + isActive: boolean; + center: RadiusCenter | null; + radiusNM: number; +} + /** * 경도 1도당 대략적인 미터 (위도에 따라 다름) * 중위도(35도) 기준 약 91km @@ -21,9 +59,9 @@ const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km /** * 반경 필터링 훅 - * @returns {Object} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM } + * @returns {UseRadiusFilterReturn} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM } */ -export default function useRadiusFilter() { +export default function useRadiusFilter(): UseRadiusFilterReturn { const mode = useTrackingModeStore((s) => s.mode); const trackedShip = useTrackingModeStore((s) => s.trackedShip); const radiusNM = useTrackingModeStore((s) => s.radiusNM); @@ -32,7 +70,7 @@ export default function useRadiusFilter() { const isRadiusFilterActive = mode === 'ship' && trackedShip !== null; // 반경 중심 좌표 - const radiusCenter = useMemo(() => { + const radiusCenter = useMemo((): RadiusCenter | null => { if (!isRadiusFilterActive || !trackedShip) return null; return { lon: trackedShip.longitude, @@ -44,7 +82,7 @@ export default function useRadiusFilter() { * Bounding Box 계산 (사전 필터링용) * 반경을 감싸는 사각형 영역 */ - const boundingBox = useMemo(() => { + const boundingBox = useMemo((): BoundingBox | null => { if (!radiusCenter) return null; const radiusMeters = radiusNM * NM_TO_METERS; @@ -62,7 +100,7 @@ export default function useRadiusFilter() { /** * 선박이 Bounding Box 내에 있는지 빠른 체크 */ - const isInBoundingBox = useCallback((ship) => { + const isInBoundingBox = useCallback((ship: ShipWithCoords): boolean => { if (!boundingBox) return true; if (!ship.longitude || !ship.latitude) return false; @@ -79,7 +117,7 @@ export default function useRadiusFilter() { * @param {Array} ships - 선박 배열 * @returns {Array} 반경 내 선박만 */ - const filterByRadius = useCallback((ships) => { + const filterByRadius = useCallback((ships: T[]): T[] => { // 반경 필터 비활성화 시 전체 반환 if (!isRadiusFilterActive || !radiusCenter) { return ships; @@ -99,7 +137,7 @@ export default function useRadiusFilter() { * @param {Object} ship * @returns {boolean} */ - const isShipInRadius = useCallback((ship) => { + const isShipInRadius = useCallback((ship: ShipWithCoords): boolean => { if (!isRadiusFilterActive || !radiusCenter) return true; if (!isInBoundingBox(ship)) return false; return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM); @@ -110,12 +148,12 @@ export default function useRadiusFilter() { * @param {Map} featuresMap - featureId -> ship Map * @returns {Map} 반경 내 선박만 */ - const filterFeaturesMapByRadius = useCallback((featuresMap) => { + const filterFeaturesMapByRadius = useCallback((featuresMap: Map): Map => { if (!isRadiusFilterActive || !radiusCenter) { return featuresMap; } - const filteredMap = new Map(); + const filteredMap = new Map(); featuresMap.forEach((ship, featureId) => { if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) { filteredMap.set(featureId, ship); @@ -140,7 +178,7 @@ export default function useRadiusFilter() { * 반경 필터 유틸리티 (비훅 버전) * shipStore나 다른 스토어에서 직접 사용 */ -export function getRadiusFilterState() { +export function getRadiusFilterState(): RadiusFilterState { const state = useTrackingModeStore.getState(); const { mode, trackedShip, radiusNM } = state; @@ -160,7 +198,7 @@ export function getRadiusFilterState() { /** * 선박이 반경 내에 있는지 확인 (비훅 버전) */ -export function checkShipInRadius(ship) { +export function checkShipInRadius(ship: ShipWithCoords): boolean { const { isActive, center, radiusNM } = getRadiusFilterState(); if (!isActive || !center) return true; return isWithinRadius(ship, center.lon, center.lat, radiusNM); diff --git a/src/hooks/useRealmLayer.js b/src/hooks/useRealmLayer.ts similarity index 81% rename from src/hooks/useRealmLayer.js rename to src/hooks/useRealmLayer.ts index ca4ea962..93ca1515 100644 --- a/src/hooks/useRealmLayer.js +++ b/src/hooks/useRealmLayer.ts @@ -3,18 +3,34 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import Feature from 'ol/Feature'; import Polygon from 'ol/geom/Polygon'; +import type { Geometry } from 'ol/geom'; import { Style, Fill, Stroke, Text } from 'ol/style'; import useFavoriteStore from '../stores/favoriteStore'; import { useMapStore } from '../stores/mapStore'; +/** 관심구역 데이터 (API 응답 형태) */ +interface RealmData { + coordinates: number[] | number[][] | number[][][]; + seaRelmNameYn?: string; + seaRelmName?: string; + fontColor?: string; + fontSize?: number; + fontKind?: string; + outlineColor?: string; + outlineWidth?: number | string; + outlineType?: string; + fillColor?: string; + seaRelmId?: string | number; +} + /** * 관심구역 OpenLayers 레이어 관리 훅 * 참조: mda-react-front/src/services/commonService.ts - getRealmLayer() */ -export default function useRealmLayer() { +export default function useRealmLayer(): void { const map = useMapStore((s) => s.map); - const layerRef = useRef(null); - const sourceRef = useRef(null); + const layerRef = useRef> | null>(null); + const sourceRef = useRef(null); useEffect(() => { if (!map) return; @@ -34,7 +50,7 @@ export default function useRealmLayer() { console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`); layer.setVisible(isRealmVisible); if (realmList.length > 0) { - renderRealms(source, realmList); + renderRealms(source, realmList as unknown as RealmData[]); } // realmList 변경 구독 @@ -45,14 +61,14 @@ export default function useRealmLayer() { if (newRealmList.length > 0) { console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300)); } - renderRealms(source, newRealmList); + renderRealms(source, newRealmList as unknown as RealmData[]); } ); // isRealmVisible 변경 구독 const unsubVisible = useFavoriteStore.subscribe( (state) => state.isRealmVisible, - (isVisible) => { + (isVisible: boolean) => { console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`); if (layerRef.current) { layerRef.current.setVisible(isVisible); @@ -78,7 +94,8 @@ export default function useRealmLayer() { * @param {Array} coordinates - 좌표 데이터 * @returns {Array} Polygon rings 배열 [[lon,lat], ...] */ -function normalizeCoordinates(coordinates) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function normalizeCoordinates(coordinates: any): number[][] | null { if (!Array.isArray(coordinates) || coordinates.length === 0) return null; // coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...] @@ -99,7 +116,7 @@ function normalizeCoordinates(coordinates) { * @param {VectorSource} source - OL VectorSource * @param {Array} realmList - 관심구역 데이터 배열 */ -function renderRealms(source, realmList) { +function renderRealms(source: VectorSource, realmList: RealmData[]): void { source.clear(); if (!realmList || realmList.length === 0) return; diff --git a/src/hooks/useShipData.js b/src/hooks/useShipData.ts similarity index 76% rename from src/hooks/useShipData.js rename to src/hooks/useShipData.ts index bf1720a2..77b99f48 100644 --- a/src/hooks/useShipData.js +++ b/src/hooks/useShipData.ts @@ -16,18 +16,31 @@ const INITIAL_LOAD_MINUTES = 60; /** 증분 로드 기간 (분) */ const INCREMENT_MINUTES = 2; // 약간의 중복 허용으로 누락 방지 +/** useShipData 옵션 */ +interface UseShipDataOptions { + autoConnect?: boolean; +} + +/** useShipData 반환 타입 */ +interface UseShipDataReturn { + isConnected: boolean; + isLoading: boolean; + connect: () => Promise; + disconnect: () => void; +} + /** * 선박 데이터 관리 훅 - * @param {Object} options - 옵션 + * @param {UseShipDataOptions} options - 옵션 * @param {boolean} options.autoConnect - 자동 시작 여부 (기본값: true) - * @returns {Object} { isConnected, isLoading, connect, disconnect } + * @returns {UseShipDataReturn} { isConnected, isLoading, connect, disconnect } */ -export default function useShipData(options = {}) { +export default function useShipData(options: UseShipDataOptions = {}): UseShipDataReturn { const { autoConnect = true } = options; - const pollingRef = useRef(null); - const initialLoadDoneRef = useRef(false); - const [isLoading, setIsLoading] = useState(true); + const pollingRef = useRef | null>(null); + const initialLoadDoneRef = useRef(false); + const [isLoading, setIsLoading] = useState(true); const mergeFeatures = useShipStore((s) => s.mergeFeatures); const setConnected = useShipStore((s) => s.setConnected); const isConnected = useShipStore((s) => s.isConnected); @@ -35,7 +48,7 @@ export default function useShipData(options = {}) { /** * AIS 데이터를 shipStore feature 형식으로 변환하여 머지 */ - const loadAndMerge = useCallback(async (minutes) => { + const loadAndMerge = useCallback(async (minutes: number): Promise => { try { const aisTargets = await searchAisTargets(minutes); if (aisTargets.length > 0) { @@ -53,7 +66,7 @@ export default function useShipData(options = {}) { /** * 폴링 시작 */ - const startPolling = useCallback(() => { + const startPolling = useCallback((): void => { if (pollingRef.current) return; pollingRef.current = setInterval(() => { @@ -66,7 +79,7 @@ export default function useShipData(options = {}) { /** * 폴링 중지 */ - const stopPolling = useCallback(() => { + const stopPolling = useCallback((): void => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; @@ -77,7 +90,7 @@ export default function useShipData(options = {}) { /** * 연결 (초기 로드 + 폴링 시작) */ - const connect = useCallback(async () => { + const connect = useCallback(async (): Promise => { if (initialLoadDoneRef.current) { startPolling(); setConnected(true); @@ -102,7 +115,7 @@ export default function useShipData(options = {}) { /** * 연결 해제 */ - const disconnect = useCallback(() => { + const disconnect = useCallback((): void => { stopPolling(); setConnected(false); }, [stopPolling, setConnected]); diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.ts similarity index 82% rename from src/hooks/useShipLayer.js rename to src/hooks/useShipLayer.ts index 986f7729..8de3a4a2 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.ts @@ -10,6 +10,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { Deck } from '@deck.gl/core'; import { toLonLat } from 'ol/proj'; +import type Map from 'ol/Map'; import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer'; import useShipStore from '../stores/shipStore'; import { useTrackQueryStore } from '../tracking/stores/trackQueryStore'; @@ -22,27 +23,42 @@ import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry'; import { shipBatchRenderer } from '../map/ShipBatchRenderer'; import useFavoriteStore from '../stores/favoriteStore'; +/** 뷰포트 바운드 */ +interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** useShipLayer 반환 타입 */ +interface UseShipLayerReturn { + deckCanvas: HTMLCanvasElement | null; + deckRef: React.MutableRefObject; +} + /** * 선박 레이어 관리 훅 - * @param {Object} map - OpenLayers 맵 인스턴스 - * @returns {Object} { deckCanvas } + * @param {Map | null} map - OpenLayers 맵 인스턴스 + * @returns {UseShipLayerReturn} { deckCanvas } */ -export default function useShipLayer(map) { - const deckRef = useRef(null); - const canvasRef = useRef(null); - const animationFrameRef = useRef(null); - const batchRendererInitialized = useRef(false); +export default function useShipLayer(map: Map | null): UseShipLayerReturn { + const deckRef = useRef(null); + const canvasRef = useRef(null); + const animationFrameRef = useRef(null); + const batchRendererInitialized = useRef(false); const getSelectedShips = useShipStore((s) => s.getSelectedShips); const isShipVisible = useShipStore((s) => s.isShipVisible); // 마지막 선박 레이어: 캐시용 - const lastShipLayersRef = useRef([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lastShipLayersRef = useRef([]); /** * Deck.gl 인스턴스 초기화 */ - const initDeck = useCallback((container) => { + const initDeck = useCallback((container: HTMLElement): void => { if (deckRef.current) return; const canvas = document.createElement('canvas'); @@ -63,7 +79,7 @@ export default function useShipLayer(map) { layers: [], useDevicePixels: true, pickingRadius: 20, - onError: (error) => { + onError: (error: Error) => { console.error('[Deck.gl] Error:', error); }, }); @@ -72,7 +88,7 @@ export default function useShipLayer(map) { /** * Deck.gl viewState를 OpenLayers 뷰와 동기화 */ - const syncViewState = useCallback(() => { + const syncViewState = useCallback((): void => { if (!map || !deckRef.current) return; const view = map.getView(); @@ -98,7 +114,7 @@ export default function useShipLayer(map) { /** * 뷰포트 범위 계산 */ - const getViewportBounds = useCallback(() => { + const getViewportBounds = useCallback((): ViewportBounds | null => { if (!map) return null; const view = map.getView(); @@ -116,7 +132,8 @@ export default function useShipLayer(map) { /** * 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합 */ - const handleBatchRender = useCallback((ships, trigger) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleBatchRender = useCallback((ships: any[], trigger: number): void => { if (!deckRef.current || !map) return; const view = map.getView(); @@ -157,7 +174,7 @@ export default function useShipLayer(map) { /** * 선박 레이어 업데이트 (배치 렌더러 사용) */ - const updateLayers = useCallback(() => { + const updateLayers = useCallback((): void => { if (!deckRef.current || !map) return; if (!isShipVisible) { @@ -184,7 +201,7 @@ export default function useShipLayer(map) { /** * 렌더링 루프 */ - const render = useCallback(() => { + const render = useCallback((): void => { syncViewState(); updateLayers(); deckRef.current?.redraw(); @@ -202,8 +219,8 @@ export default function useShipLayer(map) { batchRendererInitialized.current = true; } - const handleMoveEnd = () => { render(); }; - const handlePostRender = () => { + const handleMoveEnd = (): void => { render(); }; + const handlePostRender = (): void => { syncViewState(); deckRef.current?.redraw(); }; @@ -240,7 +257,8 @@ export default function useShipLayer(map) { useEffect(() => { const unsubscribe = useShipStore.subscribe( (state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds], - (current, prev) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (current: any[], prev: any[]) => { const filterChanged = current[1] !== prev[1] || current[2] !== prev[2] || @@ -261,7 +279,8 @@ export default function useShipLayer(map) { updateLayers(); }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => { unsubscribe(); }; @@ -278,7 +297,8 @@ export default function useShipLayer(map) { shipBatchRenderer.requestRender(); } }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => unsubscribe(); @@ -297,7 +317,8 @@ export default function useShipLayer(map) { shipBatchRenderer.immediateRender(); } }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => unsubscribe(); @@ -331,7 +352,8 @@ export default function useShipLayer(map) { shipBatchRenderer.immediateRender(); } }, - { equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a[0] === b[0] && a[1] === b[1] } ); return () => unsubscribe(); diff --git a/src/hooks/useShipSearch.js b/src/hooks/useShipSearch.ts similarity index 84% rename from src/hooks/useShipSearch.js rename to src/hooks/useShipSearch.ts index c769ad21..d0eb1826 100644 --- a/src/hooks/useShipSearch.js +++ b/src/hooks/useShipSearch.ts @@ -19,6 +19,7 @@ import { fromLonLat } from 'ol/proj'; import useShipStore from '../stores/shipStore'; import { useMapStore } from '../stores/mapStore'; import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore'; +import type { ShipFeature } from '../types/ship'; // 레이더 신호원 코드 const SIGNAL_SOURCE_CODE_RADAR = '000005'; @@ -32,12 +33,47 @@ const DEBOUNCE_MS = 200; // 한글 정규식 const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/; +/** 검색 결과 항목 */ +export interface SearchResult { + featureId: string; + targetId: string; + originalTargetId: string; + shipName: string; + signalSourceCode: string; + longitude: number; + latitude: number; + ship: ShipFeature; +} + +/** 반경 바운딩 박스 */ +interface RadiusBoundingBox { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** 반경 중심 좌표 */ +interface RadiusCenter { + lon: number; + lat: number; +} + +/** useShipSearch 반환 타입 */ +interface UseShipSearchReturn { + searchValue: string; + setSearchValue: (keyword: string) => void; + results: SearchResult[]; + handleClickResult: (result: SearchResult) => void; + handleSelectFirst: () => void; + clearSearch: () => void; + isIntegrate: boolean; +} + /** * 검색어가 한글을 포함하는지 확인 - * @param {string} text - * @returns {boolean} */ -function containsKorean(text) { +function containsKorean(text: string): boolean { return KOREAN_REGEX.test(text); } @@ -46,14 +82,12 @@ function containsKorean(text) { * - 공백 제거 * - 특수문자 제거 (알파벳, 숫자, 한글만 유지) * - 소문자 변환 - * @param {string} text - * @returns {string} */ -function normalizeSearchText(text) { +function normalizeSearchText(text: string): string { if (!text) return ''; return text .toLowerCase() - .replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '') + .replace(/[\s\-_.,:;!@#$%^&*()+=[\]{}|\\/<>?'"]/g, '') .trim(); } @@ -61,11 +95,8 @@ function normalizeSearchText(text) { * 최소 검색 길이 확인 * - 한글 포함: 최소 2자 * - 영문/숫자만: 최소 3자 - * @param {string} originalText - 원본 입력값 - * @param {string} normalizedText - 정규화된 검색어 - * @returns {boolean} */ -function meetsMinLength(originalText, normalizedText) { +function meetsMinLength(originalText: string, normalizedText: string): boolean { if (!normalizedText) return false; const hasKorean = containsKorean(originalText); @@ -76,11 +107,10 @@ function meetsMinLength(originalText, normalizedText) { /** * 선박 검색 훅 - * @returns {Object} { searchValue, setSearchValue, results, handleSearch, handleClickResult, clearSearch } */ -export default function useShipSearch() { +export default function useShipSearch(): UseShipSearchReturn { const [searchValue, setSearchValueState] = useState(''); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const map = useMapStore((s) => s.map); const features = useShipStore((s) => s.features); @@ -95,7 +125,7 @@ export default function useShipSearch() { const radiusNM = useTrackingModeStore((s) => s.radiusNM); // 디바운스 타이머 ref - const debounceTimerRef = useRef(null); + const debounceTimerRef = useRef | null>(null); // 컴포넌트 언마운트 시 타이머 정리 useEffect(() => { @@ -108,9 +138,8 @@ export default function useShipSearch() { /** * 실제 검색 실행 (디바운스 후 호출) - * @param {string} keyword - 검색어 */ - const executeSearch = useCallback((keyword) => { + const executeSearch = useCallback((keyword: string) => { const normalizedKeyword = normalizeSearchText(keyword); // 최소 길이 미달 시 결과 초기화 @@ -121,8 +150,8 @@ export default function useShipSearch() { // 반경 필터 상태 확인 const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null; - let radiusBoundingBox = null; - let radiusCenter = null; + let radiusBoundingBox: RadiusBoundingBox | null = null; + let radiusCenter: RadiusCenter | null = null; if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) { radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude }; @@ -140,7 +169,7 @@ export default function useShipSearch() { } const hasKorean = containsKorean(keyword); - const matchedShips = []; + const matchedShips: SearchResult[] = []; // Map을 배열로 변환하여 for...of로 조기 종료 가능하게 const featuresArray = Array.from(features.entries()); @@ -226,9 +255,8 @@ export default function useShipSearch() { /** * 검색어 변경 핸들러 (디바운스 적용) - * @param {string} keyword - 검색어 */ - const setSearchValue = useCallback((keyword) => { + const setSearchValue = useCallback((keyword: string) => { setSearchValueState(keyword); // 빈 입력 시 즉시 결과 초기화 @@ -257,9 +285,8 @@ export default function useShipSearch() { * - 선박 선택 (하이라이트) * - 상세 모달 열기 * - 지도 중심 이동 - * @param {Object} result - 검색 결과 항목 */ - const handleClickResult = useCallback((result) => { + const handleClickResult = useCallback((result: SearchResult) => { if (!map || !result) return; const { featureId, longitude, latitude, ship } = result; diff --git a/src/hooks/useTrackingMode.js b/src/hooks/useTrackingMode.ts similarity index 88% rename from src/hooks/useTrackingMode.js rename to src/hooks/useTrackingMode.ts index 8046cb92..e05f8d5b 100644 --- a/src/hooks/useTrackingMode.js +++ b/src/hooks/useTrackingMode.ts @@ -13,12 +13,20 @@ import { Fill, Stroke, Style } from 'ol/style'; import { useMapStore } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; import useTrackingModeStore, { NM_TO_METERS } from '../stores/trackingModeStore'; +import type { ShipFeature } from '../types/ship'; + +/** useTrackingMode 반환 타입 */ +interface UseTrackingModeReturn { + isTrackingActive: boolean; + trackedShip: ShipFeature | null; + radiusNM: number; +} /** * 추적 모드 훅 * MapContainer에서 호출 */ -export default function useTrackingMode() { +export default function useTrackingMode(): UseTrackingModeReturn { const map = useMapStore((s) => s.map); const features = useShipStore((s) => s.features); @@ -29,11 +37,11 @@ export default function useTrackingMode() { const updateTrackedShip = useTrackingModeStore((s) => s.updateTrackedShip); // 반경 원 레이어 ref - const radiusLayerRef = useRef(null); - const radiusFeatureRef = useRef(null); + const radiusLayerRef = useRef | null>(null); + const radiusFeatureRef = useRef(null); // 이전 좌표 (중복 업데이트 방지) - const prevCoordsRef = useRef(null); + const prevCoordsRef = useRef(null); /** * 반경 원 레이어 생성 @@ -71,10 +79,11 @@ export default function useTrackingMode() { /** * 반경 원 업데이트 */ - const updateRadiusCircle = useCallback((lon, lat) => { + const updateRadiusCircle = useCallback((lon: number, lat: number) => { if (!radiusLayerRef.current) return; const source = radiusLayerRef.current.getSource(); + if (!source) return; source.clear(); // 원형 폴리곤 생성 (WGS84 좌표에서 미터 단위 반경) @@ -92,7 +101,7 @@ export default function useTrackingMode() { /** * 지도 중심 이동 (애니메이션) */ - const centerMapOnShip = useCallback((lon, lat, animate = true) => { + const centerMapOnShip = useCallback((lon: number, lat: number, animate = true) => { if (!map) return; const center = fromLonLat([lon, lat]); diff --git a/src/main.jsx b/src/main.tsx similarity index 85% rename from src/main.jsx rename to src/main.tsx index cb5cfbb1..8bc1cfac 100644 --- a/src/main.jsx +++ b/src/main.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; @@ -14,7 +13,7 @@ import './scss/global.scss'; // basename은 trailing slash 없이 설정 ('' 또는 '/kcgv') const basename = import.meta.env.BASE_URL.replace(/\/$/, ''); -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.tsx similarity index 78% rename from src/map/MapContainer.jsx rename to src/map/MapContainer.tsx index ec904926..75761225 100644 --- a/src/map/MapContainer.jsx +++ b/src/map/MapContainer.tsx @@ -5,10 +5,13 @@ import { fromLonLat, transformExtent } from 'ol/proj'; import { defaults as defaultControls, ScaleLine } from 'ol/control'; import { defaults as defaultInteractions, DragBox } from 'ol/interaction'; import { platformModifierKeyOnly } from 'ol/events/condition'; +import type { MapBrowserEvent } from 'ol'; +import type { Deck } from '@deck.gl/core'; import { createBaseLayers } from './layers/baseLayer'; import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; +import type { ShipFeature } from '../types/ship'; import useShipData from '../hooks/useShipData'; import useShipLayer from '../hooks/useShipLayer'; import ShipLegend from '../components/ship/ShipLegend'; @@ -35,8 +38,6 @@ import useZoneDraw from '../areaSearch/hooks/useZoneDraw'; import useZoneEdit from '../areaSearch/hooks/useZoneEdit'; import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore'; import { useStsStore } from '../areaSearch/stores/stsStore'; -import { useAreaSearchAnimationStore } from '../areaSearch/stores/areaSearchAnimationStore'; -import { unregisterAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry'; import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types'; import { STS_LAYER_IDS } from '../areaSearch/types/sts.types'; import AreaSearchTimeline from '../areaSearch/components/AreaSearchTimeline'; @@ -51,6 +52,14 @@ import './MapContainer.scss'; /** 호버 쓰로틀 간격 (ms) */ const HOVER_THROTTLE_MS = 50; +/** deck.pickObject 결과 타입 */ +interface DeckPickResult { + object?: ShipFeature & Record; + layer?: { id: string }; + x: number; + y: number; +} + /** * 지도 컨테이너 컴포넌트 * - OpenLayers 맵 초기화 및 관리 @@ -59,9 +68,13 @@ const HOVER_THROTTLE_MS = 50; * - 선박 호버 툴팁 / 더블클릭 상세 모달 */ export default function MapContainer() { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const baseLayersRef = useRef(null); // 배경지도 레이어 참조 + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const baseLayersRef = useRef<{ + worldMap: import('ol/layer/Tile').default; + encMap: import('ol/layer/Tile').default; + darkMap: import('ol/layer/Tile').default; + } | null>(null); const { map, setMap, setZoom, center, baseMapType } = useMapStore(); const showLegend = useShipStore((s) => s.showLegend); const hoverInfo = useShipStore((s) => s.hoverInfo); @@ -112,15 +125,15 @@ export default function MapContainer() { }, [baseMapType]); // 호버 쓰로틀 타이머 - const hoverTimerRef = useRef(null); + const hoverTimerRef = useRef | null>(null); /** * deck.pickObject 헬퍼 (라이브 선박 전용) */ - const pickShip = useCallback((pixel) => { + const pickShip = useCallback((pixel: number[]): ShipFeature | null => { const deck = deckRef.current; if (!deck) return null; - if (!deck.layerManager) return null; + if (!(deck as Deck & { layerManager?: unknown }).layerManager) return null; try { const result = deck.pickObject({ @@ -128,7 +141,7 @@ export default function MapContainer() { y: pixel[1], layerIds: ['ship-icon-layer'], }); - return result?.object || null; + return (result?.object as ShipFeature) || null; } catch { return null; } @@ -137,10 +150,10 @@ export default function MapContainer() { /** * deck.pickObject 헬퍼 (모든 레이어) */ - const pickAny = useCallback((pixel) => { + const pickAny = useCallback((pixel: number[]): DeckPickResult | null => { const deck = deckRef.current; if (!deck) return null; - if (!deck.layerManager) return null; + if (!(deck as Deck & { layerManager?: unknown }).layerManager) return null; try { // layerIds를 지정하지 않으면 모든 pickable 레이어에서 픽킹 @@ -148,16 +161,16 @@ export default function MapContainer() { x: pixel[0], y: pixel[1], }); - return result || null; + return (result as DeckPickResult) || null; } catch { return null; } }, [deckRef]); /** - * OpenLayers pointermove → 호버 툴팁 + * OpenLayers pointermove -> 호버 툴팁 */ - const handlePointerMove = useCallback((evt) => { + const handlePointerMove = useCallback((evt: MapBrowserEvent) => { // 드래그 중이면 무시 if (evt.dragging) { useShipStore.getState().setHoverInfo(null); @@ -177,7 +190,7 @@ export default function MapContainer() { const pixel = evt.pixel; const { clientX, clientY } = evt.originalEvent; - const pickResult = pickAny(pixel); + const pickResult = pickAny(pixel as unknown as number[]); if (!pickResult || !pickResult.layer) { // 아무것도 픽킹되지 않음 @@ -210,7 +223,7 @@ export default function MapContainer() { // 라이브 선박 if (layerId === 'ship-icon-layer') { - useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY }); + useShipStore.getState().setHoverInfo({ ship: obj as ShipFeature, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); @@ -220,7 +233,7 @@ export default function MapContainer() { // 항적조회 경로 (PathLayer) if (layerId === TRACK_QUERY_LAYER_IDS.PATH) { useShipStore.getState().setHoverInfo(null); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useTrackQueryStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; @@ -229,16 +242,17 @@ export default function MapContainer() { // 항적조회 포인트 (ScatterplotLayer) if (layerId === TRACK_QUERY_LAYER_IDS.POINTS) { useShipStore.getState().setHoverInfo(null); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useTrackQueryStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useReplayStore.getState().setHighlightedVesselId(null); // 포인트 호버 정보 설정 if (obj) { + const o = obj as Record; useTrackQueryStore.getState().setHoveredPoint({ - vesselId: obj.vesselId, - position: obj.position, - timestamp: obj.timestamp, - speed: obj.speed, - index: obj.index, + vesselId: o.vesselId as string, + position: o.position as [number, number], + timestamp: o.timestamp as number, + speed: o.speed as number, + index: o.index as number, }, clientX, clientY); } else { useTrackQueryStore.getState().clearHoveredPoint(); @@ -248,15 +262,16 @@ export default function MapContainer() { // 항적조회 가상 선박 아이콘 if (layerId === TRACK_QUERY_LAYER_IDS.VIRTUAL_SHIP) { + const o = obj as Record; const tooltipShip = { - shipName: obj.shipName, - targetId: obj.vesselId?.split('_').pop() || obj.vesselId, - signalKindCode: obj.shipKindCode, - sog: obj.speed || 0, - cog: obj.heading || 0, + shipName: o.shipName as string, + targetId: ((o.vesselId as string)?.split('_').pop() || o.vesselId) as string, + signalKindCode: o.shipKindCode as string, + sog: (o.speed as number) || 0, + cog: (o.heading as number) || 0, }; - useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useShipStore.getState().setHoverInfo({ ship: tooltipShip as ShipFeature, x: clientX, y: clientY }); + useTrackQueryStore.getState().setHighlightedVesselId((o.vesselId as string) || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; @@ -268,9 +283,9 @@ export default function MapContainer() { useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useAreaSearchStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useAreaSearchStore.getState().setAreaSearchTooltip( - obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null, + obj ? { vesselId: (obj as Record).vesselId as string, x: clientX, y: clientY } : null, ); return; } @@ -281,9 +296,9 @@ export default function MapContainer() { useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useAreaSearchStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useAreaSearchStore.getState().setAreaSearchTooltip( - obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null, + obj ? { vesselId: (obj as Record).vesselId as string, x: clientX, y: clientY } : null, ); return; } @@ -297,8 +312,8 @@ export default function MapContainer() { useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - // vesselId → 그룹 인덱스 매핑 (쌍 하이라이트) - const vesselId = obj?.vesselId; + // vesselId -> 그룹 인덱스 매핑 (쌍 하이라이트) + const vesselId = (obj as Record)?.vesselId as string | undefined; if (vesselId) { const groups = useStsStore.getState().groupedContacts; const groupIdx = groups.findIndex( @@ -316,23 +331,24 @@ export default function MapContainer() { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); - useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useReplayStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); return; } // 리플레이 가상 선박 아이콘 if (layerId === 'track-virtual-ship-layer') { + const o = obj as Record; const tooltipShip = { - shipName: obj.shipName, - targetId: obj.vesselId?.split('_').pop() || obj.vesselId, - signalKindCode: obj.shipKindCode, - sog: obj.speed || 0, - cog: obj.heading || 0, + shipName: o.shipName as string, + targetId: ((o.vesselId as string)?.split('_').pop() || o.vesselId) as string, + signalKindCode: o.shipKindCode as string, + sog: (o.speed as number) || 0, + cog: (o.heading as number) || 0, }; - useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); + useShipStore.getState().setHoverInfo({ ship: tooltipShip as ShipFeature, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); - useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useReplayStore.getState().setHighlightedVesselId((o.vesselId as string) || null); return; } @@ -346,11 +362,11 @@ export default function MapContainer() { }, [pickAny]); /** - * OpenLayers dblclick → 상세 모달 + * OpenLayers dblclick -> 상세 모달 */ - const handleDblClick = useCallback((evt) => { + const handleDblClick = useCallback((evt: MapBrowserEvent) => { const pixel = evt.pixel; - const ship = pickShip(pixel); + const ship = pickShip(pixel as unknown as number[]); if (ship) { evt.stopPropagation(); @@ -359,7 +375,7 @@ export default function MapContainer() { }, [pickShip]); /** - * pointerout → 툴팁 숨김 + 하이라이트 클리어 + * pointerout -> 툴팁 숨김 + 하이라이트 클리어 */ const handlePointerOut = useCallback(() => { useShipStore.getState().setHoverInfo(null); @@ -372,10 +388,10 @@ export default function MapContainer() { }, []); /** - * singleclick → 빈 영역 클릭 시 선택/메뉴 해제 + * singleclick -> 빈 영역 클릭 시 선택/메뉴 해제 */ - const handleSingleClick = useCallback((evt) => { - const ship = pickShip(evt.pixel); + const handleSingleClick = useCallback((evt: MapBrowserEvent) => { + const ship = pickShip(evt.pixel as unknown as number[]); if (!ship) { useShipStore.getState().clearSelectedShips(); useShipStore.getState().closeContextMenu(); @@ -386,7 +402,7 @@ export default function MapContainer() { useEffect(() => { if (!map) return; - map.on('pointermove', handlePointerMove); + map.on('pointermove', handlePointerMove as (evt: MapBrowserEvent) => void); map.on('dblclick', handleDblClick); map.on('singleclick', handleSingleClick); @@ -395,10 +411,10 @@ export default function MapContainer() { viewport.addEventListener('pointerout', handlePointerOut); // 우클릭 컨텍스트 메뉴 - const handleContextMenu = (e) => { + const handleContextMenu = (e: MouseEvent): void => { e.preventDefault(); const pixel = map.getEventPixel(e); - const ship = pickShip(pixel); + const ship = pickShip(pixel as unknown as number[]); const state = useShipStore.getState(); if (ship) { @@ -411,7 +427,7 @@ export default function MapContainer() { viewport.addEventListener('contextmenu', handleContextMenu); return () => { - map.un('pointermove', handlePointerMove); + map.un('pointermove', handlePointerMove as (evt: MapBrowserEvent) => void); map.un('dblclick', handleDblClick); map.un('singleclick', handleSingleClick); viewport.removeEventListener('pointerout', handlePointerOut); @@ -444,7 +460,7 @@ export default function MapContainer() { }); // 지도 인스턴스 생성 - const map = new Map({ + const olMap = new Map({ target: mapRef.current, layers: [ worldMap, @@ -471,7 +487,7 @@ export default function MapContainer() { // Ctrl+Drag 박스 선택 인터랙션 const dragBox = new DragBox({ condition: platformModifierKeyOnly }); - map.addInteraction(dragBox); + olMap.addInteraction(dragBox); dragBox.on('boxend', () => { const extent3857 = dragBox.getGeometry().getExtent(); @@ -482,7 +498,7 @@ export default function MapContainer() { nationalVisibility, darkSignalVisible } = state; // 국적 코드 매핑 (shipStore.js와 동일) - const mapNational = (code) => { + const mapNational = (code: string | undefined): string => { if (!code) return 'OTHER'; const c = code.toUpperCase(); if (c === 'KR' || c === 'KOR' || c === '440') return 'KR'; @@ -492,7 +508,7 @@ export default function MapContainer() { return 'OTHER'; }; - const matchedIds = []; + const matchedIds: string[] = []; features.forEach((ship, featureId) => { // 단독 레이더 제외 if (ship.signalSourceCode === '000005' && !ship.integrate) return; @@ -509,8 +525,8 @@ export default function MapContainer() { if (!nationalVisibility[mappedNational]) return; } - const lon = parseFloat(ship.longitude); - const lat = parseFloat(ship.latitude); + const lon = parseFloat(String(ship.longitude)); + const lat = parseFloat(String(ship.latitude)); if (lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat) { matchedIds.push(featureId); } @@ -520,24 +536,24 @@ export default function MapContainer() { }); // 줌 변경 이벤트 - map.getView().on('change:resolution', () => { - const zoom = Math.round(map.getView().getZoom()); + olMap.getView().on('change:resolution', () => { + const zoom = Math.round(olMap.getView().getZoom() ?? 7); setZoom(zoom); }); // 스토어에 맵 인스턴스 저장 - setMap(map); - mapInstanceRef.current = map; + setMap(olMap); + mapInstanceRef.current = olMap; // TrackQueryViewer 등에서 줌 감지용 - window.__mainMap__ = map; + (window as Window & { __mainMap__?: Map | null }).__mainMap__ = olMap; // 클린업 return () => { if (mapInstanceRef.current) { - mapInstanceRef.current.setTarget(null); + mapInstanceRef.current.setTarget(undefined); mapInstanceRef.current = null; } - window.__mainMap__ = null; + (window as Window & { __mainMap__?: Map | null }).__mainMap__ = null; }; }, []); diff --git a/src/map/ShipBatchRenderer.js b/src/map/ShipBatchRenderer.ts similarity index 80% rename from src/map/ShipBatchRenderer.js rename to src/map/ShipBatchRenderer.ts index 2d3997a3..7fecc1a7 100644 --- a/src/map/ShipBatchRenderer.js +++ b/src/map/ShipBatchRenderer.ts @@ -35,13 +35,21 @@ import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY, } from '../types/constants'; +import type { ShipFeature } from '../types/ship'; import useFavoriteStore from '../stores/favoriteStore'; // ===================== // 렌더링 설정 // 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙 // ===================== -const RENDER_CONFIG = { +interface RenderConfig { + defaultMinInterval: number; + maxInterval: number; + targetRenderTime: number; + maxRenderTime: number; +} + +const RENDER_CONFIG: RenderConfig = { defaultMinInterval: 1000, // 기본 최소 렌더링 간격 (1초) maxInterval: 5000, // 최대 렌더링 간격 (5초) targetRenderTime: 100, // 목표 렌더링 시간 (10fps 기준) @@ -52,7 +60,7 @@ const RENDER_CONFIG = { // 줌 레벨별 최소 렌더링 간격 // 낮은 줌 = 많은 선박 = 긴 간격 // ===================== -const ZOOM_MIN_INTERVAL = { +const ZOOM_MIN_INTERVAL: Record = { // zoom < 8: 광역 (전국/동아시아) 7: 4000, // zoom 8-9: 중광역 (해역) @@ -70,10 +78,8 @@ const ZOOM_MIN_INTERVAL = { /** * 줌 레벨에 따른 최소 렌더링 간격 반환 - * @param {number} zoom - 현재 줌 레벨 - * @returns {number} 최소 렌더링 간격 (ms) */ -function getMinIntervalByZoom(zoom) { +function getMinIntervalByZoom(zoom: number): number { const zoomInt = Math.floor(zoom); if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7]; @@ -90,7 +96,13 @@ function getMinIntervalByZoom(zoom) { // - 낮은 줌: 더 적은 개수 (밀집 지역 성능 최적화) // - 높은 줌: 더 많은 개수 (상세 보기) // ===================== -const DENSITY_LIMITS = [ +interface DensityConfig { + maxZoom: number; + maxPerCell: number; + gridSizeMultiplier: number; +} + +const DENSITY_LIMITS: DensityConfig[] = [ { maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 }, { maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 }, { maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 }, @@ -105,7 +117,7 @@ const DENSITY_LIMITS = [ * 밀도 제한 시 선박 우선순위 (낮을수록 높은 우선순위) * 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이 */ -const SHIP_KIND_PRIORITY = { +const SHIP_KIND_PRIORITY: Record = { [SIGNAL_KIND_CODE_KCGV]: 2, // 함정 [SIGNAL_KIND_CODE_GOV]: 3, // 관공선 [SIGNAL_KIND_CODE_PASSENGER]: 4, // 여객선 @@ -121,11 +133,8 @@ const PRIORITY_DEFAULT = 11; // 기본값 (최하위) /** * 선박 우선순위 점수 계산 - * @param {Object} ship - 선박 데이터 - * @param {Set} favoriteSet - 관심선박 ID Set - * @returns {number} 우선순위 점수 (낮을수록 높은 우선순위) */ -function getShipPriority(ship, favoriteSet) { +function getShipPriority(ship: ShipFeature, favoriteSet: Set | null): number { // 관심선박 체크 (최우선) const favoriteKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; if (favoriteSet && favoriteSet.has(favoriteKey)) { @@ -143,10 +152,8 @@ function getShipPriority(ship, favoriteSet) { /** * 줌레벨에 따른 밀도 설정 반환 - * @param {number} zoomLevel - 현재 줌 레벨 - * @returns {Object} 밀도 설정 { maxZoom, maxPerCell, gridSizeMultiplier } */ -function getDensityConfig(zoomLevel) { +function getDensityConfig(zoomLevel: number): DensityConfig { for (const config of DENSITY_LIMITS) { if (zoomLevel <= config.maxZoom) { return config; @@ -163,13 +170,8 @@ function getDensityConfig(zoomLevel) { * - 밀집 지역도 N척이 겹쳐 보여서 "빈 공간"처럼 보이지 않음 * - 줌레벨이 높아지면 제한이 완화되어 더 많은 선박 표시 * - 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이 - * - * @param {Array} ships - 필터링된 선박 데이터 - * @param {number} zoomLevel - 현재 줌레벨 - * @param {Set} favoriteSet - 관심선박 ID Set (optional) - * @returns {Array} 밀도 제한이 적용된 선박 데이터 */ -function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { +function applyDensityLimit(ships: ShipFeature[], zoomLevel: number, favoriteSet: Set | null = null): ShipFeature[] { const config = getDensityConfig(zoomLevel); // 제한 없음 설정이면 원본 반환 @@ -186,8 +188,8 @@ function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; // 그리드별 선박 수 카운트 - const gridCounts = new Map(); - const result = []; + const gridCounts = new Map(); + const result: ShipFeature[] = []; const len = sortedShips.length; for (let i = 0; i < len; i++) { @@ -216,17 +218,29 @@ function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { // 참조: mda-react-front/src/common/deck.ts (52-108) // ===================== +interface FilterCache { + enabledKinds: Set; + enabledSources: Set; + enabledNationals: Set; + isShipVisible: boolean; + isIntegrate: boolean; + darkSignalVisible: boolean; + darkSignalIds: Set; + dynamicPrioritySet: Set | null; + isFavoriteEnabled: boolean; + favoriteSet: Set; + favoriteTargetIds: Set | null; +} + /** * 필터 캐시 생성 * 렌더링 시작 시 1회 호출하여 Set 기반 O(1) lookup 가능하게 함 - * - * @returns {Object} 필터 캐시 객체 */ -function buildFilterCache() { +function buildFilterCache(): FilterCache { const { kindVisibility, sourceVisibility, nationalVisibility, isShipVisible, isIntegrate, darkSignalVisible, darkSignalIds, features } = useShipStore.getState(); // 활성화된 선종 코드 Set - const enabledKinds = new Set(); + const enabledKinds = new Set(); Object.entries(kindVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledKinds.add(code); @@ -234,7 +248,7 @@ function buildFilterCache() { }); // 활성화된 신호원 코드 Set - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledSources.add(code); @@ -242,7 +256,7 @@ function buildFilterCache() { }); // 활성화된 국적 코드 Set - const enabledNationals = new Set(); + const enabledNationals = new Set(); Object.entries(nationalVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledNationals.add(code); @@ -251,7 +265,7 @@ function buildFilterCache() { // 동적 우선순위 Set (통합 모드에서만 생성) // 참조: mda-react-front/docs/dynamic-priority.md §5 - let dynamicPrioritySet = null; + let dynamicPrioritySet: Set | null = null; if (isIntegrate) { dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); } @@ -260,9 +274,9 @@ function buildFilterCache() { const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); // 통합모드용: 관심선박이 포함된 통합그룹의 targetId Set - let favoriteTargetIds = null; + let favoriteTargetIds: Set | null = null; if (isFavoriteEnabled && favoriteSet.size > 0) { - favoriteTargetIds = new Set(); + favoriteTargetIds = new Set(); for (const ship of features.values()) { const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; if (favoriteSet.has(favKey)) { @@ -288,10 +302,8 @@ function buildFilterCache() { /** * 국적 코드 매핑 (실제 국적 코드 -> 필터 코드) - * @param {string} nationalCode - 선박의 국적 코드 - * @returns {string} 필터용 국적 코드 */ -function mapNationalCode(nationalCode) { +function mapNationalCode(nationalCode: string | undefined): string { if (!nationalCode) return 'OTHER'; const code = nationalCode.toUpperCase(); if (code === 'KR' || code === 'KOR' || code === '440') return 'KR'; @@ -304,16 +316,12 @@ function mapNationalCode(nationalCode) { /** * 캐시된 필터를 사용한 필터링 (O(1) lookup) * 참조: mda-react-front/src/common/deck.ts - applyFilterWithCache() - * - * @param {Object} ship - 선박 데이터 - * @param {Object} cache - 필터 캐시 - * @returns {boolean} 필터 통과 여부 */ -function applyFilterWithCache(ship, cache) { +function applyFilterWithCache(ship: ShipFeature, cache: FilterCache): boolean { // 전체 선박 표시 Off if (!cache.isShipVisible) return false; - // ⓪ 관심선박: 다른 모든 필터보다 우선 + // 0. 관심선박: 다른 모든 필터보다 우선 if (cache.isFavoriteEnabled && cache.favoriteTargetIds) { if (cache.isIntegrate) { // 통합모드: 이 통합그룹에 관심선박이 포함되어 있는가? @@ -331,11 +339,11 @@ function applyFilterWithCache(ship, cache) { } } - // ① 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조) + // 1. 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조) // 통합모드 체크보다 먼저 실행해야 통합 다크시그널 선박도 렌더링됨 if (cache.darkSignalIds.has(ship.featureId)) return cache.darkSignalVisible; - // ② 통합 모드 필터: 동적 우선순위 기반 대표 선박만 표시 + // 2. 통합 모드 필터: 동적 우선순위 기반 대표 선박만 표시 // 참조: mda-react-front/docs/dynamic-priority.md §5 if (cache.isIntegrate && cache.dynamicPrioritySet) { const targetId = ship.targetId; @@ -346,7 +354,7 @@ function applyFilterWithCache(ship, cache) { // 단독선박: 동적 우선순위 체크 스킵 } - // ③ 선종 필터 (Set.has = O(1)) + // 3. 선종 필터 (Set.has = O(1)) if (!cache.enabledKinds.has(ship.signalKindCode)) return false; // 신호원 필터 (Set.has = O(1)) @@ -359,15 +367,19 @@ function applyFilterWithCache(ship, cache) { return true; } +/** 뷰포트 범위 */ +interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + /** * 뷰포트 범위 내 선박 필터링 * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - filterFeaturesWithBounds() - * - * @param {Array} ships - 선박 배열 - * @param {Object} bounds - 뷰포트 범위 { minLon, maxLon, minLat, maxLat } - * @returns {Array} 범위 내 선박 배열 */ -function filterByViewport(ships, bounds) { +function filterByViewport(ships: ShipFeature[], bounds: ViewportBounds | null): ShipFeature[] { if (!bounds) return ships; const { minLon, maxLon, minLat, maxLat } = bounds; @@ -385,11 +397,18 @@ function filterByViewport(ships, bounds) { const LON_DEGREE_METERS = 91000; // 중위도(35도) 기준 경도 1도당 약 91km const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km +/** 반경 필터 상태 */ +interface RadiusFilterState { + isActive: boolean; + center: { lon: number; lat: number } | null; + radiusNM: number; + boundingBox: ViewportBounds | null; +} + /** * 반경 필터 상태 가져오기 - * @returns {Object} { isActive, center, radiusNM, boundingBox } */ -function getRadiusFilterState() { +function getRadiusFilterState(): RadiusFilterState { const state = useTrackingModeStore.getState(); const { mode, trackedShip, radiusNM } = state; @@ -406,7 +425,7 @@ function getRadiusFilterState() { const lonDelta = radiusMeters / LON_DEGREE_METERS; const latDelta = radiusMeters / LAT_DEGREE_METERS; - const boundingBox = { + const boundingBox: ViewportBounds = { minLon: center.lon - lonDelta, maxLon: center.lon + lonDelta, minLat: center.lat - latDelta, @@ -420,12 +439,8 @@ function getRadiusFilterState() { * 반경 필터링 적용 * - 1단계: Bounding Box 체크 (빠른 사각형 체크) * - 2단계: Haversine 거리 계산 (정확한 원형 체크) - * - * @param {Array} ships - 선박 배열 - * @param {Object} radiusState - 반경 필터 상태 - * @returns {Array} 반경 내 선박 배열 */ -function filterByRadius(ships, radiusState) { +function filterByRadius(ships: ShipFeature[], radiusState: RadiusFilterState): ShipFeature[] { const { isActive, center, radiusNM, boundingBox } = radiusState; if (!isActive || !center || !boundingBox) { @@ -456,7 +471,12 @@ function filterByRadius(ships, radiusState) { // ===================== const LIVE_COUNT_THROTTLE_MS = 5000; -let liveCountCache = { +interface LiveCountCache { + lastCalcTime: number; + lastFilterHash: string; +} + +const liveCountCache: LiveCountCache = { lastCalcTime: 0, lastFilterHash: '', }; @@ -465,7 +485,7 @@ let liveCountCache = { * 필터 해시 생성 (필터 변경 감지용) * 참조: mda-react-front/src/common/deck.ts (488-511) */ -function generateFilterHash() { +function generateFilterHash(): string { const { kindVisibility, sourceVisibility, nationalVisibility, isIntegrate, darkSignalVisible } = useShipStore.getState(); const kinds = Object.entries(kindVisibility).filter(([,v]) => v).map(([k]) => k).join(','); @@ -475,13 +495,20 @@ function generateFilterHash() { return `${kinds}|${sources}|${nationals}|${isIntegrate?1:0}|${darkSignalVisible?1:0}|${isFavoriteEnabled?1:0}|${favoriteSet.size}`; } +/** cleanup 결과 */ +interface CleanupResult { + kindCounts: Record; + darkSignalCount: number; + totalCount: number; + deleteIds: string[]; + darkSignalConvertIds: string[]; +} + /** * 단일 패스로 타임아웃 cleanup + 카운트를 동시 수행 * 참조: mda-react-front/src/common/deck.ts - calculateAndCleanupLiveShips - * - * @returns {{ kindCounts, darkSignalCount, totalCount, deleteIds, darkSignalConvertIds }} */ -function calculateAndCleanupLiveShips() { +function calculateAndCleanupLiveShips(): CleanupResult { const state = useShipStore.getState(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; @@ -489,30 +516,30 @@ function calculateAndCleanupLiveShips() { // 반경 필터 상태 const radiusState = getRadiusFilterState(); - const kindCounts = { ...initialKindCounts }; + const kindCounts: Record = { ...initialKindCounts }; let darkSignalCount = 0; - const deleteIds = []; - const darkSignalConvertIds = []; + const deleteIds: string[] = []; + const darkSignalConvertIds: string[] = []; const now = Date.now(); // enabledSources Set - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, on]) => { if (on) enabledSources.add(code); }); - const seenTargetIds = new Set(); - const bestByTargetId = new Map(); // 통합모드용 + const seenTargetIds = new Set(); + const bestByTargetId = new Map(); features.forEach((ship, featureId) => { - // ① 이미 다크시그널 → 카운트만, 즉시 리턴 + // 1. 이미 다크시그널 -> 카운트만, 즉시 리턴 if (darkSignalIds.has(featureId)) { // 반경 필터가 활성화된 경우, 반경 내에 있을 때만 카운트 if (radiusState.isActive) { if (!ship.longitude || !ship.latitude) return; - const inBounds = ship.longitude >= radiusState.boundingBox.minLon && - ship.longitude <= radiusState.boundingBox.maxLon && - ship.latitude >= radiusState.boundingBox.minLat && - ship.latitude <= radiusState.boundingBox.maxLat; - if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) { + const inBounds = ship.longitude >= radiusState.boundingBox!.minLon && + ship.longitude <= radiusState.boundingBox!.maxLon && + ship.latitude >= radiusState.boundingBox!.minLat && + ship.latitude <= radiusState.boundingBox!.maxLat; + if (!inBounds || !isWithinRadius(ship, radiusState.center!.lon, radiusState.center!.lat, radiusState.radiusNM)) { return; } } @@ -521,28 +548,28 @@ function calculateAndCleanupLiveShips() { } const sourceCode = ship.signalSourceCode; - const elapsed = now - ship.receivedTimestamp; + const elapsed = now - (ship.receivedTimestamp ?? 0); - // ② 레이더: 만료 시 삭제, 유효 시 fall-through + // 2. 레이더: 만료 시 삭제, 유효 시 fall-through if (sourceCode === SIGNAL_SOURCE_RADAR) { if (elapsed > RADAR_TIMEOUT_MS) { deleteIds.push(featureId); return; } - // 유효 레이더 → fall-through + // 유효 레이더 -> fall-through } else { - // ③ LOST=0 + INSHORE 타임아웃 → 삭제 + // 3. LOST=0 + INSHORE 타임아웃 -> 삭제 if (!ship.lost && elapsed > INSHORE_TIMEOUT_MS) { deleteIds.push(featureId); return; } - // ④ LOST=1 + OFFSHORE 타임아웃 → 다크시그널 전환 + // 4. LOST=1 + OFFSHORE 타임아웃 -> 다크시그널 전환 if (ship.lost && elapsed > OFFSHORE_TIMEOUT_MS) { darkSignalConvertIds.push(featureId); darkSignalCount++; return; } - // ⑤ 장비 전체 비활성 → 다크시그널 전환 + // 5. 장비 전체 비활성 -> 다크시그널 전환 if (!isAnyEquipmentActive(ship)) { darkSignalConvertIds.push(featureId); darkSignalCount++; @@ -550,26 +577,26 @@ function calculateAndCleanupLiveShips() { } } - // ⑥ 반경 필터 체크 (카운트 전) + // 6. 반경 필터 체크 (카운트 전) if (radiusState.isActive) { if (!ship.longitude || !ship.latitude) return; - const inBounds = ship.longitude >= radiusState.boundingBox.minLon && - ship.longitude <= radiusState.boundingBox.maxLon && - ship.latitude >= radiusState.boundingBox.minLat && - ship.latitude <= radiusState.boundingBox.maxLat; - if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) { + const inBounds = ship.longitude >= radiusState.boundingBox!.minLon && + ship.longitude <= radiusState.boundingBox!.maxLon && + ship.latitude >= radiusState.boundingBox!.minLat && + ship.latitude <= radiusState.boundingBox!.maxLat; + if (!inBounds || !isWithinRadius(ship, radiusState.center!.lon, radiusState.center!.lat, radiusState.radiusNM)) { return; } } - // ⑦ 카운트 대상 + // 7. 카운트 대상 const targetId = ship.targetId; const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate); if (isIntegrate && isIntegratedShip) { - // 통합모드 + 통합선박 (언더스코어 또는 integrate 플래그) → 후보 수집 + // 통합모드 + 통합선박 (언더스코어 또는 integrate 플래그) -> 후보 수집 const activeKey = SOURCE_TO_ACTIVE_KEY[sourceCode]; - if (!activeKey || ship[activeKey] !== '1') return; + if (!activeKey || (ship[activeKey] as string) !== '1') return; if (!enabledSources.has(sourceCode)) return; const rank = SOURCE_PRIORITY_RANK[sourceCode] ?? 99; @@ -611,11 +638,54 @@ function calculateAndCleanupLiveShips() { return { kindCounts, darkSignalCount, totalCount, deleteIds, darkSignalConvertIds }; } +/** 렌더링 콜백 타입 */ +type RenderCallback = (ships: ShipFeature[], trigger: number) => void; + +/** 렌더링 상태 */ +interface RenderState { + animationFrameId: ReturnType | number | null; + pendingRender: boolean; + isRendering: boolean; + lastRenderTime: number; + currentInterval: number; + currentZoom: number; +} + +/** 배치 렌더러 캐시 */ +interface BatchRendererCache { + filterCache: FilterCache | null; + lastFilterHash: string; + lastShipsData: ShipFeature[]; + lastFilteredCount: number; + lastRenderTrigger: number; + favoriteSet: Set | null; +} + +/** 렌더링 통계 */ +interface RenderStats { + currentZoom: number; + minInterval: number; + currentInterval: number; + lastRenderTime: number; + filteredCount: number; + renderedCount: number; + densityConfig: { + maxPerCell: number; + gridSizeMultiplier: number; + }; + renderTrigger: number; +} + /** * 선박 배치 렌더러 클래스 * 참조: mda-react-front/src/tracking/utils/ReplayBatchRenderer.ts */ class ShipBatchRenderer { + renderState: RenderState; + cache: BatchRendererCache; + onRenderCallback: RenderCallback | null; + viewportBounds: ViewportBounds | null; + constructor() { // 렌더링 상태 this.renderState = { @@ -634,7 +704,7 @@ class ShipBatchRenderer { lastShipsData: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전) lastRenderTrigger: 0, - favoriteSet: null, // (사용 안 함 — useFavoriteStore.getState() 직접 참조) + favoriteSet: null, // (사용 안 함 -- useFavoriteStore.getState() 직접 참조) }; // 외부 콜백 @@ -646,31 +716,24 @@ class ShipBatchRenderer { /** * 배치 렌더러 초기화 - * @param {Function} renderCallback - 레이어 렌더링 콜백 - * (ships, trigger) => void - * - ships: 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) - * - trigger: 렌더링 트리거 (주기적 갱신용) */ - initialize(renderCallback) { + initialize(renderCallback: RenderCallback): void { this.onRenderCallback = renderCallback; console.log('[ShipBatchRenderer] Initialized'); } /** * 뷰포트 범위 업데이트 - * @param {Object} bounds - { minLon, maxLon, minLat, maxLat } */ - setViewportBounds(bounds) { + setViewportBounds(bounds: ViewportBounds | null): void { this.viewportBounds = bounds; } /** * 줌 레벨 업데이트 * 줌 변경 시 최소 렌더링 간격도 재조정 - * @param {number} zoom - 현재 줌 레벨 - * @returns {boolean} 정수 줌 레벨이 변경되었는지 여부 */ - setZoom(zoom) { + setZoom(zoom: number): boolean { const prevZoom = this.renderState.currentZoom; const prevZoomInt = Math.floor(prevZoom); const newZoomInt = Math.floor(zoom); @@ -700,7 +763,7 @@ class ShipBatchRenderer { * 렌더링 요청 * 다음 렌더링 사이클에 처리됨 */ - requestRender() { + requestRender(): void { if (this.renderState.pendingRender) return; this.renderState.pendingRender = true; @@ -714,7 +777,7 @@ class ShipBatchRenderer { /** * 렌더링 스케줄링 */ - scheduleRender() { + scheduleRender(): void { const now = Date.now(); const elapsed = now - this.renderState.lastRenderTime; const delay = Math.max(0, this.renderState.currentInterval - elapsed); @@ -729,7 +792,7 @@ class ShipBatchRenderer { /** * 실제 렌더링 실행 */ - executeRender() { + executeRender(): void { if (this.renderState.isRendering || !this.onRenderCallback) { this.renderState.animationFrameId = null; return; @@ -762,12 +825,12 @@ class ShipBatchRenderer { // 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup) const filteredShips = radiusFilteredShips.filter((ship) => - applyFilterWithCache(ship, this.cache.filterCache) + applyFilterWithCache(ship, this.cache.filterCache!) ); // 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반) - // 관심선박 토글 ON → favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선) - // 관심선박 토글 OFF → null 전달 (일반 선종 우선순위 적용) + // 관심선박 토글 ON -> favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선) + // 관심선박 토글 OFF -> null 전달 (일반 선종 우선순위 적용) const zoom = this.renderState.currentZoom; const { isFavoriteEnabled, favoriteSet: currentFavoriteSet } = useFavoriteStore.getState(); const densityFavoriteSet = isFavoriteEnabled ? currentFavoriteSet : null; @@ -805,10 +868,8 @@ class ShipBatchRenderer { /** * 적응형 렌더링 간격 조정 * 줌 레벨에 따른 최소 간격 + 성능 기반 적응형 조정 - * - * @param {number} renderTime - 렌더링 소요 시간 (ms) */ - adjustRenderInterval(renderTime) { + adjustRenderInterval(renderTime: number): void { const { targetRenderTime, maxRenderTime, maxInterval } = RENDER_CONFIG; const minInterval = getMinIntervalByZoom(this.renderState.currentZoom); @@ -831,7 +892,7 @@ class ShipBatchRenderer { * 카운트 + 타임아웃 cleanup (5초 쓰로틀, 필터 변경 시 즉시) * 참조: mda-react-front/src/common/deck.ts - updateLayerData 내 카운트 로직 */ - updateLiveShipCountsThrottled() { + updateLiveShipCountsThrottled(): void { const now = Date.now(); const currentFilterHash = generateFilterHash(); const filterChanged = currentFilterHash !== liveCountCache.lastFilterHash; @@ -849,7 +910,7 @@ class ShipBatchRenderer { useShipStore.getState().applyCleanup(result.deleteIds, result.darkSignalConvertIds); } - // 카운트 업데이트 (값 비교 가드 — 불필요한 리렌더 방지) + // 카운트 업데이트 (값 비교 가드 -- 불필요한 리렌더 방지) const state = useShipStore.getState(); const prevKindCounts = state.kindCounts; const countsChanged = result.totalCount !== state.totalCount @@ -869,7 +930,7 @@ class ShipBatchRenderer { * 강제 렌더링 (필터 변경 등) * 일반 렌더링 주기에 따름 */ - forceRender() { + forceRender(): void { this.cache.filterCache = null; this.requestRender(); } @@ -878,11 +939,11 @@ class ShipBatchRenderer { * 즉시 렌더링 (필터/선명표시 토글 등 사용자 인터랙션) * 렌더링 주기를 무시하고 즉시 실행 */ - immediateRender() { + immediateRender(): void { // 기존 스케줄 취소 if (this.renderState.animationFrameId) { - clearTimeout(this.renderState.animationFrameId); - cancelAnimationFrame(this.renderState.animationFrameId); + clearTimeout(this.renderState.animationFrameId as ReturnType); + cancelAnimationFrame(this.renderState.animationFrameId as number); this.renderState.animationFrameId = null; } @@ -899,7 +960,7 @@ class ShipBatchRenderer { /** * 캐시 클리어 */ - clearCache() { + clearCache(): void { this.cache.filterCache = null; this.cache.lastFilterHash = ''; this.cache.lastShipsData = []; @@ -910,10 +971,10 @@ class ShipBatchRenderer { /** * 배치 렌더러 정리 */ - dispose() { + dispose(): void { if (this.renderState.animationFrameId) { - cancelAnimationFrame(this.renderState.animationFrameId); - clearTimeout(this.renderState.animationFrameId); + cancelAnimationFrame(this.renderState.animationFrameId as number); + clearTimeout(this.renderState.animationFrameId as ReturnType); this.renderState.animationFrameId = null; } this.clearCache(); @@ -923,17 +984,15 @@ class ShipBatchRenderer { /** * 마지막 필터링된 선박 데이터 반환 - * @returns {Array} 필터링된 선박 배열 */ - getFilteredShips() { + getFilteredShips(): ShipFeature[] { return this.cache.lastShipsData; } /** * 현재 렌더링 통계 반환 - * @returns {Object} 렌더링 통계 */ - getStats() { + getStats(): RenderStats { const zoom = this.renderState.currentZoom; const densityConfig = getDensityConfig(zoom); return { @@ -957,5 +1016,6 @@ export const shipBatchRenderer = new ShipBatchRenderer(); // 유틸리티 함수 export export { buildFilterCache, applyFilterWithCache, filterByViewport, applyDensityLimit, getDensityConfig }; +export type { FilterCache, ViewportBounds, DensityConfig, RenderStats }; export default ShipBatchRenderer; diff --git a/src/map/layers/baseLayer.js b/src/map/layers/baseLayer.ts similarity index 74% rename from src/map/layers/baseLayer.js rename to src/map/layers/baseLayer.ts index 6f95dc8c..ed62004b 100644 --- a/src/map/layers/baseLayer.js +++ b/src/map/layers/baseLayer.ts @@ -4,14 +4,31 @@ * - Phase 3에서 MapLibre GL JS 벡터맵으로 최종 전환 예정 */ import { XYZ, OSM } from 'ol/source'; +import type TileSource from 'ol/source/Tile'; import TileLayer from 'ol/layer/Tile'; const DARK_TILE_URL = 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'; +type BaseMapType = 'normal' | 'enc' | 'dark'; + +interface MapLayerConfig { + darkLayer: { + source: XYZ; + }; +} + +interface BaseLayers { + worldMap: TileLayer; + encMap: TileLayer; + darkMap: TileLayer; + eastAsiaMap: TileLayer; + korMap: TileLayer; +} + /** * 레이어 설정 (하위 모듈 호환용 export) */ -export const mapLayerConfig = { +export const mapLayerConfig: MapLayerConfig = { darkLayer: { source: new XYZ({ url: DARK_TILE_URL, @@ -24,7 +41,7 @@ export const mapLayerConfig = { * 베이스맵 레이어 생성 * @param {string} baseMapType - 배경지도 타입 ('normal' | 'enc' | 'dark') */ -export const createBaseLayers = (baseMapType = 'dark') => { +export const createBaseLayers = (baseMapType: BaseMapType = 'dark'): BaseLayers => { // OSM 기반 일반지도 const worldMap = new TileLayer({ source: new OSM(), diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.ts similarity index 77% rename from src/map/layers/shipLayer.js rename to src/map/layers/shipLayer.ts index 3ed64d27..de8a2420 100644 --- a/src/map/layers/shipLayer.js +++ b/src/map/layers/shipLayer.ts @@ -4,6 +4,7 @@ * 참조: mda-react-front/src/util/realTimeLayerUtil.ts */ import { IconLayer, TextLayer, ScatterplotLayer, LineLayer, PathLayer } from '@deck.gl/layers'; +import type { Layer } from '@deck.gl/core'; import { fromLonLat, toLonLat } from 'ol/proj'; import { ICON_ATLAS_MAPPING, @@ -13,6 +14,7 @@ import { SIGNAL_KIND_CODE_BUOY, SIGNAL_FLAG_CONFIGS, } from '../../types/constants'; +import type { ShipFeature } from '../../types/ship'; import useShipStore from '../../stores/shipStore'; import useFavoriteStore from '../../stores/favoriteStore'; import useTrackingModeStore from '../../stores/trackingModeStore'; @@ -23,9 +25,54 @@ import atlasImg from '../../assets/img/icon/atlas.png'; // 관심선박 강조 아이콘 import favShipIcon from '../../assets/images/ico_favship.svg'; +/** 속도벡터 라인 데이터 */ +interface SpeedVectorData { + sourcePosition: number[]; + targetPosition: number[]; + color: number[]; +} + +/** DIM 폴리곤 데이터 */ +interface DimPolygonData { + path: number[][]; + featureId: string; +} + +/** 신호 플래그 IconLayer 데이터 */ +interface SignalFlagData { + longitude: number; + latitude: number; + url: string; +} + +/** 신호 플래그 배열 항목 */ +interface FlagItem { + name: string; + color: string; + flag: string; +} + +/** buildFlagStateArray 반환 타입 */ +interface FlagStateResult { + key: string; + flagArray: FlagItem[]; +} + +/** 라벨 옵션 */ +interface LabelOptions { + showShipName?: boolean; + showSpeedVector?: boolean; + showShipSize?: boolean; + showSignalStatus?: boolean; +} + +/** 라벨 데이터 (ShipFeature + labelText) */ +interface LabelData extends ShipFeature { + labelText: string; +} + /** * 현재 테마 색상 가져오기 - * @returns {Object} 테마 색상 객체 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -33,15 +80,13 @@ function getCurrentThemeColors() { return THEME_COLORS[theme] || THEME_COLORS[THEME_TYPES.LIGHT]; } -// 추적 선박 아이콘 (인라인 SVG data URL) -const TRACKED_SHIP_SVG = ` - - - - -`; -const TRACKED_SHIP_ICON_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRACKED_SHIP_SVG)}`; - +// 추적 선박 아이콘 (인라인 SVG data URL) — 향후 추적 선박 시각화에 사용 예정 +// const TRACKED_SHIP_SVG = ` +// +// +// +// +// `; // ===================== // 도 → 라디안 변환 상수 // ===================== @@ -52,25 +97,37 @@ const DEG_TO_RAD = 0.0174533; // Math.PI / 180 // 줌 레벨 + isIntegrate 상태 + 렌더 트리거 기반 갱신 // 참조: mda-react-front/src/util/realTimeLayerUtil.ts (라인 327-363) // ===================== -const clusterCache = { +interface ClusterCache { + lastZoom: number | null; + lastDataLength: number; + lastIsIntegrate: boolean | null; + lastRenderTrigger: number; + clusteredData: ShipFeature[]; + positionHash: string; +} + +interface SignalClusterCache extends ClusterCache { + lastShipsLength: number; + lastShipsHash: string; +} + +const clusterCache: ClusterCache = { lastZoom: null, lastDataLength: 0, lastIsIntegrate: null, lastRenderTrigger: 0, clusteredData: [], - // 선박 위치 샘플 해시 (변경 감지용) positionHash: '', }; // 신호상태 레이어용 별도 캐시 -const signalClusterCache = { +const signalClusterCache: SignalClusterCache = { lastZoom: null, lastDataLength: 0, lastIsIntegrate: null, lastRenderTrigger: 0, clusteredData: [], positionHash: '', - // 밀도 제한된 ships 배열 변경 감지용 lastShipsLength: 0, lastShipsHash: '', }; @@ -86,16 +143,11 @@ const CLUSTER_GRID_SIZE_SIGNAL = 35; // 신호상태용 (더 조밀하게 표시 * 그리드 기반 클러스터링 (개선된 버전) * - 줌 레벨이 높을수록 더 많은 선박 표시 (점진적 증가) * - 낮은 줌에서 표시된 선박은 높은 줌에서도 반드시 표시 - * - * @param {Array} data - 선박 데이터 배열 - * @param {number} zoomLevel - 현재 줌 레벨 - * @param {number} gridSizeMultiplier - 그리드 크기 배율 (기본: 50) - * @returns {Array} 클러스터링된 선박 배열 */ -function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_LABEL) { +function clusterPoints(data: ShipFeature[], zoomLevel: number, gridSizeMultiplier: number = CLUSTER_GRID_SIZE_LABEL): ShipFeature[] { // 그리드 크기: 줌 레벨이 높을수록 작은 그리드 const gridSize = Math.pow(2, -zoomLevel) * gridSizeMultiplier; - const clusters = {}; + const clusters: Record = {}; // 1단계: 그리드별 선박 그룹화 const len = data.length; @@ -114,7 +166,7 @@ function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_L // 2단계: 각 그리드에서 대표 선박 선택 // 우선순위: 신호상태 있음 > 선박명 있음 > 첫 번째 const clusterKeys = Object.keys(clusters); - const result = []; + const result: ShipFeature[] = []; for (let i = 0; i < clusterKeys.length; i++) { const clusterShips = clusters[clusterKeys[i]]; @@ -147,10 +199,8 @@ function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_L /** * 선박 위치 해시 생성 (샘플링) * 처음/중간/마지막 선박 위치를 해시로 변환 - * @param {Array} ships - 선박 배열 - * @returns {string} 위치 해시 */ -function computePositionHash(ships) { +function computePositionHash(ships: ShipFeature[]): string { if (ships.length === 0) return ''; // 샘플링: 처음, 중간, 마지막 선박 위치 @@ -168,14 +218,8 @@ function computePositionHash(ships) { * 캐시된 클러스터링 결과 가져오기 * - 줌 레벨, isIntegrate 변경 시 재계산 * - 렌더 트리거 주기에 따라 갱신 (선박 위치 변경 반영) - * - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 (배치 렌더러에서 증가) - * @returns {Array} 클러스터링된 선박 배열 */ -function getClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { +function getClusteredShips(ships: ShipFeature[], zoom: number, isIntegrate: boolean, renderTrigger: number = 0): ShipFeature[] { const zoomInt = Math.floor(zoom); const positionHash = computePositionHash(ships); @@ -211,7 +255,7 @@ function getClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { /** * 클러스터 캐시 초기화 */ -export function clearClusterCache() { +export function clearClusterCache(): void { clusterCache.lastZoom = null; clusterCache.lastDataLength = 0; clusterCache.lastIsIntegrate = null; @@ -231,11 +275,8 @@ export function clearClusterCache() { /** * 실제로 신호상태 SVG가 생성 가능한지 체크 - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {boolean} SVG 생성 가능 여부 */ -function canGenerateSignalSVG(ship, isIntegrate) { +function canGenerateSignalSVG(ship: ShipFeature, isIntegrate: boolean): boolean { if (isIntegrate && isIntegratedShip(ship)) { // 통합선박 + 선박통합 ON: 장비 값이 '0' 또는 '1'인 것이 하나라도 있어야 함 return ship.ais === '0' || ship.ais === '1' || @@ -252,10 +293,8 @@ function canGenerateSignalSVG(ship, isIntegrate) { /** * ships 배열의 featureId 해시 생성 (배열 변경 감지용) - * @param {Array} ships - 선박 배열 - * @returns {string} featureId 기반 해시 */ -function computeShipsHash(ships) { +function computeShipsHash(ships: ShipFeature[]): string { if (ships.length === 0) return ''; // 처음, 1/4, 중간, 3/4, 마지막 선박의 featureId를 샘플링 const indices = [ @@ -271,14 +310,8 @@ function computeShipsHash(ships) { /** * 신호상태용 클러스터링 결과 가져오기 * 실제로 SVG가 생성 가능한 선박만 대상으로 클러스터링 - * - * @param {Array} ships - 필터링된 선박 데이터 배열 (지도에 그려지는 선박) - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 - * @returns {Array} 클러스터링된 선박 배열 */ -function getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { +function getSignalClusteredShips(ships: ShipFeature[], zoom: number, isIntegrate: boolean, renderTrigger: number = 0): ShipFeature[] { const zoomInt = Math.floor(zoom); // ships 배열 변경 감지용 해시 (밀도 제한 결과가 달라지면 캐시 무효화) @@ -334,10 +367,8 @@ const VECTOR_INCREMENT_SCALE = 40; // 속도 1kn 증가당 추가 길이 /** * 선박 아이콘 결정 - * @param {Object} ship - 선박 데이터 - * @returns {string} 아이콘 이름 (ICON_ATLAS_MAPPING 키) */ -export function getShipIcon(ship, darkSignalIds) { +export function getShipIcon(ship: ShipFeature, darkSignalIds: Set | null): string { // 다크시그널(소실신호): darkSignalIds Set으로 판단 if (darkSignalIds && darkSignalIds.has(ship.featureId)) { return 'lostShipImg'; @@ -356,11 +387,8 @@ export function getShipIcon(ship, darkSignalIds) { /** * 선박 회전 각도 계산 (COG 기준) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - targetAngle() - * - * @param {Object} ship - 선박 데이터 - * @returns {number} 회전 각도 (degrees) */ -export function getShipAngle(ship) { +export function getShipAngle(ship: ShipFeature): number { // 부이는 회전하지 않음 if (ship.signalKindCode === SIGNAL_KIND_CODE_BUOY) { return 0; @@ -374,12 +402,8 @@ export function getShipAngle(ship) { /** * 선박 크기 결정 (운항/정박 상태 + 줌 레벨) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - targetScale() - * - * @param {Object} ship - 선박 데이터 - * @param {number} zoom - 현재 줌 레벨 - * @returns {number} 아이콘 크기 (px) */ -export function getShipSize(ship, zoom) { +export function getShipSize(ship: ShipFeature, zoom: number): number { // 부이: 항상 16px if (ship.signalKindCode === SIGNAL_KIND_CODE_BUOY) { return 16; @@ -405,12 +429,9 @@ export function getShipSize(ship, zoom) { /** * 선박 IconLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {IconLayer} Deck.gl IconLayer */ -export function createShipIconLayer(ships, zoom = 10, darkSignalIds = null) { - return new IconLayer({ +export function createShipIconLayer(ships: ShipFeature[], zoom: number = 10, darkSignalIds: Set | null = null): IconLayer { + return new IconLayer({ id: 'ship-icon-layer', data: ships, pickable: true, @@ -439,11 +460,8 @@ export function createShipIconLayer(ships, zoom = 10, darkSignalIds = null) { * 벡터 길이 계산: * - 최소 길이(VECTOR_MIN_LENGTH) + 속도 초과분 * 증가율(VECTOR_INCREMENT_SCALE) * - SPEED_THRESHOLD(1kn) 기준으로 항해중 아이콘과 함께 표시 - * - * @param {Array} ships - 선박 데이터 배열 - * @returns {Array} 라인 데이터 [{ sourcePosition, targetPosition, color }] */ -function buildSpeedVectorData(ships) { +function buildSpeedVectorData(ships: ShipFeature[]): SpeedVectorData[] { // 테마 기반 색상 const themeColors = getCurrentThemeColors(); const vectorColor = themeColors.speedVector; @@ -475,18 +493,15 @@ function buildSpeedVectorData(ships) { return { sourcePosition: toLonLat([projX, projY]), targetPosition: toLonLat([projX + xAdd, projY + yAdd]), - color: vectorColor, + color: vectorColor as number[], }; }); } /** * 속도벡터 LineLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {LineLayer|null} Deck.gl LineLayer */ -export function createSpeedVectorLayer(ships, zoom) { +export function createSpeedVectorLayer(ships: ShipFeature[], zoom: number): LineLayer | null { // 줌 9 이상에서만 표시 if (zoom < 9) { return null; @@ -497,13 +512,13 @@ export function createSpeedVectorLayer(ships, zoom) { return null; } - return new LineLayer({ + return new LineLayer({ id: 'speed-vector-layer', data: vectorData, pickable: false, - getSourcePosition: (d) => d.sourcePosition, - getTargetPosition: (d) => d.targetPosition, - getColor: (d) => d.color, + getSourcePosition: (d) => d.sourcePosition as [number, number], + getTargetPosition: (d) => d.targetPosition as [number, number], + getColor: (d) => d.color as [number, number, number, number], getWidth: 2, widthMinPixels: 1, widthMaxPixels: 3, @@ -521,23 +536,12 @@ const DIM_POLYGON_MIN_ZOOM = 14; /** * 선박 크기 폴리곤 계산 (dimABCD 기준) * 참조점(안테나 위치)을 기준으로 선박 형태 폴리곤 생성 - * - * dimA: 참조점 → 뱃머리(bow) 거리 - * dimB: 참조점 → 선미(stern) 거리 - * dimC: 참조점 → 좌현(port) 거리 - * dimD: 참조점 → 우현(starboard) 거리 - * - * @param {number} projX - 투영좌표 X (선박 위치) - * @param {number} projY - 투영좌표 Y (선박 위치) - * @param {number} dimA - 뱃머리 거리 (m) - * @param {number} dimB - 선미 거리 (m) - * @param {number} dimC - 좌현 거리 (m) - * @param {number} dimD - 우현 거리 (m) - * @param {number} angleS - sin(360 - COG) - * @param {number} angleC - cos(360 - COG) - * @returns {Array} 경위도 좌표 배열 (6점 폴리곤) */ -function calDimension(projX, projY, dimA, dimB, dimC, dimD, angleS, angleC) { +function calDimension( + projX: number, projY: number, + dimA: number, dimB: number, dimC: number, dimD: number, + angleS: number, angleC: number, +): number[][] { // 좌상단 (좌현, 뱃머리 3/4) let leftTopX = -1 * dimC; let leftTopY = (dimA * 3) / 4; @@ -592,16 +596,12 @@ function calDimension(projX, projY, dimA, dimB, dimC, dimD, angleS, angleC) { /** * 선박 크기 폴리곤 계산 (길이/너비만 있는 경우) * 중심점 기준으로 사각형 + 선수 형태 생성 - * - * @param {number} projX - 투영좌표 X (선박 중심) - * @param {number} projY - 투영좌표 Y (선박 중심) - * @param {number} length - 선박 총 길이 (m) - * @param {number} width - 선박 총 너비 (m) - * @param {number} angleS - sin(360 - COG) - * @param {number} angleC - cos(360 - COG) - * @returns {Array} 경위도 좌표 배열 (6점 폴리곤) */ -function calDimensionCentered(projX, projY, length, width, angleS, angleC) { +function calDimensionCentered( + projX: number, projY: number, + length: number, width: number, + angleS: number, angleC: number, +): number[][] { // 중심 기준이므로 dimA = dimB = length/2, dimC = dimD = width/2 const dimA = length / 2; // 뱃머리 const dimB = length / 2; // 선미 @@ -614,12 +614,9 @@ function calDimensionCentered(projX, projY, length, width, angleS, angleC) { /** * 선박 DIM 폴리곤 데이터 생성 * dimABCD 우선, 없으면 길이/너비로 중심 기준 폴리곤 생성 - * - * @param {Array} ships - 뷰포트 내 선박 배열 - * @returns {Array} 폴리곤 경로 데이터 */ -function buildDimPolygonData(ships) { - const result = []; +function buildDimPolygonData(ships: ShipFeature[]): DimPolygonData[] { + const result: DimPolygonData[] = []; for (const ship of ships) { const dimA = Number(ship.dimA) || 0; @@ -637,7 +634,7 @@ function buildDimPolygonData(ships) { const angleS = Math.sin(((360 - cog) * Math.PI) / 180.0); const angleC = Math.cos(((360 - cog) * Math.PI) / 180.0); - let path = null; + let path: number[][] | null = null; // 케이스 1: dimABCD가 모두 있는 경우 (참조점 기준) if (dimA > 0) { @@ -672,12 +669,8 @@ function buildDimPolygonData(ships) { /** * 선박크기 PathLayer 생성 * 줌 레벨 11 이상에서만 렌더링 - * - * @param {Array} ships - 뷰포트 내 선박 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {PathLayer|null} Deck.gl PathLayer */ -export function createShipDimLayer(ships, zoom) { +export function createShipDimLayer(ships: ShipFeature[], zoom: number): PathLayer | null { // 줌 레벨 11 미만이면 null 반환 if (zoom < DIM_POLYGON_MIN_ZOOM) { return null; @@ -692,15 +685,16 @@ export function createShipDimLayer(ships, zoom) { // 테마 기반 색상 const themeColors = getCurrentThemeColors(); - return new PathLayer({ + return new PathLayer({ id: 'ship-dim-layer', data: dimData, pickable: false, widthScale: 1, widthMinPixels: 1, widthMaxPixels: 3, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry getPath: (d) => d.path, - getColor: themeColors.shipDim, + getColor: themeColors.shipDim as [number, number, number, number], getWidth: 2, jointRounded: true, capRounded: true, @@ -713,15 +707,13 @@ export function createShipDimLayer(ships, zoom) { // ===================== // SVG 캐시 맵 -const flagSvgCache = new Map(); +const flagSvgCache = new Map(); /** * 통합선박 여부 판별 - * @param {Object} ship - 선박 객체 - * @returns {boolean} 통합선박 여부 */ -function isIntegratedShip(ship) { - return ship.targetId && (ship.targetId.includes('_') || ship.integrate); +function isIntegratedShip(ship: ShipFeature): boolean { + return !!ship.targetId && (ship.targetId.includes('_') || ship.integrate); } /** @@ -729,11 +721,10 @@ function isIntegratedShip(ship) { * 동일 targetId를 공유하는 모든 feature의 장비 플래그를 합산 * 예: 레이더 feature(vtsRadar='1') + AIS feature(ais='1') → { ais:'1', vtsRadar:'1' } * '1'(활성) > '0'(비활성) > ''(없음) 우선순위로 병합 - * @returns {Map} targetId → 병합된 장비 플래그 */ -function buildMergedEquipmentFlags() { +function buildMergedEquipmentFlags(): Map> { const { features } = useShipStore.getState(); - const map = new Map(); + const map = new Map>(); features.forEach((ship) => { const targetId = ship.targetId; @@ -742,7 +733,7 @@ function buildMergedEquipmentFlags() { const existing = map.get(targetId) || {}; for (const config of SIGNAL_FLAG_CONFIGS) { const key = config.dataKey; - const val = ship[key]; + const val = ship[key] as string | undefined; // '1'이면 무조건 설정, '0'은 기존이 '1'이 아닐 때만 if (val === '1') { existing[key] = '1'; @@ -759,22 +750,10 @@ function buildMergedEquipmentFlags() { /** * 신호 상태 배열 생성 (캐시 키 + SVG 생성용) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - * - * 선박통합 ON + 통합선박 (TARGET_ID에 '_' 포함): - * - '1' = 장비 존재 + 활성 (activeColor) - * - '0' = 장비 존재 + 비활성 (inactiveColor/회색) - * - '' = 장비 없음 (표시 안함) - * - * 선박통합 OFF 또는 단독선박: - * - 현재 signalSourceCode에 해당하는 장비만 표시 (항상 활성 색상) - * - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {Object} { key, flagArray } */ -function buildFlagStateArray(ship, isIntegrate) { - const keyParts = []; - const flagArray = []; +function buildFlagStateArray(ship: ShipFeature, isIntegrate: boolean): FlagStateResult { + const keyParts: string[] = []; + const flagArray: FlagItem[] = []; // 선박통합 ON이고 통합선박인 경우에만 통합 모드로 처리 const useIntegratedMode = isIntegrate && isIntegratedShip(ship); @@ -786,11 +765,8 @@ function buildFlagStateArray(ship, isIntegrate) { if (useIntegratedMode) { // 통합선박 + 선박통합 ON: 장비 값 확인 - const dataValue = ship[config.dataKey]; + const dataValue = ship[config.dataKey] as string | undefined; - // '' 또는 undefined → 장비 없음 (표시 안함) - // '0' → 장비 존재, 비활성 (회색) - // '1' → 장비 존재, 활성 (색상) if (dataValue === '1') { isVisible = true; isActive = true; @@ -800,7 +776,6 @@ function buildFlagStateArray(ship, isIntegrate) { isActive = false; color = config.inactiveColor; } - // dataValue가 '' 또는 undefined면 isVisible = false 유지 } else { // 선박통합 OFF 또는 단독선박: 현재 신호원만 표시 (항상 활성 색상) if (config.signalSourceCode === ship.signalSourceCode) { @@ -830,10 +805,8 @@ function buildFlagStateArray(ship, isIntegrate) { /** * 신호 플래그 SVG 생성 * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - createFlagLabelSVG() - * @param {Array} arr - 플래그 배열 - * @returns {string} SVG 문자열 */ -function createFlagLabelSVG(arr) { +function createFlagLabelSVG(arr: FlagItem[]): string { const filteredArr = arr.filter((v) => v.flag !== ''); if (filteredArr.length === 0) { return ''; @@ -863,20 +836,15 @@ function createFlagLabelSVG(arr) { /** * SVG를 Data URI로 변환 - * @param {string} svgStr - SVG 문자열 - * @returns {string} Data URI */ -function svgToDataURI(svgStr) { +function svgToDataURI(svgStr: string): string { return `data:image/svg+xml;charset=utf8,${encodeURIComponent(svgStr)}`; } /** * 캐시된 신호 플래그 SVG 가져오기 - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {string} SVG 문자열 */ -function getCachedFlagSVG(ship, isIntegrate) { +function getCachedFlagSVG(ship: ShipFeature, isIntegrate: boolean): string { const { key, flagArray } = buildFlagStateArray(ship, isIntegrate); let svg = flagSvgCache.get(key); @@ -893,33 +861,27 @@ function getCachedFlagSVG(ship, isIntegrate) { /** * SVG 캐시 초기화 */ -export function clearFlagSvgCache() { +export function clearFlagSvgCache(): void { flagSvgCache.clear(); } /** * 신호상태 IconLayer 생성 (SVG 캐싱 사용) * 참조: mda-react-front/src/common/deck.ts (라인 757-771) - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {IconLayer|null} Deck.gl IconLayer */ -export function createSignalStatusLayer(ships, zoom, isIntegrate) { +export function createSignalStatusLayer(ships: ShipFeature[], zoom: number, isIntegrate: boolean): IconLayer | null { if (zoom < 9) { return null; } // 통합모드: 동일 targetId의 모든 feature에서 장비 플래그 병합 - // 대표 feature(예: 레이더)에는 자기 장비 플래그만 있으므로, - // 같은 targetId를 공유하는 다른 feature(AIS 등)의 플래그를 합쳐야 함 - let mergedFlagsMap = null; + let mergedFlagsMap: Map> | null = null; if (isIntegrate) { mergedFlagsMap = buildMergedEquipmentFlags(); } // 신호 플래그 데이터 생성 (SVG 캐싱 적용) - const flagData = ships + const flagData: SignalFlagData[] = ships .map((ship) => { // 통합선박이면 병합된 장비 플래그 적용 let effectiveShip = ship; @@ -939,13 +901,13 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) { url: svg, }; }) - .filter((d) => d !== null); + .filter((d): d is SignalFlagData => d !== null); if (flagData.length === 0) { return null; } - return new IconLayer({ + return new IconLayer({ id: 'signal-status-layer', data: flagData, pickable: false, @@ -968,12 +930,9 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) { /** * 선박 라벨 텍스트 생성 - * @param {Object} ship - 선박 데이터 - * @param {Object} labelOptions - 라벨 옵션 - * @returns {string} 라벨 텍스트 */ -function buildLabelText(ship, labelOptions) { - const parts = []; +function buildLabelText(ship: ShipFeature, labelOptions: LabelOptions): string { + const parts: string[] = []; // 선박명 if (labelOptions.showShipName && ship.shipName) { @@ -994,17 +953,13 @@ function buildLabelText(ship, labelOptions) { /** * 선박명 TextLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {Object} labelOptions - 라벨 옵션 - * @returns {TextLayer|null} Deck.gl TextLayer */ -export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: true }) { +export function createShipLabelLayer(ships: ShipFeature[], zoom: number, labelOptions: LabelOptions = { showShipName: true }): TextLayer | null { if (zoom < 9) { return null; } - const labelData = ships + const labelData: LabelData[] = ships .map((ship) => ({ ...ship, labelText: buildLabelText(ship, labelOptions), @@ -1020,7 +975,7 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: // 테마 기반 색상 const themeColors = getCurrentThemeColors(); - return new TextLayer({ + return new TextLayer({ id: 'ship-label-layer', data: labelData, pickable: false, @@ -1043,13 +998,11 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: /** * 선택된 선박 하이라이트 레이어 (다중 선박 지원) - * @param {Array} selectedShips - 선택된 선박 데이터 배열 - * @returns {ScatterplotLayer|null} Deck.gl 레이어 */ -export function createSelectedShipLayer(selectedShips) { +export function createSelectedShipLayer(selectedShips: ShipFeature[] | null): ScatterplotLayer | null { if (!selectedShips || selectedShips.length === 0) return null; - return new ScatterplotLayer({ + return new ScatterplotLayer({ id: 'selected-ship-layer', data: selectedShips, pickable: false, @@ -1070,10 +1023,8 @@ export function createSelectedShipLayer(selectedShips) { * 추적 선박 레이어 (최상단 표시) * 선박 모드에서 추적 중인 함정을 특별 아이콘으로 표시 * ScatterplotLayer로 원형 마커 + IconLayer로 아이콘 표시 - * @param {number} zoom - 현재 줌 레벨 - * @returns {Array} Deck.gl 레이어 배열 */ -export function createTrackedShipLayers(zoom) { +export function createTrackedShipLayers(_zoom: number): Layer[] { const { mode, trackedShip } = useTrackingModeStore.getState(); // 선박 모드가 아니거나 추적 중인 함정이 없으면 빈 배열 @@ -1081,13 +1032,13 @@ export function createTrackedShipLayers(zoom) { return []; } - const layers = []; + const layers: Layer[] = []; // 위치 기반 업데이트 트리거 (좌표 변경 시 레이어 갱신) const positionKey = `${trackedShip.longitude.toFixed(6)}_${trackedShip.latitude.toFixed(6)}`; // 1. 외곽 원형 마커 (강조 효과) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-outer-ring', data: [trackedShip], pickable: false, @@ -1107,7 +1058,7 @@ export function createTrackedShipLayers(zoom) { })); // 2. 내부 원형 마커 (반투명) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-inner-circle', data: [trackedShip], pickable: false, @@ -1125,7 +1076,7 @@ export function createTrackedShipLayers(zoom) { })); // 3. 중심점 (흰색) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-center-dot', data: [trackedShip], pickable: true, @@ -1147,18 +1098,15 @@ export function createTrackedShipLayers(zoom) { /** * 관심선박 매칭 데이터 추출 - * @param {Array} ships - 밀도 제한된 선박 배열 - * @param {boolean} isIntegrate - 통합모드 여부 - * @returns {Array} 관심선박 배열 */ -function getFavoriteShips(ships, isIntegrate) { +function getFavoriteShips(ships: ShipFeature[], isIntegrate: boolean): ShipFeature[] { const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); if (!isFavoriteEnabled || favoriteSet.size === 0) return []; // 통합모드: 관심선박이 속한 통합그룹의 targetId Set - let favoriteTargetIds = null; + let favoriteTargetIds: Set | null = null; if (isIntegrate) { - favoriteTargetIds = new Set(); + favoriteTargetIds = new Set(); const { features } = useShipStore.getState(); for (const ship of features.values()) { const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; @@ -1170,7 +1118,7 @@ function getFavoriteShips(ships, isIntegrate) { return ships.filter((ship) => { if (isIntegrate) { - return favoriteTargetIds.has(ship.targetId) && (!ship.integrate || ship.isPriority); + return favoriteTargetIds!.has(ship.targetId) && (!ship.integrate || ship.isPriority); } const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; return favoriteSet.has(favKey); @@ -1180,17 +1128,13 @@ function getFavoriteShips(ships, isIntegrate) { /** * 관심선박 강조 레이어 생성 (배경원 + 아이콘) * 참조: mda-react-front/src/common/targetLayer.ts - deckFavoriteLayer - * - * @param {Array} ships - 밀도 제한된 선박 배열 - * @param {boolean} isIntegrate - 통합모드 여부 - * @returns {Array} [ScatterplotLayer, IconLayer] 또는 빈 배열 */ -function createFavoriteHighlightLayers(ships, isIntegrate) { +function createFavoriteHighlightLayers(ships: ShipFeature[], isIntegrate: boolean): Layer[] { const favoriteShips = getFavoriteShips(ships, isIntegrate); if (favoriteShips.length === 0) return []; // 배경 원 (반투명 노란색 — 선박 아이콘 뒤에서 강조) - const bgLayer = new ScatterplotLayer({ + const bgLayer = new ScatterplotLayer({ id: 'favorite-bg-layer', data: favoriteShips, pickable: false, @@ -1208,7 +1152,7 @@ function createFavoriteHighlightLayers(ships, isIntegrate) { }); // 별+선박 아이콘 (우상단 오프셋) - const iconLayer = new IconLayer({ + const iconLayer = new IconLayer({ id: 'favorite-icon-layer', data: favoriteShips, pickable: false, @@ -1238,18 +1182,18 @@ function createFavoriteHighlightLayers(ships, isIntegrate) { * 2. 아이콘 레이어 생성 (밀도 제한된 ships 사용) * 3. 라벨 클러스터링 (밀도 제한된 ships 대상) → 라벨 표시 대상 결정 * 4. 라벨/신호상태 레이어 생성 - * - * @param {Array} ships - 선박 데이터 (밀도 제한 적용됨, 아이콘 + 라벨 공통) - * @param {Object|null} selectedShip - 선택된 선박 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} showLabels - 선명표시 여부 - * @param {Object} labelOptions - 선명표시 옵션 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 (배치 렌더러에서 증가) - * @returns {Array} Deck.gl 레이어 배열 */ -export function createShipLayers(ships, selectedShips, zoom, showLabels = false, labelOptions = { showShipName: true }, isIntegrate = true, renderTrigger = 0, darkSignalIds = null) { - const layers = []; +export function createShipLayers( + ships: ShipFeature[], + selectedShips: ShipFeature[] | null, + zoom: number, + showLabels: boolean = false, + labelOptions: LabelOptions = { showShipName: true }, + isIntegrate: boolean = true, + renderTrigger: number = 0, + darkSignalIds: Set | null = null, +): Layer[] { + const layers: Layer[] = []; // 1. 선택된 선박 하이라이트 (다중) const selectedLayer = createSelectedShipLayer(selectedShips); diff --git a/src/map/layers/trackLayer.js b/src/map/layers/trackLayer.ts similarity index 72% rename from src/map/layers/trackLayer.js rename to src/map/layers/trackLayer.ts index af408e1f..6d698375 100644 --- a/src/map/layers/trackLayer.js +++ b/src/map/layers/trackLayer.ts @@ -11,14 +11,68 @@ * - TextLayer: 선명 라벨 */ import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; import { getShipKindTrackColor } from '../../stores/trackStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; import { ICON_ATLAS_MAPPING, ICON_MAPPING_KIND_MOVING, } from '../../types/constants'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; import atlasImg from '../../assets/img/icon/atlas.png'; +/** PathLayer 데이터 */ +interface PathData { + path: number[][]; + color: number[]; + vesselId: string; +} + +/** ScatterplotLayer 포인트 데이터 */ +interface PointData { + position: number[]; + color: number[]; +} + +/** 가상선박 현재 위치 데이터 */ +interface CurrentPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipName: string; + shipKindCode: string; +} + +/** 레이어 ID 오버라이드 */ +interface LayerIds { + path?: string; + point?: string; + icon?: string; + label?: string; +} + +/** 정적 항적 레이어 파라미터 */ +interface StaticTrackLayerParams { + tracks: ProcessedTrack[]; + showPoints: boolean; + highlightedVesselId?: string | null; + highlightedVesselIds?: Set | null; + onPathHover?: (vesselId: string | null) => void; + layerIds?: LayerIds; +} + +/** 동적 가상선박 레이어 파라미터 */ +interface VirtualShipLayerParams { + currentPositions: CurrentPosition[]; + showVirtualShip: boolean; + showLabels: boolean; + onIconHover?: (obj: CurrentPosition | null, x: number, y: number) => void; + onPathHover?: (vesselId: string | null) => void; + layerIds?: LayerIds; +} + /** 현재 테마 색상 가져오기 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -33,24 +87,17 @@ const MAX_POINTS_PER_TRACK = 800; * 정적 항적 레이어 생성 * tracks, showPoints, disabledVesselIds가 변경될 때만 호출 * currentTime과 무관 - 전체 항적 데이터를 항상 표시 - * - * @param {Object} params - * @param {Array} params.tracks - 항적 데이터 배열 - * @param {boolean} params.showPoints - 포인트 표시 여부 - * @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID (단일) - * @param {Set} [params.highlightedVesselIds] - 하이라이트할 선박 ID 집합 (복수) - * @param {Function} [params.onPathHover] - 항적 호버 콜백 */ -export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, highlightedVesselIds, onPathHover, layerIds }) { - const layers = []; +export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, highlightedVesselIds, onPathHover, layerIds }: StaticTrackLayerParams): Layer[] { + const layers: Layer[] = []; if (!tracks || tracks.length === 0) return layers; const pathId = layerIds?.path || 'track-path-layer'; const pointId = layerIds?.point || 'track-point-layer'; - const isHighlighted = (vesselId) => - highlightedVesselIds?.has(vesselId) || - (highlightedVesselId && highlightedVesselId === vesselId); + const isHighlighted = (vesselId: string): boolean => + (highlightedVesselIds?.has(vesselId) ?? false) || + (!!highlightedVesselId && highlightedVesselId === vesselId); // Set을 직렬화하여 Deck.gl updateTriggers가 변경을 감지하도록 const highlightKey = highlightedVesselIds @@ -58,17 +105,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI : null; // 1. PathLayer - 전체 경로 (시간 무관) - const pathData = tracks.map((track) => ({ + const pathData: PathData[] = tracks.map((track) => ({ path: track.geometry, color: getShipKindTrackColor(track.shipKindCode), vesselId: track.vesselId, })); layers.push( - new PathLayer({ + new PathLayer({ id: pathId, data: pathData, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry getPath: (d) => d.path, + // @ts-expect-error Deck.gl runtime accepts number[] for Color getColor: (d) => isHighlighted(d.vesselId) ? [255, 255, 0, 255] : d.color, getWidth: (d) => isHighlighted(d.vesselId) ? 4 : 2, widthUnits: 'pixels', @@ -79,7 +128,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI pickable: true, autoHighlight: true, highlightColor: [255, 255, 0, 220], - onHover: (info) => { + onHover: (info: PickingInfo) => { if (onPathHover) { onPathHover(info.object?.vesselId ?? null); } @@ -93,7 +142,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI // 2. ScatterplotLayer - 포인트 (간인 적용) if (showPoints) { - const pointData = []; + const pointData: PointData[] = []; tracks.forEach((track) => { const color = getShipKindTrackColor(track.shipKindCode); @@ -118,11 +167,11 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI }); layers.push( - new ScatterplotLayer({ + new ScatterplotLayer({ id: pointId, data: pointData, - getPosition: (d) => d.position, - getFillColor: (d) => d.color, + getPosition: (d) => d.position as [number, number], + getFillColor: (d) => d.color as [number, number, number, number], getRadius: 3, radiusUnits: 'pixels', radiusMinPixels: 2, @@ -138,17 +187,9 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI /** * 동적 가상선박 레이어 생성 * currentTime 변경 시마다 호출 (경량 - 데이터 수 = 선박 수) - * - * @param {Object} params - * @param {Array} params.currentPositions - 보간된 현재 위치 배열 - * @param {boolean} params.showVirtualShip - 아이콘 표시 - * @param {boolean} params.showLabels - 선명 라벨 표시 - * @param {Function} [params.onIconHover] - 아이콘 호버 콜백 - * @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백 - * @returns {Array} Deck.gl Layer 배열 */ -export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) { - const layers = []; +export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }: VirtualShipLayerParams): Layer[] { + const layers: Layer[] = []; if (!currentPositions || currentPositions.length === 0) return layers; @@ -158,7 +199,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho // 1. IconLayer - 가상 선박 아이콘 if (showVirtualShip) { layers.push( - new IconLayer({ + new IconLayer({ id: iconId, data: currentPositions, iconAtlas: atlasImg, @@ -169,7 +210,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho sizeUnits: 'pixels', getAngle: (d) => -(d.heading || 0), pickable: true, - onHover: (info) => { + onHover: (info: PickingInfo) => { if (info.object) { // 하이라이트 설정 if (onPathHover) { @@ -200,7 +241,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho const themeColors = getCurrentThemeColors(); layers.push( - new TextLayer({ + new TextLayer({ id: labelId, data: labelData, getPosition: (d) => [d.lon, d.lat], diff --git a/src/map/measure/measure.js b/src/map/measure/measure.ts similarity index 64% rename from src/map/measure/measure.js rename to src/map/measure/measure.ts index 8c6ebae1..95cd6a00 100644 --- a/src/map/measure/measure.js +++ b/src/map/measure/measure.ts @@ -8,12 +8,19 @@ */ import VectorSource from 'ol/source/Vector'; import VectorLayer from 'ol/layer/Vector'; +import Feature from 'ol/Feature'; import { Draw } from 'ol/interaction'; import { Overlay } from 'ol'; import { createBox } from 'ol/interaction/Draw'; import { unByKey } from 'ol/Observable'; import { getArea, getLength } from 'ol/sphere'; -import { LineString } from 'ol/geom'; +import { LineString, type Geometry } from 'ol/geom'; +import type OlMap from 'ol/Map'; +import type { EventsKey } from 'ol/events'; +import type { DrawEvent } from 'ol/interaction/Draw'; + +/** 면적 측정 도형 타입 */ +type AreaMeasureShape = 'Polygon' | 'Box' | 'Circle'; // ===================== // MeasureSession 클래스 @@ -24,7 +31,13 @@ import { LineString } from 'ol/geom'; * 직접 추적하고, dispose() 한 번으로 일괄 정리. */ export class MeasureSession { - constructor(map) { + map: OlMap; + _layer: VectorLayer> | null; + _interactions: Draw[]; + _overlays: Overlay[]; + _listeners: EventsKey[]; + + constructor(map: OlMap) { this.map = map; this._layer = null; this._interactions = []; @@ -33,22 +46,23 @@ export class MeasureSession { } /** VectorLayer 생성+등록, source 반환 */ - createLayer() { + createLayer(): VectorSource { const source = new VectorSource({ wrapX: false }); - this._layer = new VectorLayer({ source, zIndex: 54 }); - this.map.addLayer(this._layer); + const layer = new VectorLayer({ source, zIndex: 54 }); + this._layer = layer; + this.map.addLayer(layer); return source; } /** Draw 인터랙션 등록+추적 */ - addInteraction(draw) { + addInteraction(draw: Draw): Draw { this.map.addInteraction(draw); this._interactions.push(draw); return draw; } /** 측정 툴팁 Overlay 생성+등록+추적 */ - createTooltip() { + createTooltip(): Overlay { const el = document.createElement('div'); el.className = 'ol-tooltip ol-tooltip-measure'; const overlay = new Overlay({ @@ -62,13 +76,13 @@ export class MeasureSession { } /** 리스너 키 추적 (dispose 시 일괄 해제) */ - addListener(key) { + addListener(key: EventsKey | undefined): EventsKey | undefined { if (key) this._listeners.push(key); return key; } /** 모든 추적 객체 일괄 제거 */ - dispose() { + dispose(): void { this._listeners.forEach((key) => unByKey(key)); this._listeners = []; @@ -91,12 +105,10 @@ export class MeasureSession { /** * 거리 포맷: NM (km) - * @param {number} meters - * @returns {string} e.g. "5.2 NM (9.63 km)" */ -export function formatDistance(meters) { +export function formatDistance(meters: number): string { const nm = ((meters / 1000) * 0.5399568035).toFixed(1); - let sub; + let sub: string; if (meters > 1000) { sub = (Math.round((meters / 1000) * 100) / 100) + ' km'; } else { @@ -107,10 +119,8 @@ export function formatDistance(meters) { /** * 면적 포맷: km² 또는 m² - * @param {number} sqMeters - * @returns {string} */ -export function formatArea(sqMeters) { +export function formatArea(sqMeters: number): string { if (sqMeters > 10000) { return (Math.round((sqMeters / 1000000) * 100) / 100) + ' km\u00B2'; } @@ -119,12 +129,8 @@ export function formatArea(sqMeters) { /** * 각도 계산 (북쪽 기준 시계방향) - * @param {number[]} start - [x, y] 맵 좌표 - * @param {number[]} end - [x, y] 맵 좌표 - * @param {number} [cog=0] - 선박 COG (도) - * @returns {string} 각도 (0-360, 소수점 1자리) */ -export function getCircleDegree(start, end, cog = 0) { +export function getCircleDegree(start: number[], end: number[], cog: number = 0): string { const x = Number(end[0]) - Number(start[0]); const y = Number(end[1]) - Number(start[1]); @@ -141,16 +147,18 @@ export function getCircleDegree(start, end, cog = 0) { * 좌표 배열이 변경될 때마다 선분 개수에 맞춰 툴팁을 생성/업데이트/제거 */ class SegmentTooltips { - constructor(session) { + session: MeasureSession; + tooltips: Overlay[]; + + constructor(session: MeasureSession) { this.session = session; - this.tooltips = []; // Overlay 배열 + this.tooltips = []; } /** * 좌표 배열을 받아 각 선분 중점에 거리 툴팁 배치 - * @param {Array} coords - 좌표 배열 */ - update(coords) { + update(coords: number[][]): void { const segCount = coords.length - 1; // 부족하면 툴팁 추가 생성 @@ -176,17 +184,20 @@ class SegmentTooltips { for (let i = 0; i < segCount; i++) { const segLine = new LineString([coords[i], coords[i + 1]]); const length = getLength(segLine); - const mid = [ + const mid: [number, number] = [ (coords[i][0] + coords[i + 1][0]) / 2, (coords[i][1] + coords[i + 1][1]) / 2, ]; - this.tooltips[i].getElement().innerHTML = formatDistance(length); + const el = this.tooltips[i].getElement(); + if (el) { + el.innerHTML = formatDistance(length); + } this.tooltips[i].setPosition(mid); } } /** 모든 선분 툴팁을 static 스타일로 고정 */ - finalize() { + finalize(): void { this.tooltips.forEach((overlay) => { const el = overlay.getElement(); if (el && overlay.getPosition()) { @@ -202,31 +213,33 @@ class SegmentTooltips { /** * 거리 측정 설정 (LineString) - * @param {MeasureSession} session - * @param {VectorSource} source */ -export function setupDistanceMeasure(session, source) { +export function setupDistanceMeasure(session: MeasureSession, source: VectorSource): void { const draw = new Draw({ source, type: 'LineString' }); session.addInteraction(draw); - let currentTooltip = null; - let segTooltips = null; + let currentTooltip: Overlay | null = null; + let segTooltips: SegmentTooltips | null = null; - draw.on('drawstart', (evt) => { + draw.on('drawstart', (evt: DrawEvent) => { const tooltip = session.createTooltip(); currentTooltip = tooltip; segTooltips = new SegmentTooltips(session); - const geom = evt.feature.getGeometry(); + const geom = evt.feature.getGeometry()!; const key = geom.on('change', (e) => { - const coords = e.target.getCoordinates(); - const length = getLength(e.target); - tooltip.getElement().innerHTML = formatDistance(length); - tooltip.setPosition(e.target.getLastCoordinate()); + const target = e.target as LineString; + const coords = target.getCoordinates(); + const length = getLength(target); + const el = tooltip.getElement(); + if (el) { + el.innerHTML = formatDistance(length); + } + tooltip.setPosition(target.getLastCoordinate()); // 선분별 거리 표시 (2개 이상 좌표일 때) if (coords.length >= 2) { - segTooltips.update(coords); + segTooltips!.update(coords); } }); session.addListener(key); @@ -235,7 +248,9 @@ export function setupDistanceMeasure(session, source) { draw.on('drawend', () => { if (currentTooltip) { const el = currentTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; + if (el) { + el.className = 'ol-tooltip ol-tooltip-static'; + } currentTooltip.setOffset([0, -7]); } if (segTooltips) { @@ -246,13 +261,10 @@ export function setupDistanceMeasure(session, source) { /** * 면적 측정 설정 (Polygon / Box / Circle) - * @param {MeasureSession} session - * @param {VectorSource} source - * @param {'Polygon'|'Box'|'Circle'} shape */ -export function setupAreaMeasure(session, source, shape) { +export function setupAreaMeasure(session: MeasureSession, source: VectorSource, shape: AreaMeasureShape): void { // 메인 Draw 생성 - let draw; + let draw: Draw; if (shape === 'Box') { draw = new Draw({ source, type: 'Circle', geometryFunction: createBox() }); } else if (shape === 'Circle') { @@ -263,47 +275,55 @@ export function setupAreaMeasure(session, source, shape) { session.addInteraction(draw); // Circle인 경우 반경 표시용 Line Draw 추가 - let lineDraw = null; - let lineTooltip = null; + let lineDraw: Draw | null = null; + let lineTooltip: Overlay | null = null; if (shape === 'Circle') { lineTooltip = session.createTooltip(); lineDraw = new Draw({ source, type: 'LineString' }); session.addInteraction(lineDraw); - lineDraw.on('drawstart', (evt) => { - session.map.addOverlay(lineTooltip); - const geom = evt.feature.getGeometry(); + lineDraw.on('drawstart', (evt: DrawEvent) => { + session.map.addOverlay(lineTooltip!); + const geom = evt.feature.getGeometry()!; const key = geom.on('change', (e) => { - const length = getLength(e.target); + const target = e.target as LineString; + const length = getLength(target); const area = length * length * Math.PI; - lineTooltip.getElement().innerHTML = formatArea(area); - lineTooltip.setPosition(e.target.getFirstCoordinate()); + const el = lineTooltip!.getElement(); + if (el) { + el.innerHTML = formatArea(area); + } + lineTooltip!.setPosition(target.getFirstCoordinate()); }); session.addListener(key); }); } - let currentTooltip = null; - let segTooltips = null; + let currentTooltip: Overlay | null = null; + let segTooltips: SegmentTooltips | null = null; - draw.on('drawstart', (evt) => { + draw.on('drawstart', (evt: DrawEvent) => { if (shape === 'Polygon' || shape === 'Box') { currentTooltip = session.createTooltip(); segTooltips = new SegmentTooltips(session); } - const geom = evt.feature.getGeometry(); + const geom = evt.feature.getGeometry()!; const key = geom.on('change', (e) => { if (shape === 'Polygon' || shape === 'Box') { - const areaValue = getArea(e.target); - currentTooltip.getElement().innerHTML = formatArea(areaValue); - currentTooltip.setPosition(e.target.getInteriorPoint().getCoordinates()); + const target = e.target as import('ol/geom/Polygon').default; + const areaValue = getArea(target); + const el = currentTooltip!.getElement(); + if (el) { + el.innerHTML = formatArea(areaValue); + } + currentTooltip!.setPosition(target.getInteriorPoint().getCoordinates()); // 선분별 거리 표시 - const coords = e.target.getCoordinates()[0]; // 외부 링 + const coords = target.getCoordinates()[0]; // 외부 링 if (coords && coords.length >= 2) { - segTooltips.update(coords); + segTooltips!.update(coords); } } }); @@ -314,7 +334,9 @@ export function setupAreaMeasure(session, source, shape) { if (shape === 'Polygon' || shape === 'Box') { if (currentTooltip) { const el = currentTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; + if (el) { + el.className = 'ol-tooltip ol-tooltip-static'; + } currentTooltip.setOffset([0, -7]); } if (segTooltips) { @@ -330,44 +352,49 @@ export function setupAreaMeasure(session, source, shape) { /** * 거리환 측정 설정 (Circle + Line 이중 Draw) * 참조: mda-react-front measure.ts getCircleMeasureInteraction - * - * @param {MeasureSession} session - * @param {VectorSource} source */ -export function setupRangeRingMeasure(session, source) { +export function setupRangeRingMeasure(session: MeasureSession, source: VectorSource): void { // Line Draw (반경 거리 표시) const lineTooltip = session.createTooltip(); const lineDraw = new Draw({ source, type: 'LineString' }); - lineDraw.on('drawstart', (evt) => { + lineDraw.on('drawstart', (evt: DrawEvent) => { session.map.addOverlay(lineTooltip); - const geom = evt.feature.getGeometry(); + const geom = evt.feature.getGeometry()!; const key = geom.on('change', (e) => { - const length = getLength(e.target); - lineTooltip.getElement().innerHTML = formatDistance(length); - lineTooltip.setPosition(e.target.getLastCoordinate()); + const target = e.target as LineString; + const length = getLength(target); + const el = lineTooltip.getElement(); + if (el) { + el.innerHTML = formatDistance(length); + } + lineTooltip.setPosition(target.getLastCoordinate()); }); session.addListener(key); }); // Circle Draw (각도 표시) const circleDraw = new Draw({ source, type: 'Circle' }); - let circleTooltip = null; + let circleTooltip: Overlay | null = null; let degree = '0.0'; - circleDraw.on('drawstart', (evt) => { + circleDraw.on('drawstart', (evt: DrawEvent) => { circleTooltip = session.createTooltip(); - const geom = evt.feature.getGeometry(); + const geom = evt.feature.getGeometry()!; const key = geom.on('change', () => { // sketchCoords_: [center, edge] — OL Draw 내부 좌표 - const coords = evt.target.sketchCoords_; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coords = (evt.target as any).sketchCoords_ as number[][] | undefined; if (coords && coords[0] && coords[1]) { degree = getCircleDegree(coords[0], coords[1]); } - circleTooltip.getElement().innerHTML = `각도: ${degree}°`; - circleTooltip.setPosition(geom.getCenter()); + const el = circleTooltip!.getElement(); + if (el) { + el.innerHTML = `각도: ${degree}°`; + } + circleTooltip!.setPosition((geom as import('ol/geom/Circle').default).getCenter()); }); session.addListener(key); }); @@ -377,9 +404,12 @@ export function setupRangeRingMeasure(session, source) { if (circleTooltip) { const el = circleTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; - // 최종 툴팁: 거리 + 각도 - el.innerHTML = lineTooltip.getElement().innerHTML + ` 각도: ${degree}°`; + if (el) { + el.className = 'ol-tooltip ol-tooltip-static'; + // 최종 툴팁: 거리 + 각도 + const lineEl = lineTooltip.getElement(); + el.innerHTML = (lineEl?.innerHTML ?? '') + ` 각도: ${degree}°`; + } circleTooltip.setOffset([0, -7]); } diff --git a/src/map/measure/useMeasure.js b/src/map/measure/useMeasure.ts similarity index 80% rename from src/map/measure/useMeasure.js rename to src/map/measure/useMeasure.ts index 197812cc..508b9054 100644 --- a/src/map/measure/useMeasure.js +++ b/src/map/measure/useMeasure.ts @@ -13,16 +13,16 @@ import { setupRangeRingMeasure, } from './measure'; -export default function useMeasure() { +export default function useMeasure(): void { const map = useMapStore((s) => s.map); const activeTool = useMapStore((s) => s.activeMeasureTool); const areaShape = useMapStore((s) => s.areaShape); const clearMeasure = useMapStore((s) => s.clearMeasure); - const sessionRef = useRef(null); + const sessionRef = useRef(null); // ESC 키로 측정 취소 useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape' && activeTool) { clearMeasure(); } @@ -53,7 +53,9 @@ export default function useMeasure() { setupDistanceMeasure(session, source); break; case 'area': - setupAreaMeasure(session, source, areaShape); + // mapStore AreaShape → measure AreaMeasureShape 직접 전달 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- store 타입과 measure 타입 불일치, 런타임 값은 호환 + setupAreaMeasure(session, source, areaShape as any); break; case 'rangeRing': setupRangeRingMeasure(session, source); diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.tsx similarity index 100% rename from src/pages/HomePage.jsx rename to src/pages/HomePage.tsx diff --git a/src/pages/ReplayPage.jsx b/src/pages/ReplayPage.tsx similarity index 89% rename from src/pages/ReplayPage.jsx rename to src/pages/ReplayPage.tsx index f32ce75b..e9744572 100644 --- a/src/pages/ReplayPage.jsx +++ b/src/pages/ReplayPage.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import './ReplayPage.scss'; import { getReplayWebSocketService } from '../replay/services/ReplayWebSocketService'; import useReplayStore from '../replay/stores/replayStore'; -import useMergedTrackStore from '../replay/stores/mergedTrackStore'; import useAnimationStore from '../replay/stores/animationStore'; import { ConnectionState, VesselState } from '../replay/types/replay.types'; +import type { VesselStateType } from '../replay/types/replay.types'; import VesselListManager from '../replay/components/VesselListManager'; import ReplayControlV2 from '../replay/components/ReplayControlV2'; import { TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../types/constants'; @@ -14,16 +14,21 @@ import { showToast } from '../components/common/Toast'; const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */ -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } +interface ReplayPageProps { + isOpen: boolean; + onToggle: () => void; +} + /** * 리플레이 페이지 * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx */ -export default function ReplayPage({ isOpen, onToggle }) { +export default function ReplayPage({ isOpen, onToggle }: ReplayPageProps) { // 조회 기간 const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState('00:00'); @@ -39,19 +44,17 @@ export default function ReplayPage({ isOpen, onToggle }) { const queryCompleted = useReplayStore((s) => s.queryCompleted); const progress = useReplayStore((s) => s.progress); const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId); - const deletedVesselIds = useReplayStore((s) => s.deletedVesselIds); - const selectedVesselIds = useReplayStore((s) => s.selectedVesselIds); const setTimeRange = useAnimationStore((s) => s.setTimeRange); /** * 선박 상태 전환 핸들러 - * DELETE: 일반/선택 → 삭제, 삭제 → 일반 - * INSERT: 일반 → 선택, 삭제 → 선택, 선택 → 일반 + * DELETE: 일반/선택 -> 삭제, 삭제 -> 일반 + * INSERT: 일반 -> 선택, 삭제 -> 선택, 선택 -> 일반 */ const handleVesselStateTransition = useCallback( - (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => { - let targetState; + (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => { + let targetState: VesselStateType; if (action === 'DELETE') { if (isCurrentlyDeleted) { @@ -77,7 +80,7 @@ export default function ReplayPage({ isOpen, onToggle }) { // 키보드 이벤트 리스너 (Delete/Insert 키로 항적 상태 변경) useEffect(() => { - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (!highlightedVesselId) return; const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState(); @@ -103,7 +106,7 @@ export default function ReplayPage({ isOpen, onToggle }) { const defaultDaysAgo = new Date(now); defaultDaysAgo.setDate(defaultDaysAgo.getDate() - TRACK_QUERY_DEFAULT_DAYS); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); setStartDate(defaultDaysAgo.toISOString().split('T')[0]); setStartTime('00:00'); setEndDate(now.toISOString().split('T')[0]); @@ -111,16 +114,16 @@ export default function ReplayPage({ isOpen, onToggle }) { }, []); // 시작일 변경 핸들러 - const handleStartDateChange = useCallback((newStartDate) => { + const handleStartDateChange = useCallback((newStartDate: string) => { setStartDate(newStartDate); // 현재 종료일/시간과 비교 const start = new Date(`${newStartDate}T${startTime}:00`); const end = new Date(`${endDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); - // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 + // 시작일이 종료일보다 뒤인 경우 -> 종료일을 시작일 + 기본조회기간으로 조정 if (diffDays < 0) { const adjustedEnd = new Date(start.getTime() + TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); setEndDate(adjustedEnd.toISOString().split('T')[0]); @@ -137,16 +140,16 @@ export default function ReplayPage({ isOpen, onToggle }) { }, [startTime, endDate, endTime]); // 종료일 변경 핸들러 - const handleEndDateChange = useCallback((newEndDate) => { + const handleEndDateChange = useCallback((newEndDate: string) => { setEndDate(newEndDate); // 현재 시작일/시간과 비교 const start = new Date(`${startDate}T${startTime}:00`); const end = new Date(`${newEndDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); - // 종료일이 시작일보다 앞인 경우 → 시작일을 종료일 - 기본조회기간으로 조정 + // 종료일이 시작일보다 앞인 경우 -> 시작일을 종료일 - 기본조회기간으로 조정 if (diffDays < 0) { const adjustedStart = new Date(end.getTime() - TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); setStartDate(adjustedStart.toISOString().split('T')[0]); @@ -223,7 +226,7 @@ export default function ReplayPage({ isOpen, onToggle }) { } catch (error) { console.error('[ReplayPage] 쿼리 실패:', error); setIsQuerying(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, setTimeRange]); diff --git a/src/replay/components/ReplayControlV2.jsx b/src/replay/components/ReplayControlV2.tsx similarity index 95% rename from src/replay/components/ReplayControlV2.jsx rename to src/replay/components/ReplayControlV2.tsx index cc53f845..7a05c8bc 100644 --- a/src/replay/components/ReplayControlV2.jsx +++ b/src/replay/components/ReplayControlV2.tsx @@ -7,10 +7,11 @@ import { useState, useCallback } from 'react'; import useReplayStore from '../stores/replayStore'; import useMergedTrackStore from '../stores/mergedTrackStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; +import type { FilterModuleConfig } from '../types/replay.types'; import './ReplayControlV2.scss'; // 리플레이 필터 옵션 -const CUSTOM_FILTER_OPTIONS = [ +const CUSTOM_FILTER_OPTIONS: { key: keyof FilterModuleConfig; label: string }[] = [ { key: 'showNormal', label: '기본' }, { key: 'showSelected', label: '선택' }, { key: 'showDeleted', label: '삭제' }, @@ -50,19 +51,19 @@ const ReplayControlV2 = () => { const toggleShipKindCode = useReplayStore(state => state.toggleShipKindCode); // 커스텀 필터 토글 핸들러 - const handleCustomFilterToggle = (key) => { + const handleCustomFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.custom[key]; updateFilterModule('custom', { [key]: newValue }); }; // 항적 필터 토글 핸들러 - const handlePathFilterToggle = (key) => { + const handlePathFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.path[key]; updateFilterModule('path', { [key]: newValue }); }; // 라벨 필터 토글 핸들러 - const handleLabelFilterToggle = (key) => { + const handleLabelFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.label[key]; updateFilterModule('label', { [key]: newValue }); }; @@ -77,7 +78,7 @@ const ReplayControlV2 = () => { }; // 카운트 표시 헬퍼 - const getCountLabel = (key) => { + const getCountLabel = (key: keyof FilterModuleConfig): string => { if (key === 'showNormal') return ` (${normalVesselCount})`; if (key === 'showSelected') return ` (${selectedVesselIds.size})`; if (key === 'showDeleted') return ` (${deletedVesselIds.size})`; diff --git a/src/replay/components/ReplayLoadingOverlay.jsx b/src/replay/components/ReplayLoadingOverlay.tsx similarity index 100% rename from src/replay/components/ReplayLoadingOverlay.jsx rename to src/replay/components/ReplayLoadingOverlay.tsx diff --git a/src/replay/components/ReplayTimeline.jsx b/src/replay/components/ReplayTimeline.tsx similarity index 90% rename from src/replay/components/ReplayTimeline.jsx rename to src/replay/components/ReplayTimeline.tsx index bc1bbe4a..76bdaf45 100644 --- a/src/replay/components/ReplayTimeline.jsx +++ b/src/replay/components/ReplayTimeline.tsx @@ -9,7 +9,7 @@ * - 드래그 가능한 헤더 * - 항적표시 토글 */ -import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo, ChangeEvent } from 'react'; import useAnimationStore, { PlaybackState } from '../stores/animationStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; import './ReplayTimeline.scss'; @@ -20,7 +20,7 @@ const PLAYBACK_SPEED_OPTIONS = [1, 10, 50, 100, 500, 1000]; /** * 날짜 포맷팅 (YYYY-MM-DD HH:mm 형식) */ -function formatDateRange(dateStr) { +function formatDateRange(dateStr: string): string { if (!dateStr) return ''; try { const date = new Date(dateStr); @@ -36,19 +36,25 @@ function formatDateRange(dateStr) { } /** - * ms → 날짜시간 문자열 (YYYY-MM-DD HH:mm:ss) + * ms -> 날짜시간 문자열 (YYYY-MM-DD HH:mm:ss) */ -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms || ms <= 0) return '--:--:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +interface ReplayTimelineProps { + fromDate?: string; + toDate?: string; + onClose?: () => void; +} + /** * 리플레이 타임라인 컨트롤 컴포넌트 */ -export default function ReplayTimeline({ fromDate, toDate, onClose }) { +export default function ReplayTimeline({ fromDate, toDate, onClose }: ReplayTimelineProps) { // animationStore 상태 const playbackState = useAnimationStore((s) => s.playbackState); const currentTime = useAnimationStore((s) => s.currentTime); @@ -73,15 +79,15 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 배속 드롭다운 상태 const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); - const sliderContainerRef = useRef(null); + const speedMenuRef = useRef(null); + const sliderContainerRef = useRef(null); // 드래그 상태 const [isDragging, setIsDragging] = useState(false); const [hasDragged, setHasDragged] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const containerRef = useRef(null); + const containerRef = useRef(null); // 항적표시 상태 (playbackTrailStore와 동기화) const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled); @@ -89,8 +95,8 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 외부 클릭 시 드롭다운 닫기 useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -107,7 +113,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 드래그 핸들러 // CSS 센터링(left:50% + translateX(-50%))에서 절대좌표(left/top)로 전환하여 // transform 충돌로 인한 위치 이탈/가로스크롤 방지 - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const parent = containerRef.current.parentElement; @@ -126,7 +132,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, [hasDragged]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!isDragging || !containerRef.current) return; const parent = containerRef.current.parentElement; if (!parent) return; @@ -170,7 +176,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, [stop]); // 배속 변경 - const handleSpeedChange = useCallback((speed) => { + const handleSpeedChange = useCallback((speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, [setPlaybackSpeed]); @@ -198,7 +204,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, []); // 슬라이더로 시간 변경 - const handleSliderChange = useCallback((e) => { + const handleSliderChange = useCallback((e: ChangeEvent) => { const newTime = parseFloat(e.target.value); seekTo(newTime); }, [seekTo]); @@ -270,7 +276,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { disabled={!hasData} title={isPlaying ? '일시정지' : '재생'} > - {isPlaying ? '❚❚' : '▶'} + {isPlaying ? '\u275A\u275A' : '\u25B6'} {/* 정지 버튼 */} @@ -281,7 +287,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { disabled={!hasData} title="정지" > - ■ + \u25A0 {/* 슬라이더 */} @@ -296,7 +302,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { onPointerDown={handleSliderPointerDown} onChange={handleSliderChange} disabled={!hasData} - style={{ '--progress': `${progress}%` }} + style={{ '--progress': `${progress}%` } as React.CSSProperties} />
        diff --git a/src/replay/components/VesselListManager/VesselContextMenu.jsx b/src/replay/components/VesselListManager/VesselContextMenu.tsx similarity index 82% rename from src/replay/components/VesselListManager/VesselContextMenu.jsx rename to src/replay/components/VesselListManager/VesselContextMenu.tsx index 11cb3200..47db1581 100644 --- a/src/replay/components/VesselListManager/VesselContextMenu.jsx +++ b/src/replay/components/VesselListManager/VesselContextMenu.tsx @@ -2,24 +2,32 @@ * 선박 아이템 우클릭 컨텍스트 메뉴 컴포넌트 */ import React, { useCallback, useEffect } from 'react'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselContextMenu.scss'; +interface VesselContextMenuProps { + vessel: ClassifiedVesselItem; + position: { x: number; y: number }; + onClose: () => void; + onShowDetail: (vesselId: string) => void; +} + const VesselContextMenu = ({ vessel, position, onClose, onShowDetail, -}) => { +}: VesselContextMenuProps) => { // 메뉴 외부 클릭 시 닫기 useEffect(() => { - const handleClickOutside = (event) => { - const target = event.target; + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; if (!target.closest('.vessel-context-menu')) { onClose(); } }; - const handleEscKey = (event) => { + const handleEscKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } diff --git a/src/replay/components/VesselListManager/VesselItem.jsx b/src/replay/components/VesselListManager/VesselItem.tsx similarity index 83% rename from src/replay/components/VesselListManager/VesselItem.jsx rename to src/replay/components/VesselListManager/VesselItem.tsx index d70718cf..dac53462 100644 --- a/src/replay/components/VesselListManager/VesselItem.jsx +++ b/src/replay/components/VesselListManager/VesselItem.tsx @@ -5,6 +5,8 @@ */ import React, { useCallback, useState } from 'react'; import { getCountryNameFromCode } from './utils/countryCodeUtils'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; +import type { VesselStateType } from '../../types/replay.types'; import './VesselItem.scss'; // 선종 아이콘 import (dark 프로젝트 assets) @@ -29,7 +31,7 @@ import { } from '../../../types/constants'; // 선종코드 → 아이콘 매핑 -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, // 000020: 어선 [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, // 000021: 함정 [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, // 000022: 여객선 @@ -41,7 +43,7 @@ const SHIP_KIND_ICONS = { }; // 선종 코드별 표시명 -const SHIP_KIND_NAMES = { +const SHIP_KIND_NAMES: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -53,7 +55,7 @@ const SHIP_KIND_NAMES = { }; // 신호원 코드별 표시명 -const SIGNAL_SOURCE_NAMES = { +const SIGNAL_SOURCE_NAMES: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', @@ -63,23 +65,35 @@ const SIGNAL_SOURCE_NAMES = { /** * 선종 아이콘 반환 */ -const getShipKindIcon = (shipKindCode) => { +const getShipKindIcon = (shipKindCode: string): string => { return SHIP_KIND_ICONS[shipKindCode] || etcIcon; }; /** * 국기 이미지 URL 생성 (서버 API) * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 - * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') - * @returns {string} 국기 이미지 URL */ -const getNationalFlagUrl = (nationalCode) => { +const getNationalFlagUrl = (nationalCode: string): string => { // 국적 코드가 없으면 기본값 '000' 사용 const code = nationalCode || '000'; // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) return `/ship/image/small/${code}.svg`; }; +interface VesselItemProps { + vessel: ClassifiedVesselItem; + index: number; + isDragDisabled?: boolean; + isSelected?: boolean; + onDragStart?: (vesselId: string, sourceState: VesselStateType) => void; + onDragEnd?: () => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; +} + const VesselItem = ({ vessel, index, @@ -90,11 +104,10 @@ const VesselItem = ({ onMouseEnterVessel, onMouseLeaveVessel, onToggleSelection, - onShowVesselDetail, onContextMenu, -}) => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); +}: VesselItemProps) => { + const [, setShowTooltip] = useState(false); + const [, setTooltipPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); // 아이콘 및 정보 @@ -106,7 +119,7 @@ const VesselItem = ({ // 마우스 호버 이벤트 핸들러 const handleMouseEnter = useCallback( - (event) => { + (event: React.MouseEvent) => { if (isDragDisabled || isDragging) return; const rect = event.currentTarget.getBoundingClientRect(); @@ -131,7 +144,7 @@ const VesselItem = ({ // HTML5 드래그앤드롭 이벤트 핸들러 const handleDragStart = useCallback( - (event) => { + (event: React.DragEvent) => { if (isDragDisabled) { event.preventDefault(); return; @@ -152,7 +165,7 @@ const VesselItem = ({ event.dataTransfer.effectAllowed = 'move'; // 커스텀 드래그 이미지 설정 (선택적) - const dragImage = event.currentTarget.cloneNode(true); + const dragImage = event.currentTarget.cloneNode(true) as HTMLElement; dragImage.style.transform = 'rotate(5deg)'; dragImage.style.opacity = '0.8'; event.dataTransfer.setDragImage(dragImage, 50, 25); @@ -163,7 +176,7 @@ const VesselItem = ({ ); const handleDragEnd = useCallback( - (event) => { + (_event: React.DragEvent) => { setIsDragging(false); onDragEnd?.(); }, @@ -172,7 +185,7 @@ const VesselItem = ({ // 체크박스 토글 핸들러 const handleToggleSelection = useCallback( - (event) => { + (event: React.MouseEvent) => { event.stopPropagation(); onToggleSelection?.(vessel.vesselId, !isSelected); }, @@ -181,7 +194,7 @@ const VesselItem = ({ // 우클릭 컨텍스트 메뉴 핸들러 const handleRightClick = useCallback( - (event) => { + (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); onContextMenu?.(vessel.vesselId, event); @@ -227,13 +240,13 @@ const VesselItem = ({ src={shipKindIcon} alt={shipKindName} className="ship-kind-icon" - onError={(e) => { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> {countryName} { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> ({signalSourceName})
        diff --git a/src/replay/components/VesselListManager/VesselListManager.jsx b/src/replay/components/VesselListManager/VesselListManager.tsx similarity index 86% rename from src/replay/components/VesselListManager/VesselListManager.jsx rename to src/replay/components/VesselListManager/VesselListManager.tsx index 79ff002e..a618b453 100644 --- a/src/replay/components/VesselListManager/VesselListManager.jsx +++ b/src/replay/components/VesselListManager/VesselListManager.tsx @@ -3,9 +3,11 @@ * HTML5 드래그앤드롭을 통한 선박 상태 전환 인터페이스 * dark 프로젝트 스타일 적용 */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import { useVesselClassification } from './hooks/useVesselClassification'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import { useVesselActions } from './hooks/useVesselActions'; import VesselListPanel from './VesselListPanel'; import VesselSearchFilter from './VesselSearchFilter'; @@ -13,20 +15,29 @@ import VesselContextMenu from './VesselContextMenu'; import useReplayStore from '../../stores/replayStore'; import './VesselListManager.scss'; -export const VesselListManager = ({ className = '' }) => { - const { vesselsByState, totalCount, hasVessels } = useVesselClassification(); +interface VesselListManagerProps { + className?: string; +} + +interface ContextMenuState { + vessel: ClassifiedVesselItem; + position: { x: number; y: number }; +} + +export const VesselListManager = ({ className = '' }: VesselListManagerProps) => { + const { vesselsByState, totalCount } = useVesselClassification(); const { handleDragDrop } = useVesselActions(); // 필터링 및 선택 상태 - const [filteredVessels, setFilteredVessels] = useState([]); - const [selectedVesselIds, setSelectedVesselIds] = useState(new Set()); + const [filteredVessels, setFilteredVessels] = useState([]); + const [selectedVesselIds, setSelectedVesselIds] = useState>(new Set()); // 패널 상태 (기본 열림) const [isOpen, setIsOpen] = useState(true); const [tap, setTap] = useState(1); // 컨텍스트 메뉴 상태 - const [contextMenu, setContextMenu] = useState(null); + const [contextMenu, setContextMenu] = useState(null); // 전체 선박 목록 (필터링 전) const allVessels = useMemo(() => { @@ -45,12 +56,12 @@ export const VesselListManager = ({ className = '' }) => { /** * 지도 상의 항적 하이라이트 핸들러 */ - const handleMouseEnterVessel = useCallback((vesselId) => { + const handleMouseEnterVessel = useCallback((vesselId: string) => { // replayStore의 하이라이트 시스템 사용 useReplayStore.getState().setHighlightedVesselId(vesselId); }, []); - const handleMouseLeaveVessel = useCallback((vesselId) => { + const handleMouseLeaveVessel = useCallback((_vesselId: string) => { // 하이라이트 제거 useReplayStore.getState().setHighlightedVesselId(null); }, []); @@ -59,7 +70,7 @@ export const VesselListManager = ({ className = '' }) => { * HTML5 드래그앤드롭 완료 핸들러 */ const onDrop = useCallback( - (vesselId, sourceState, targetState) => { + (vesselId: string, sourceState: VesselStateType, targetState: VesselStateType) => { // 드래그앤드롭 결과를 상태 전환으로 변환 handleDragDrop({ vesselId, @@ -73,7 +84,7 @@ export const VesselListManager = ({ className = '' }) => { /** * 선박 선택/해제 핸들러 */ - const handleToggleSelection = useCallback((vesselId, isSelected) => { + const handleToggleSelection = useCallback((vesselId: string, isSelected: boolean) => { setSelectedVesselIds(prev => { const newSet = new Set(prev); if (isSelected) { @@ -93,7 +104,7 @@ export const VesselListManager = ({ className = '' }) => { * 선박 상세보기 핸들러 (컨텍스트 메뉴에서 호출) */ const handleShowVesselDetail = useCallback( - (vesselId) => { + (vesselId: string) => { // 선박 정보 찾기 const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel) { @@ -108,7 +119,7 @@ export const VesselListManager = ({ className = '' }) => { * 컨텍스트 메뉴 열기 */ const handleContextMenu = useCallback( - (vesselId, event) => { + (vesselId: string, event: React.MouseEvent) => { const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel) { setContextMenu({ @@ -131,9 +142,9 @@ export const VesselListManager = ({ className = '' }) => { * 선박 일괄 상태 변경 핸들러 */ const handleBulkStateChange = useCallback( - (selectedVesselIds, targetState) => { + (bulkVesselIds: string[], targetState: VesselStateType) => { // 각 선박에 대해 상태 변경 실행 - selectedVesselIds.forEach(vesselId => { + bulkVesselIds.forEach(vesselId => { const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel && vessel.state !== targetState) { handleDragDrop({ @@ -150,7 +161,7 @@ export const VesselListManager = ({ className = '' }) => { /** * 패널별 전체선택/해제 핸들러 */ - const handleSelectAllInPanel = useCallback((vesselIds, isSelected) => { + const handleSelectAllInPanel = useCallback((vesselIds: string[], isSelected: boolean) => { setSelectedVesselIds(prev => { const newSet = new Set(prev); @@ -166,22 +177,6 @@ export const VesselListManager = ({ className = '' }) => { }); }, []); - // 탭 스타일 헬퍼 - const getTabStyle = (tabIndex) => ({ - flex: 1, - height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - fontSize: '1.2rem', - fontWeight: '600', - color: tap === tabIndex ? '#fff' : 'rgba(255, 255, 255, 0.6)', - backgroundColor: tap === tabIndex ? 'rgba(74, 158, 255, 0.3)' : 'transparent', - borderBottom: tap === tabIndex ? '2px solid #4a9eff' : '2px solid transparent', - cursor: 'pointer', - transition: 'all 0.2s ease', - }); - return (
        {/* 헤더 */} diff --git a/src/replay/components/VesselListManager/VesselListPanel.jsx b/src/replay/components/VesselListManager/VesselListPanel.tsx similarity index 74% rename from src/replay/components/VesselListManager/VesselListPanel.jsx rename to src/replay/components/VesselListManager/VesselListPanel.tsx index 53c4aa09..de5381f7 100644 --- a/src/replay/components/VesselListManager/VesselListPanel.jsx +++ b/src/replay/components/VesselListManager/VesselListPanel.tsx @@ -2,14 +2,16 @@ * 개별 선박 목록 패널 컴포넌트 * HTML5 드래그앤드롭 API를 사용하여 상태별(일반/선택/삭제) 선박 목록을 드롭 영역으로 표시 */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import VesselItem from './VesselItem'; import VirtualVesselList from './VirtualVesselList'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselListPanel.scss'; // 상태별 설정 -const STATE_CONFIG = { +const STATE_CONFIG: Record = { [VesselState.NORMAL]: { title: '기본 선박', color: '#28a745', @@ -27,11 +29,27 @@ const STATE_CONFIG = { }, }; +interface VesselListPanelProps { + state: VesselStateType; + vessels: ClassifiedVesselItem[]; + title?: string; + color?: string; + emptyMessage?: string; + selectedVesselIds?: Set; + onDrop?: (vesselId: string, sourceState: VesselStateType, targetState: VesselStateType) => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; + onSelectAllInPanel?: (vesselIds: string[], isSelected: boolean) => void; +} + const VesselListPanel = ({ state, vessels, - title, - color, + title: _title, + color: _color, emptyMessage, selectedVesselIds = new Set(), onDrop, @@ -40,29 +58,27 @@ const VesselListPanel = ({ onToggleSelection, onShowVesselDetail, onContextMenu, - onSelectAllInPanel, -}) => { + onSelectAllInPanel: _onSelectAllInPanel, +}: VesselListPanelProps) => { const [isDragOver, setIsDragOver] = useState(false); - const [dragData, setDragData] = useState(null); + const [, setDragData] = useState<{ vesselId: string; sourceState: VesselStateType } | null>(null); const config = STATE_CONFIG[state]; - const panelTitle = title || config.title; - const panelColor = color || config.color; const panelEmptyMessage = emptyMessage || config.emptyMessage; // HTML5 드롭 이벤트 핸들러 - const handleDragOver = useCallback((event) => { + const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setIsDragOver(true); }, []); - const handleDragEnter = useCallback((event) => { + const handleDragEnter = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOver(true); }, []); - const handleDragLeave = useCallback((event) => { + const handleDragLeave = useCallback((event: React.DragEvent) => { // 실제로 컨테이너를 벗어날 때만 drag over 상태 해제 const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX; @@ -74,12 +90,15 @@ const VesselListPanel = ({ }, []); const handleDrop = useCallback( - (event) => { + (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); try { - const data = JSON.parse(event.dataTransfer.getData('text/plain')); + const data = JSON.parse(event.dataTransfer.getData('text/plain')) as { + vesselId: string; + sourceState: VesselStateType; + }; const { vesselId, sourceState } = data; if (vesselId && sourceState && sourceState !== state) { @@ -92,7 +111,7 @@ const VesselListPanel = ({ [state, onDrop], ); - const handleVesselDragStart = useCallback((vesselId, sourceState) => { + const handleVesselDragStart = useCallback((vesselId: string, sourceState: VesselStateType) => { setDragData({ vesselId, sourceState }); }, []); @@ -100,28 +119,6 @@ const VesselListPanel = ({ setDragData(null); }, []); - // 패널 내 선택 상태 계산 - const vesselIds = useMemo(() => vessels.map(v => v.vesselId), [vessels]); - const selectedInPanel = useMemo( - () => vesselIds.filter(id => selectedVesselIds.has(id)).length, - [vesselIds, selectedVesselIds], - ); - const isAllSelected = vesselIds.length > 0 && selectedInPanel === vesselIds.length; - const isPartiallySelected = selectedInPanel > 0 && selectedInPanel < vesselIds.length; - - // 패널별 전체선택/해제 - const handleSelectAllInPanel = useCallback( - (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (!onSelectAllInPanel || vesselIds.length === 0) return; - - onSelectAllInPanel(vesselIds, !isAllSelected); - }, - [vesselIds, isAllSelected, onSelectAllInPanel], - ); - return (
        {/* 드롭 영역 */} diff --git a/src/replay/components/VesselListManager/VesselSearchFilter.jsx b/src/replay/components/VesselListManager/VesselSearchFilter.tsx similarity index 84% rename from src/replay/components/VesselListManager/VesselSearchFilter.jsx rename to src/replay/components/VesselListManager/VesselSearchFilter.tsx index ed1de482..68b287b5 100644 --- a/src/replay/components/VesselListManager/VesselSearchFilter.jsx +++ b/src/replay/components/VesselListManager/VesselSearchFilter.tsx @@ -5,11 +5,13 @@ */ import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import { getSortedCountryOptions } from './utils/countryCodeUtils'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselSearchFilter.scss'; // 선종 코드별 표시명 -const SHIP_KIND_NAMES = { +const SHIP_KIND_NAMES: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -20,17 +22,32 @@ const SHIP_KIND_NAMES = { '000028': '부이', }; +interface CountryGroupOption { + countryName: string; + codes: string[]; + displayName: string; + truncatedName: string; +} + +interface VesselSearchFilterProps { + vessels: ClassifiedVesselItem[]; + onFilteredVesselsChange: (filtered: ClassifiedVesselItem[]) => void; + onSelectedVesselsChange?: (selectedIds: Set) => void; + selectedVesselIds?: Set; + onBulkStateChange?: (vesselIds: string[], targetState: VesselStateType) => void; +} + export const VesselSearchFilter = ({ vessels, onFilteredVesselsChange, onSelectedVesselsChange, selectedVesselIds = new Set(), onBulkStateChange, -}) => { +}: VesselSearchFilterProps) => { const [searchText, setSearchText] = useState(''); const [selectedShipKind, setSelectedShipKind] = useState(''); - const [selectedCountryGroup, setSelectedCountryGroup] = useState(null); - const [selectedState, setSelectedState] = useState(''); + const [selectedCountryGroup, setSelectedCountryGroup] = useState(null); + const [selectedState, _setSelectedState] = useState(''); // 고유 선종 목록 추출 const availableShipKinds = useMemo(() => { @@ -88,36 +105,9 @@ export const VesselSearchFilter = ({ onFilteredVesselsChange(filteredVessels); }, [filteredVessels, onFilteredVesselsChange]); - /** - * 전체 선택/해제 토글 핸들러 - */ - const handleSelectAll = useCallback(() => { - const newSelectedIds = new Set(selectedVesselIds); - - if (filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId))) { - filteredVessels.forEach(vessel => { - newSelectedIds.delete(vessel.vesselId); - }); - } else { - filteredVessels.forEach(vessel => { - newSelectedIds.add(vessel.vesselId); - }); - } - - onSelectedVesselsChange?.(newSelectedIds); - }, [filteredVessels, selectedVesselIds, onSelectedVesselsChange]); - - // 필터 초기화 - const handleClearFilters = useCallback(() => { - setSearchText(''); - setSelectedShipKind(''); - setSelectedCountryGroup(null); - setSelectedState(''); - }, []); - // 선택된 선박들의 상태 일괄 변경 const handleBulkStateChange = useCallback( - (targetState) => { + (targetState: VesselStateType) => { const selectedIds = Array.from(selectedVesselIds); if (selectedIds.length > 0 && onBulkStateChange) { onBulkStateChange(selectedIds, targetState); @@ -127,10 +117,6 @@ export const VesselSearchFilter = ({ [selectedVesselIds, onBulkStateChange, onSelectedVesselsChange], ); - const isAllSelected = - filteredVessels.length > 0 && filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId)); - const isPartiallySelected = filteredVessels.some(vessel => selectedVesselIds.has(vessel.vesselId)) && !isAllSelected; - return (
        diff --git a/src/replay/components/VesselListManager/VirtualVesselList.jsx b/src/replay/components/VesselListManager/VirtualVesselList.tsx similarity index 67% rename from src/replay/components/VesselListManager/VirtualVesselList.jsx rename to src/replay/components/VesselListManager/VirtualVesselList.tsx index 71222cfd..6252de3b 100644 --- a/src/replay/components/VesselListManager/VirtualVesselList.jsx +++ b/src/replay/components/VesselListManager/VirtualVesselList.tsx @@ -5,6 +5,22 @@ import React, { useMemo } from 'react'; import VesselItem from './VesselItem'; import { useVirtualScroll } from './hooks/useVirtualScroll'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; +import type { VesselStateType } from '../../types/replay.types'; + +interface VirtualVesselListProps { + vessels: ClassifiedVesselItem[]; + selectedVesselIds?: Set; + onDragStart?: (vesselId: string, sourceState: VesselStateType) => void; + onDragEnd?: () => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; + containerHeight?: number; + itemHeight?: number; +} const VirtualVesselList = ({ vessels, @@ -18,10 +34,10 @@ const VirtualVesselList = ({ onContextMenu, containerHeight = 300, itemHeight = 40, // VesselItem의 대략적인 높이 -}) => { - const { containerRef, scrollElementRef, handleScroll, visibleItems, totalHeight, offsetY, startIndex, endIndex } = +}: VirtualVesselListProps) => { + const { scrollElementRef, handleScroll, visibleItems, totalHeight } = useVirtualScroll({ - items: vessels, + items: vessels as (ClassifiedVesselItem & Record)[], itemHeight, containerHeight, overscan: 5, @@ -31,7 +47,7 @@ const VirtualVesselList = ({ const renderedItems = useMemo(() => { return visibleItems.map((vessel) => (
        } className="virtual-vessel-list" style={{ height: containerHeight, diff --git a/src/replay/components/VesselListManager/hooks/useVesselActions.js b/src/replay/components/VesselListManager/hooks/useVesselActions.ts similarity index 78% rename from src/replay/components/VesselListManager/hooks/useVesselActions.js rename to src/replay/components/VesselListManager/hooks/useVesselActions.ts index ba130838..490173f1 100644 --- a/src/replay/components/VesselListManager/hooks/useVesselActions.js +++ b/src/replay/components/VesselListManager/hooks/useVesselActions.ts @@ -18,38 +18,35 @@ */ import { useCallback } from 'react'; import useReplayStore from '../../../stores/replayStore'; -import { VesselState } from '../../../types/replay.types'; +import { VesselState, VesselStateType } from '../../../types/replay.types'; + +interface DragDropResult { + vesselId: string; + sourceState: VesselStateType; + targetState: VesselStateType; +} + +interface UseVesselActionsResult { + handleDragDrop: (result: DragDropResult) => void; + setVesselState: (vesselId: string, targetState: VesselStateType) => void; + handleVesselStateTransition: (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => void; +} /** * 선박 액션 관리 훅 - * - * @returns {Object} 선박 상태 관리 함수들 - * @returns {Function} handleDragDrop - 드래그앤드롭 결과 처리 함수 - * @returns {Function} setVesselState - 선박 상태 직접 설정 함수 - * @returns {Function} handleVesselStateTransition - 상태 전환 로직 (디버깅용) - * - * @example - * const { handleDragDrop, setVesselState } = useVesselActions(); - * setVesselState('vessel_001', VesselState.SELECTED); */ -export const useVesselActions = () => { +export const useVesselActions = (): UseVesselActionsResult => { /** * 선박 상태 전환 로직 * * @description ReplayV2의 상태 전환 로직을 재현합니다. * 선박은 일반/선택/삭제 중 하나의 상태만 가질 수 있으며, 상태 간 전환은 DELETE/INSERT 액션으로 수행됩니다. * replayStore.setVesselState를 사용하여 Map/Set 동기화 보장 - * - * @param {string} vesselId - 선박 ID - * @param {'DELETE' | 'INSERT'} action - 수행할 액션 - * @param {boolean} isCurrentlyDeleted - 현재 삭제 상태 여부 - * @param {boolean} isCurrentlySelected - 현재 선택 상태 여부 - * @private */ const handleVesselStateTransition = useCallback( - (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => { + (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => { // 상태 전환 로직 (상호 배타적) - let targetState; + let targetState: VesselStateType; if (action === 'DELETE') { if (isCurrentlyDeleted) { @@ -83,10 +80,9 @@ export const useVesselActions = () => { * 드래그앤드롭 결과 처리 * * @description 드래그앤드롭 이벤트 결과를 상태 전환 로직으로 변환하여 처리합니다 - * @param {Object} result - 드래그앤드롭 결과 (vesselId, sourceState, targetState) */ const handleDragDrop = useCallback( - (result) => { + (result: DragDropResult) => { const { vesselId, sourceState, targetState } = result; // 같은 상태로 드롭하면 무시 @@ -127,15 +123,13 @@ export const useVesselActions = () => { * 선박 상태 직접 설정 * * @description 개별 선박의 상태를 특정 상태로 직접 설정합니다 - * @param {string} vesselId - 선박 ID - * @param {string} targetState - 목표 상태 (NORMAL, SELECTED, DELETED) */ - const setVesselState = useCallback((vesselId, targetState) => { + const setVesselState = useCallback((vesselId: string, targetState: VesselStateType) => { const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState(); const currentlyDeleted = deletedVesselIds.has(vesselId); const currentlySelected = selectedVesselIds.has(vesselId); - const currentState = currentlyDeleted + const currentState: VesselStateType = currentlyDeleted ? VesselState.DELETED : currentlySelected ? VesselState.SELECTED diff --git a/src/replay/components/VesselListManager/hooks/useVesselClassification.js b/src/replay/components/VesselListManager/hooks/useVesselClassification.ts similarity index 74% rename from src/replay/components/VesselListManager/hooks/useVesselClassification.js rename to src/replay/components/VesselListManager/hooks/useVesselClassification.ts index 1b8e89f9..5ee69e4c 100644 --- a/src/replay/components/VesselListManager/hooks/useVesselClassification.js +++ b/src/replay/components/VesselListManager/hooks/useVesselClassification.ts @@ -15,22 +15,45 @@ import { useEffect, useMemo, useState } from 'react'; import useReplayStore from '../../../stores/replayStore'; import useMergedTrackStore from '../../../stores/mergedTrackStore'; -import { VesselState } from '../../../types/replay.types'; +import { VesselState, VesselStateType } from '../../../types/replay.types'; + +/** 분류된 선박 아이템 */ +export interface ClassifiedVesselItem { + vesselId: string; + targetId: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + sigSrcCd: string; + state: VesselStateType; + lastPosition: number[] | undefined; + lastUpdateTime: number; +} + +interface VesselsByState { + normal: ClassifiedVesselItem[]; + selected: ClassifiedVesselItem[]; + deleted: ClassifiedVesselItem[]; +} + +interface TotalCounts { + normal: number; + selected: number; + deleted: number; + total: number; +} + +interface UseVesselClassificationResult { + vesselsByState: VesselsByState; + totalCount: number; + hasVessels: boolean; + totalCounts: TotalCounts; +} /** * 선박 분류 훅 - * - * @returns {Object} 선박 분류 결과 - * @returns {Object} vesselsByState - 상태별로 분류된 선박 목록 - * @returns {number} totalCount - 전체 선박 수 - * @returns {boolean} hasVessels - 선박 존재 여부 - * @returns {Object} totalCounts - 상태별 선박 수 - * - * @example - * const { vesselsByState, totalCount, hasVessels } = useVesselClassification(); - * console.log(vesselsByState.normal); // 일반 상태 선박 목록 */ -export const useVesselClassification = () => { +export const useVesselClassification = (): UseVesselClassificationResult => { const deletedVesselIds = useReplayStore(state => state.deletedVesselIds); const selectedVesselIds = useReplayStore(state => state.selectedVesselIds); const vesselStates = useReplayStore(state => state.vesselStates); @@ -46,10 +69,10 @@ export const useVesselClassification = () => { return unsubscribe; }, []); - const vesselsByState = useMemo(() => { - const normal = []; - const selected = []; - const deleted = []; + const vesselsByState = useMemo((): VesselsByState => { + const normal: ClassifiedVesselItem[] = []; + const selected: ClassifiedVesselItem[] = []; + const deleted: ClassifiedVesselItem[] = []; const vesselChunks = useMergedTrackStore.getState().vesselChunks; @@ -62,9 +85,9 @@ export const useVesselClassification = () => { if (!vesselInfo) return; // 상태 결정 (새로운 시스템 우선, 레거시 시스템 폴백) - let state; + let state: VesselStateType; if (vesselStates instanceof Map && vesselStates.has(vesselId)) { - state = vesselStates.get(vesselId); + state = vesselStates.get(vesselId)!; } else if (deletedVesselIds.has(vesselId)) { state = VesselState.DELETED; } else if (selectedVesselIds.has(vesselId)) { @@ -74,7 +97,7 @@ export const useVesselClassification = () => { } // 선박 정보 생성 - const vesselItem = { + const vesselItem: ClassifiedVesselItem = { vesselId, targetId: vesselInfo.targetId || vesselId.split('_')[1] || vesselId, // 원본 데이터 우선, fallback으로 split shipName: vesselInfo.shipName || 'Unknown', @@ -101,7 +124,7 @@ export const useVesselClassification = () => { }); // 선박명 기준으로 정렬 - const sortByName = (a, b) => + const sortByName = (a: ClassifiedVesselItem, b: ClassifiedVesselItem) => a.shipName.localeCompare(b.shipName); return { diff --git a/src/replay/components/VesselListManager/hooks/useVirtualScroll.js b/src/replay/components/VesselListManager/hooks/useVirtualScroll.ts similarity index 65% rename from src/replay/components/VesselListManager/hooks/useVirtualScroll.js rename to src/replay/components/VesselListManager/hooks/useVirtualScroll.ts index dfb20622..231ad829 100644 --- a/src/replay/components/VesselListManager/hooks/useVirtualScroll.js +++ b/src/replay/components/VesselListManager/hooks/useVirtualScroll.ts @@ -12,32 +12,35 @@ * * @module hooks/useVirtualScroll */ -import React, { useCallback, useMemo, useState, useRef } from 'react'; +import { useCallback, useMemo, useState, useRef, UIEvent } from 'react'; + +interface UseVirtualScrollOptions { + items: T[]; + itemHeight: number; + containerHeight: number; + overscan?: number; +} + +interface UseVirtualScrollResult { + containerRef: React.RefObject; + scrollElementRef: React.RefObject; + handleScroll: (event: UIEvent) => void; + totalHeight: number; + visibleItems: (T & { index: number })[]; + offsetY: number; + startIndex: number; + endIndex: number; +} /** * 가상 스크롤링 훅 - * - * @param {Object} options - 가상 스크롤 설정 - * @param {any[]} options.items - 전체 아이템 배열 - * @param {number} options.itemHeight - 각 아이템의 고정 높이 (px) - * @param {number} options.containerHeight - 컨테이너 높이 (px) - * @param {number} [options.overscan=5] - 화면 밖에서 미리 렌더링할 아이템 수 - * @returns {Object} 가상 스크롤 결과 및 핸들러 - * - * @example - * const { visibleItems, totalHeight, offsetY, handleScroll } = useVirtualScroll({ - * items: vessels, - * itemHeight: 60, - * containerHeight: 400, - * overscan: 5 - * }); */ -export const useVirtualScroll = ({ +export const useVirtualScroll = >({ items, itemHeight, containerHeight, overscan = 5 -}) => { +}: UseVirtualScrollOptions): UseVirtualScrollResult => { const [scrollTop, setScrollTop] = useState(0); // 가상 스크롤 계산 @@ -70,13 +73,13 @@ export const useVirtualScroll = ({ }, [items, itemHeight, containerHeight, scrollTop, overscan]); // 스크롤 이벤트 핸들러 - const handleScroll = useCallback((event) => { + const handleScroll = useCallback((event: UIEvent) => { setScrollTop(event.currentTarget.scrollTop); }, []); // Ref 생성 - const containerRef = useRef(null); - const scrollElementRef = useRef(null); + const containerRef = useRef(null); + const scrollElementRef = useRef(null); return { containerRef, diff --git a/src/replay/components/VesselListManager/index.js b/src/replay/components/VesselListManager/index.ts similarity index 100% rename from src/replay/components/VesselListManager/index.js rename to src/replay/components/VesselListManager/index.ts diff --git a/src/replay/components/VesselListManager/utils/countryCodeUtils.js b/src/replay/components/VesselListManager/utils/countryCodeUtils.ts similarity index 86% rename from src/replay/components/VesselListManager/utils/countryCodeUtils.js rename to src/replay/components/VesselListManager/utils/countryCodeUtils.ts index 1275c173..056c4620 100644 --- a/src/replay/components/VesselListManager/utils/countryCodeUtils.js +++ b/src/replay/components/VesselListManager/utils/countryCodeUtils.ts @@ -3,8 +3,22 @@ * 참조: https://www.vtexplorer.com/mmsi-mid-codes-en/ */ +/** 국가 그룹 (검색 필터용) */ +interface CountryGroup { + countryName: string; + codes: string[]; + displayName: string; + truncatedName: string; +} + +/** 선박 (국적 코드 포함) */ +interface VesselWithNationalCode { + nationalCode: string; + [key: string]: unknown; +} + // MMSI MID 코드와 한글 국가명 매핑 -export const MMSI_COUNTRY_NAMES = { +export const MMSI_COUNTRY_NAMES: Record = { // 유럽 '201': '알바니아', '202': '안도라', @@ -302,10 +316,10 @@ export const MMSI_COUNTRY_NAMES = { // 기타 '000': '알 수 없음', '999': '기타', -}; +} as const; -// MMSI MID → ISO 3166-1 alpha-2 매핑 -const MMSI_TO_ISO = { +// MMSI MID -> ISO 3166-1 alpha-2 매핑 +const MMSI_TO_ISO: Record = { '201': 'AL', '205': 'BE', '206': 'BY', '207': 'BG', '209': 'CY', '210': 'CY', '211': 'DE', '212': 'CY', '213': 'GE', '214': 'MD', '215': 'MT', '216': 'AM', '218': 'DE', '219': 'DK', '220': 'DK', @@ -346,14 +360,14 @@ const MMSI_TO_ISO = { '701': 'AR', '710': 'BR', '720': 'BO', '725': 'CL', '730': 'CO', '735': 'EC', '750': 'GY', '755': 'PY', '760': 'PE', '770': 'UY', '775': 'VE', -}; +} as const; /** - * MMSI MID 코드 → ISO alpha-2 국가코드 변환 - * @param {string} nationalCode MMSI MID 코드 (3자리) - * @returns {string} ISO alpha-2 코드 또는 원본 코드 + * MMSI MID 코드 -> ISO alpha-2 국가코드 변환 + * @param nationalCode MMSI MID 코드 (3자리) + * @returns ISO alpha-2 코드 또는 원본 코드 */ -export const getCountryIsoCode = (nationalCode) => { +export const getCountryIsoCode = (nationalCode: string | undefined | null): string => { if (!nationalCode) return ''; const code = String(nationalCode); return MMSI_TO_ISO[code] || code; @@ -361,23 +375,23 @@ export const getCountryIsoCode = (nationalCode) => { /** * MMSI MID 코드로부터 한글 국가명을 반환 - * @param {string} nationalCode MMSI MID 코드 (3자리 문자열) - * @returns {string} 한글 국가명 또는 "알 수 없음" + * @param nationalCode MMSI MID 코드 (3자리 문자열) + * @returns 한글 국가명 또는 "알 수 없음" */ -export const getCountryNameFromCode = (nationalCode) => { +export const getCountryNameFromCode = (nationalCode: string | undefined | null): string => { if (!nationalCode || nationalCode.length !== 3) { return '알 수 없음'; } - return MMSI_COUNTRY_NAMES[nationalCode] || `알 수 없음`; + return MMSI_COUNTRY_NAMES[nationalCode] || '알 수 없음'; }; /** * 국가 코드와 한글명을 함께 표시하는 형식으로 반환 - * @param {string} nationalCode MMSI MID 코드 - * @returns {string} "한글국가명 (코드)" 형식 문자열 + * @param nationalCode MMSI MID 코드 + * @returns "한글국가명 (코드)" 형식 문자열 */ -export const getCountryDisplayName = (nationalCode) => { +export const getCountryDisplayName = (nationalCode: string | undefined | null): string => { if (!nationalCode || nationalCode.length !== 3) { return '알 수 없음'; } @@ -387,28 +401,28 @@ export const getCountryDisplayName = (nationalCode) => { return `${countryName} (${nationalCode})`; } - return `알 수 없음`; + return '알 수 없음'; }; /** * 검색 필터에서 사용할 국가 목록을 반환 (국가별로 그룹화) - * @param {string[]} availableCodes 현재 데이터에서 사용 중인 국가 코드들 - * @returns {Array} 그룹화되고 정렬된 국가 목록 배열 + * @param availableCodes 현재 데이터에서 사용 중인 국가 코드들 + * @returns 그룹화되고 정렬된 국가 목록 배열 */ -export const getSortedCountryOptions = (availableCodes) => { +export const getSortedCountryOptions = (availableCodes: string[]): CountryGroup[] => { // 국가명별로 코드들을 그룹화 - const countryGroups = new Map(); + const countryGroups = new Map(); availableCodes.forEach(code => { - const countryName = MMSI_COUNTRY_NAMES[code] || `알 수 없음`; + const countryName = MMSI_COUNTRY_NAMES[code] || '알 수 없음'; if (!countryGroups.has(countryName)) { countryGroups.set(countryName, []); } - countryGroups.get(countryName).push(code); + countryGroups.get(countryName)!.push(code); }); // CountryGroup 배열로 변환 - const groupedCountries = Array.from(countryGroups.entries()).map(([countryName, codes]) => { + const groupedCountries = Array.from(countryGroups.entries()).map(([countryName, codes]): CountryGroup => { codes.sort(); // 코드 정렬 const displayName = codes.length === 1 ? `${countryName} (${codes[0]})` : `${countryName} (${codes.join(', ')})`; @@ -440,11 +454,14 @@ export const getSortedCountryOptions = (availableCodes) => { /** * 선택된 국가의 모든 코드가 포함된 선박을 필터링하는 헬퍼 함수 - * @param {Array} vessels 선박 목록 - * @param {Object|null} selectedCountryGroup 선택된 국가 그룹 - * @returns {Array} 필터링된 선박 목록 + * @param vessels 선박 목록 + * @param selectedCountryGroup 선택된 국가 그룹 + * @returns 필터링된 선박 목록 */ -export const filterVesselsByCountryGroup = (vessels, selectedCountryGroup) => { +export const filterVesselsByCountryGroup = ( + vessels: T[], + selectedCountryGroup: CountryGroup | null, +): T[] => { if (!selectedCountryGroup) return vessels; return vessels.filter(vessel => selectedCountryGroup.codes.includes(vessel.nationalCode)); diff --git a/src/replay/hooks/useReplayLayer.js b/src/replay/hooks/useReplayLayer.ts similarity index 78% rename from src/replay/hooks/useReplayLayer.js rename to src/replay/hooks/useReplayLayer.ts index a483c520..7efad39f 100644 --- a/src/replay/hooks/useReplayLayer.js +++ b/src/replay/hooks/useReplayLayer.ts @@ -3,19 +3,22 @@ * * 성능 최적화: * - currentTime은 zustand.subscribe로 React 렌더 바이패스 - * - 정적 레이어(PathLayer) 캐싱 — 필터 변경 시에만 재생성 + * - 정적 레이어(PathLayer) 캐싱 -- 필터 변경 시에만 재생성 * - 동적 레이어(IconLayer, TextLayer, TripsLayer)만 렌더 갱신 * - 재생 중 ~10fps 쓰로틀, seek 시 즉시 렌더 */ import { useEffect, useRef, useCallback } from 'react'; import { TripsLayer } from '@deck.gl/geo-layers'; +import type { Layer } from '@deck.gl/core'; import useMergedTrackStore from '../stores/mergedTrackStore'; import useAnimationStore from '../stores/animationStore'; import useReplayStore from '../stores/replayStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; import useShipStore from '../../stores/shipStore'; import { VesselState, FilterModuleType } from '../types/replay.types'; +import type { VesselStateType, FilterModuleConfig, FilterModules } from '../types/replay.types'; import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; import { hideLiveShips, showLiveShips } from '../../utils/liveControl'; @@ -24,6 +27,56 @@ import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants'; const TRAIL_LENGTH_MS = 3600000; const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps) +// ========== 인터페이스 ========== + +/** 리플레이용 트랙 데이터 */ +interface ReplayTrack { + vesselId: string; + geometry: [number, number][]; + timestamps: (string | number)[]; + timestampsMs: number[]; + speeds: number[]; + shipKindCode: string; + shipName: string; + sigSrcCd: string; +} + +/** TripsLayer용 데이터 */ +interface TripsData { + vesselId: string; + shipKindCode: string; + path: [number, number][]; + timestamps: number[]; +} + +/** 가상 선박 위치 (renderFrame에서 사용) */ +interface FormattedPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipKindCode: string; + shipName: string; +} + +/** 정적 레이어 캐시 의존성 */ +interface StaticLayerCacheDeps { + tracks: ReplayTrack[]; + shipKindCodeFilter: Set; + vesselStates: Map; + deletedVesselIds: Set; + selectedVesselIds: Set; + filterModules: FilterModules; + highlightedVesselId: string | null; +} + +/** 정적 레이어 캐시 */ +interface StaticLayerCache { + layers: Layer[]; + deps: StaticLayerCacheDeps | null; +} + // ========== 이상치 판별 유틸 ========== const MAX_DIST_DEG = 1.0; @@ -32,7 +85,7 @@ const MAX_AVG_SPEED_KNOTS = 50; const DEG_TO_NM = 60; const MS_TO_HOURS = 1 / 3600000; -function isOutlierVessel(geometry, timestampsMs, queryDays) { +function isOutlierVessel(geometry: [number, number][], timestampsMs: number[], queryDays: number): boolean { if (!geometry || geometry.length < 2) return false; const outlierThreshold = Math.max(OUTLIER_PER_DAY * queryDays, OUTLIER_PER_DAY); @@ -70,10 +123,16 @@ function isOutlierVessel(geometry, timestampsMs, queryDays) { return false; } -function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds, selectedVesselIds) { - let state = VesselState.NORMAL; +function shouldShowVessel( + vesselId: string, + filterModule: FilterModuleConfig, + vesselStates: Map, + deletedVesselIds: Set, + selectedVesselIds: Set, +): boolean { + let state: VesselStateType = VesselState.NORMAL; if (vesselStates.has(vesselId)) { - state = vesselStates.get(vesselId); + state = vesselStates.get(vesselId)!; } else if (deletedVesselIds.has(vesselId)) { state = VesselState.DELETED; } else if (selectedVesselIds.has(vesselId)) { @@ -94,13 +153,13 @@ function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds // ========== 메인 훅 ========== -export default function useReplayLayer() { - const tracksRef = useRef([]); - const tripsDataRef = useRef([]); +export default function useReplayLayer(): void { + const tracksRef = useRef([]); + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); // 정적 레이어 캐시 - const staticLayerCacheRef = useRef({ layers: [], deps: null }); + const staticLayerCacheRef = useRef({ layers: [], deps: null }); // React 구독: 필터/상태 (비빈번 변경) const queryCompleted = useReplayStore((s) => s.queryCompleted); @@ -112,14 +171,15 @@ export default function useReplayLayer() { const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId); const setHighlightedVesselId = useReplayStore((s) => s.setHighlightedVesselId); const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled); - // currentTime — React 구독 제거, zustand.subscribe로 대체 + // currentTime -- React 구독 제거, zustand.subscribe로 대체 - const handlePathHover = useCallback((vesselId) => { + const handlePathHover = useCallback((vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId]); - const handleIconHover = useCallback((shipData, x, y) => { + const handleIconHover = useCallback((shipData: FormattedPosition | null, x: number, y: number) => { if (shipData) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- setHoverInfo는 호버 툴팁 전용이라 ShipFeature 전체가 불필요, 필요 필드만 전달 useShipStore.getState().setHoverInfo({ ship: { shipName: shipData.shipName, @@ -127,7 +187,7 @@ export default function useReplayLayer() { signalKindCode: shipData.shipKindCode, sog: shipData.speed || 0, cog: shipData.heading || 0, - }, + } as any, x, y, }); @@ -148,7 +208,7 @@ export default function useReplayLayer() { return; } - const formattedPositions = currentPositions.map((pos) => ({ + const formattedPositions: FormattedPosition[] = currentPositions.map((pos) => ({ vesselId: pos.vesselId, lon: pos.position[0], lat: pos.position[1], @@ -163,8 +223,8 @@ export default function useReplayLayer() { const customFilter = filterModules[FilterModuleType.CUSTOM]; const labelFilter = filterModules[FilterModuleType.LABEL]; - const iconPositions = []; - const labelPositions = []; + const iconPositions: FormattedPosition[] = []; + const labelPositions: FormattedPosition[] = []; formattedPositions.forEach((pos) => { if (!shipKindCodeFilter.has(pos.shipKindCode)) return; @@ -178,14 +238,14 @@ export default function useReplayLayer() { }); // 선종별 카운트 - const counts = {}; + const counts: Record = {}; iconPositions.forEach((pos) => { const code = pos.shipKindCode || SIGNAL_KIND_CODE_NORMAL; counts[code] = (counts[code] || 0) + 1; }); useReplayStore.getState().setReplayShipCounts(counts); - const layers = []; + const layers: Layer[] = []; // 1. TripsLayer 궤적 if (isTrailEnabled && tripsDataRef.current.length > 0) { @@ -198,11 +258,11 @@ export default function useReplayLayer() { const relativeCurrentTime = useAnimationStore.getState().currentTime - startTimeRef.current; layers.push( - new TripsLayer({ + new TripsLayer({ id: 'replay-trips-trail', data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + getPath: (d: TripsData) => d.path, + getTimestamps: (d: TripsData) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -235,8 +295,9 @@ export default function useReplayLayer() { }); staticLayerCacheRef.current = { + // ReplayTrack은 ProcessedTrack의 부분 집합 -- createStaticTrackLayers는 geometry/vesselId/shipKindCode만 사용 layers: createStaticTrackLayers({ - tracks: filteredTracks, + tracks: filteredTracks as unknown as ProcessedTrack[], showPoints: false, highlightedVesselId: currentHighlightedVesselId, onPathHover: handlePathHover, @@ -281,7 +342,7 @@ export default function useReplayLayer() { const chunks = useMergedTrackStore.getState().vesselChunks; if (chunks.size === 0) return; - const tracks = []; + const tracks: ReplayTrack[] = []; chunks.forEach((vc, vesselId) => { const path = useMergedTrackStore.getState().getMergedPath(vesselId); if (!path || path.geometry.length < 2) return; @@ -294,7 +355,7 @@ export default function useReplayLayer() { speeds: path.speeds, shipKindCode: vc.shipKindCode || '000027', shipName: vc.shipName || '', - sigSrcCd: vc.sigSrcCd, + sigSrcCd: vc.sigSrcCd || '', }); }); @@ -307,8 +368,8 @@ export default function useReplayLayer() { const queryDays = Math.max(1, Math.ceil((endTime - startTime) / (24 * 60 * 60 * 1000))); - const tripsData = []; - const outlierVesselIds = []; + const tripsData: TripsData[] = []; + const outlierVesselIds: string[] = []; tracks.forEach((track) => { if (track.geometry.length < 2) return; @@ -336,7 +397,7 @@ export default function useReplayLayer() { /** * 쿼리 완료 시 데이터 빌드 + 자동 재생 - * renderFrame 제외 — rebuildTracksAndTripsData가 vesselStates를 변경하므로 + * renderFrame 제외 -- rebuildTracksAndTripsData가 vesselStates를 변경하므로 * renderFrame이 deps에 있으면 무한 루프 발생 */ useEffect(() => { @@ -357,9 +418,9 @@ export default function useReplayLayer() { }, [queryCompleted, rebuildTracksAndTripsData]); /** - * currentTime 구독 (zustand.subscribe — React 리렌더 바이패스) + * currentTime 구독 (zustand.subscribe -- React 리렌더 바이패스) * 재생 중: ~10fps 쓰로틀, seek/정지: 즉시 렌더 - * renderFrame이 deps → 필터 변경 시 자동 재구독 + 즉시 렌더 + * renderFrame이 deps -> 필터 변경 시 자동 재구독 + 즉시 렌더 */ useEffect(() => { if (!queryCompleted) return; @@ -367,7 +428,7 @@ export default function useReplayLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAnimationStore.subscribe( (s) => s.currentTime, @@ -402,22 +463,22 @@ export default function useReplayLayer() { useEffect(() => { if (!queryCompleted) return; - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { const currentHighlightedId = useReplayStore.getState().highlightedVesselId; if (!currentHighlightedId) return; const { vesselStates: vs, deletedVesselIds: dvi, selectedVesselIds: svi, setVesselState } = useReplayStore.getState(); - let currentState = VesselState.NORMAL; + let currentState: VesselStateType = VesselState.NORMAL; if (vs.has(currentHighlightedId)) { - currentState = vs.get(currentHighlightedId); + currentState = vs.get(currentHighlightedId)!; } else if (dvi.has(currentHighlightedId)) { currentState = VesselState.DELETED; } else if (svi.has(currentHighlightedId)) { currentState = VesselState.SELECTED; } - let targetState = null; + let targetState: VesselStateType | null = null; if (event.key === 'Delete') { if (currentState === VesselState.DELETED) { diff --git a/src/replay/services/ReplayWebSocketService.js b/src/replay/services/ReplayWebSocketService.ts similarity index 78% rename from src/replay/services/ReplayWebSocketService.js rename to src/replay/services/ReplayWebSocketService.ts index 428429af..21af0946 100644 --- a/src/replay/services/ReplayWebSocketService.js +++ b/src/replay/services/ReplayWebSocketService.ts @@ -10,29 +10,58 @@ * * @singleton 애플리케이션 전체에서 하나의 인스턴스만 사용 */ -import { Client } from '@stomp/stompjs'; +import { Client, IMessage, StompSubscription } from '@stomp/stompjs'; import { transformExtent } from 'ol/proj'; import { ConnectionState, + ConnectionStateType, isTrackChunkResponse, isQueryStatusUpdate, - normalizeChunkResponse, - extractTracks, + TrackQueryRequest, + TrackData, + TrackChunkResponse, } from '../types/replay.types'; import useReplayStore from '../stores/replayStore'; import useMergedTrackStore from '../stores/mergedTrackStore'; // WebSocket 엔드포인트 (환경 변수) -const WS_ENDPOINT = import.meta.env.VITE_TRACKING_WS; +const WS_ENDPOINT = import.meta.env.VITE_TRACKING_WS as string; // 타임아웃 설정 const CONNECTION_TIMEOUT = 10000; // 10초 const QUERY_TIMEOUT = 300000; // 5분 +/** 정규화된 청크 응답 (isLastChunk 추가) */ +interface NormalizedChunk extends TrackChunkResponse { + chunkId?: string; + isLastChunk: boolean; +} + +/** 맵 뷰포트 좌표 (EPSG:4326) */ +interface MapViewport { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + /** * ReplayWebSocketService */ class ReplayWebSocketService { + private client: Client | null; + private subscriptions: StompSubscription[]; + private isConnecting: boolean; + private currentQueryId: string | null; + private connectionPromise: Promise | null; + private queryTimeoutId: ReturnType | null; + + // timestamp 기반 진행률 추적 + private queryStartTimestamp: number; + private queryEndTimestamp: number; + private maxReceivedTimestamp: number; + private estimatedProgress: number; + constructor() { this.client = null; this.subscriptions = []; @@ -57,13 +86,8 @@ class ReplayWebSocketService { * 2. 채널 구독 * 3. 쿼리 전송 * 4. 완료 시 자동 정리 - * - * @param {Object} request - 항적 조회 요청 - * @param {string} request.startTime - 시작 시간 (ISO 형식, KST) - * @param {string} request.endTime - 종료 시간 (ISO 형식, KST) - * @param {string[]} [request.vesselIds] - 조회 대상 선박 ID (빈 배열이면 전체 조회) */ - async executeQuery(request) { + async executeQuery(request: TrackQueryRequest): Promise { try { // 이전 쿼리가 진행 중이면 취소 if (this.currentQueryId) { @@ -92,7 +116,7 @@ class ReplayWebSocketService { this._sendQuery(request); } catch (error) { console.error('[ReplayWS] 쿼리 실행 실패:', error); - this._handleError(error); + this._handleError(error as Error); throw error; } } @@ -100,7 +124,7 @@ class ReplayWebSocketService { /** * 쿼리 취소 */ - async cancelQuery() { + async cancelQuery(): Promise { if (!this.currentQueryId) return; try { @@ -120,14 +144,14 @@ class ReplayWebSocketService { /** * 연결 해제 */ - disconnect() { + disconnect(): void { this._cleanup(); } /** * 연결 상태 확인 */ - get connected() { + get connected(): boolean { return this.client?.connected || false; } @@ -136,7 +160,7 @@ class ReplayWebSocketService { /** * 연결 확보 (기존 연결 재사용 또는 새 연결 생성) */ - async _ensureConnected() { + private async _ensureConnected(): Promise { if (this.client?.connected) return; if (this.isConnecting && this.connectionPromise) { @@ -152,7 +176,7 @@ class ReplayWebSocketService { * WebSocket 연결 생성 * 참조: mda-react-front TrackingWebSocketService.createConnection() */ - async _createConnection() { + private async _createConnection(): Promise { this.isConnecting = true; try { @@ -191,7 +215,8 @@ class ReplayWebSocketService { onWebSocketError: (event) => { console.error('[ReplayWS] WebSocket 에러:', event); - if (event.type === 'close' && event.code === 1006) { + const closeEvent = event as CloseEvent; + if (closeEvent.type === 'close' && closeEvent.code === 1006) { this._handleAbnormalClose(); } }, @@ -202,7 +227,7 @@ class ReplayWebSocketService { this.client.activate(); // 연결 완료 대기 (폴링) - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('연결 타임아웃')); }, CONNECTION_TIMEOUT); @@ -229,7 +254,7 @@ class ReplayWebSocketService { * 구독 설정 * 참조: mda-react-front TrackingWebSocketService.setupSubscriptions() */ - _setupSubscriptions() { + private _setupSubscriptions(): void { if (!this.client?.connected) { console.error('[ReplayWS] 구독 설정 실패: 연결되지 않음'); return; @@ -239,31 +264,31 @@ class ReplayWebSocketService { this._clearSubscriptions(); // 1. 청크 데이터 구독 - const chunkSub = this.client.subscribe('/user/queue/tracks/chunk', (message) => { + const chunkSub = this.client.subscribe('/user/queue/tracks/chunk', (message: IMessage) => { this._handleChunkMessage(message); }); this.subscriptions.push(chunkSub); // 2. 상태 업데이트 구독 - const statusSub = this.client.subscribe('/user/queue/tracks/status', (message) => { + const statusSub = this.client.subscribe('/user/queue/tracks/status', (message: IMessage) => { this._handleStatusMessage(message); }); this.subscriptions.push(statusSub); // 3. 쿼리 응답 구독 - const responseSub = this.client.subscribe('/user/queue/tracks/response', (message) => { + const responseSub = this.client.subscribe('/user/queue/tracks/response', (message: IMessage) => { this._handleResponseMessage(message); }); this.subscriptions.push(responseSub); // 4. 에러 구독 - const errorSub = this.client.subscribe('/user/queue/errors', (message) => { + const errorSub = this.client.subscribe('/user/queue/errors', (message: IMessage) => { this._handleErrorMessage(message); }); this.subscriptions.push(errorSub); } - _clearSubscriptions() { + private _clearSubscriptions(): void { this.subscriptions.forEach((sub) => { try { sub.unsubscribe(); } catch (e) { /* ignore */ } }); @@ -279,15 +304,13 @@ class ReplayWebSocketService { * - startTime을 HH:00:00 형식으로 정규화 (hourly 테이블 최적화) * - chunkedMode: true, chunkSize: 20000 강제 설정 */ - _sendQuery(request) { + private _sendQuery(request: TrackQueryRequest): void { if (!this.client?.connected) { throw new Error('WebSocket이 연결되지 않았습니다'); } // 시작 시간 정규화: HH:00:00 (hourly 테이블 최적화) - const startTimeStr = typeof request.startTime === 'string' - ? request.startTime - : request.startTime.toString(); + const startTimeStr = request.startTime; const [datePart, timePart] = startTimeStr.split('T'); const [hour] = timePart ? timePart.split(':') : ['00']; const normalizedStartTime = `${datePart}T${hour}:00:00`; @@ -306,7 +329,7 @@ class ReplayWebSocketService { const zoomLevel = this._getMapZoomLevel(); // 요청 객체 구성 (메인 프로젝트와 동일) - const enrichedRequest = { + const enrichedRequest: TrackQueryRequest = { ...request, startTime: normalizedStartTime, vesselIds, @@ -341,9 +364,9 @@ class ReplayWebSocketService { * 청크 메시지 처리 * 참조: mda-react-front TrackingWebSocketService.handleChunkMessage() */ - _handleChunkMessage(message) { + private _handleChunkMessage(message: IMessage): void { try { - const chunk = JSON.parse(message.body); + const chunk = JSON.parse(message.body) as Record; const normalized = this._normalizeChunkResponse(chunk); if (!isTrackChunkResponse(normalized)) { @@ -351,7 +374,7 @@ class ReplayWebSocketService { return; } - const tracks = extractTracks(normalized); + const tracks = normalized.tracks || normalized.mergedTracks || normalized.compactTracks || []; if (!tracks || tracks.length === 0) return; // tracks를 정규화된 필드로 설정 @@ -380,9 +403,9 @@ class ReplayWebSocketService { * 상태 메시지 처리 * 참조: mda-react-front TrackingWebSocketService.handleStatusMessage() */ - _handleStatusMessage(message) { + private _handleStatusMessage(message: IMessage): void { try { - const status = JSON.parse(message.body); + const status = JSON.parse(message.body) as Record; if (!isQueryStatusUpdate(status)) { console.error('[ReplayWS] 잘못된 상태 형식:', status); @@ -395,12 +418,12 @@ class ReplayWebSocketService { } if (status.status === 'ERROR') { - this._handleError(new Error(status.error || '쿼리 처리 중 오류 발생')); + this._handleError(new Error((status.error as string) || '쿼리 처리 중 오류 발생')); } // 서버에서 queryId를 반환하면 저장 if (status.queryId) { - useReplayStore.getState().setQueryId(status.queryId); + useReplayStore.getState().setQueryId(status.queryId as string); } } catch (error) { console.error('[ReplayWS] 상태 처리 오류:', error); @@ -410,9 +433,9 @@ class ReplayWebSocketService { /** * 응답 메시지 처리 */ - _handleResponseMessage(message) { + private _handleResponseMessage(message: IMessage): void { try { - const response = JSON.parse(message.body); + const response = JSON.parse(message.body) as { queryId?: string }; if (response.queryId) { useReplayStore.getState().setQueryId(response.queryId); @@ -425,7 +448,7 @@ class ReplayWebSocketService { /** * 에러 메시지 처리 */ - _handleErrorMessage(message) { + private _handleErrorMessage(message: IMessage): void { console.error('[ReplayWS] 서버 에러:', message.body); this._handleError(new Error(message.body)); } @@ -436,12 +459,12 @@ class ReplayWebSocketService { * timestamp 기반 진행률 업데이트 * 참조: mda-react-front TrackingWebSocketService.updateProgressByTimestamp() */ - _updateProgressByTimestamp(tracks, isLastChunk) { + private _updateProgressByTimestamp(tracks: TrackData[], isLastChunk: boolean): void { try { tracks.forEach((track) => { if (track.timestamps && Array.isArray(track.timestamps)) { track.timestamps.forEach((ts) => { - const timestamp = typeof ts === 'number' ? ts : parseInt(ts, 10); + const timestamp = typeof ts === 'number' ? ts : parseInt(ts as string, 10); if (timestamp > this.maxReceivedTimestamp) { this.maxReceivedTimestamp = timestamp; } @@ -472,7 +495,7 @@ class ReplayWebSocketService { /** * 쿼리 완료 처리 */ - _handleQueryComplete() { + private _handleQueryComplete(): void { this._clearQueryTimeout(); useReplayStore.setState({ progress: 100 }); @@ -484,10 +507,10 @@ class ReplayWebSocketService { /** * 에러 처리 */ - _handleError(error) { + private _handleError(error: Error): void { console.error('[ReplayWS] 에러:', error); - const userMessage = this._getUserFriendlyError(error); + this._getUserFriendlyError(error); useReplayStore.setState({ connectionState: ConnectionState.ERROR, }); @@ -498,7 +521,7 @@ class ReplayWebSocketService { /** * 비정상 종료 처리 (버퍼 오버플로우 등) */ - _handleAbnormalClose() { + private _handleAbnormalClose(): void { console.error('[ReplayWS] 비정상 종료 (버퍼 오버플로우 가능)'); this._cleanup(); } @@ -506,7 +529,7 @@ class ReplayWebSocketService { /** * 사용자 친화적 에러 메시지 */ - _getUserFriendlyError(error) { + private _getUserFriendlyError(error: Error): string { const message = error.message.toLowerCase(); if (message.includes('timeout')) return '서버 응답 시간이 초과되었습니다.'; if (message.includes('network') || message.includes('connect')) return '네트워크 연결을 확인해주세요.'; @@ -515,7 +538,7 @@ class ReplayWebSocketService { // ===== 타임아웃 (private) ===== - _setQueryTimeout() { + private _setQueryTimeout(): void { this._clearQueryTimeout(); this.queryTimeoutId = setTimeout(() => { console.error('[ReplayWS] 쿼리 타임아웃'); @@ -523,7 +546,7 @@ class ReplayWebSocketService { }, QUERY_TIMEOUT); } - _clearQueryTimeout() { + private _clearQueryTimeout(): void { if (this.queryTimeoutId) { clearTimeout(this.queryTimeoutId); this.queryTimeoutId = null; @@ -532,7 +555,7 @@ class ReplayWebSocketService { // ===== 연결 상태 (private) ===== - _updateConnectionState(state) { + private _updateConnectionState(state: ConnectionStateType): void { useReplayStore.getState().setConnectionState(state); } @@ -542,7 +565,7 @@ class ReplayWebSocketService { * 전체 정리 * 참조: mda-react-front TrackingWebSocketService.cleanup() */ - _cleanup() { + private _cleanup(): void { this._clearQueryTimeout(); this._clearSubscriptions(); @@ -563,11 +586,11 @@ class ReplayWebSocketService { /** * OpenLayers 맵에서 현재 뷰포트 좌표 추출 * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx getMapViewport() - * @returns {{ minLon: number, maxLon: number, minLat: number, maxLat: number } | undefined} */ - _getMapViewport() { + private _getMapViewport(): MapViewport | undefined { try { - const map = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 OL Map 인스턴스 (글로벌 접근) + const map = (window as any).__mainMap__; if (!map) { console.warn('[ReplayWS] 맵 인스턴스 없음 (window.__mainMap__)'); return undefined; @@ -587,9 +610,10 @@ class ReplayWebSocketService { /** * 현재 맵 줌 레벨 */ - _getMapZoomLevel() { + private _getMapZoomLevel(): number { try { - const map = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 OL Map 인스턴스 (글로벌 접근) + const map = (window as any).__mainMap__; if (!map) return 10; return Math.round(map.getView().getZoom()) || 10; } catch { @@ -599,25 +623,25 @@ class ReplayWebSocketService { // ===== 유틸 (private) ===== - _normalizeChunkResponse(chunk) { + private _normalizeChunkResponse(chunk: Record): NormalizedChunk { return { - queryId: chunk.queryId, - chunkId: chunk.chunkId || `chunk_${chunk.chunkIndex}`, - chunkIndex: chunk.chunkIndex, - totalChunks: chunk.totalChunks, - tracks: chunk.tracks, - mergedTracks: chunk.mergedTracks, - compactTracks: chunk.compactTracks, - isLastChunk: chunk.isLastChunk || false, - metadata: chunk.metadata, + queryId: chunk.queryId as string, + chunkId: (chunk.chunkId as string) || `chunk_${chunk.chunkIndex}`, + chunkIndex: chunk.chunkIndex as number, + totalChunks: (chunk.totalChunks as number | null) ?? null, + tracks: chunk.tracks as TrackData[] | undefined, + mergedTracks: chunk.mergedTracks as TrackData[] | undefined, + compactTracks: chunk.compactTracks as TrackData[] | undefined, + isLastChunk: (chunk.isLastChunk as boolean) || false, + metadata: chunk.metadata as Record | null, }; } } // 싱글톤 인스턴스 -let instance = null; +let instance: ReplayWebSocketService | null = null; -export function getReplayWebSocketService() { +export function getReplayWebSocketService(): ReplayWebSocketService { if (!instance) { instance = new ReplayWebSocketService(); } diff --git a/src/replay/stores/animationStore.js b/src/replay/stores/animationStore.ts similarity index 81% rename from src/replay/stores/animationStore.js rename to src/replay/stores/animationStore.ts index 03b885a1..6c08230d 100644 --- a/src/replay/stores/animationStore.js +++ b/src/replay/stores/animationStore.ts @@ -8,7 +8,54 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import useMergedTrackStore from './mergedTrackStore'; -import useReplayStore from './replayStore'; + +// ===== 인터페이스 ===== + +interface TimeRange { + start: number; + end: number; +} + +interface VesselPosition { + vesselId: string; + position: [number, number]; + heading: number; + speed: number; + timestamp: number; + shipKindCode: string; + shipName: string; + sigSrcCd?: string; +} + +interface AnimationState { + // ========== 재생 상태 ========== + isPlaying: boolean; + playbackState: PlaybackStateType; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + loop: boolean; + loopEnabled: boolean; + + // ========== 내부 상태 ========== + animationFrameId: number | null; + lastFrameTime: number; + + // ========== 액션 ========== + play: () => void; + pause: () => void; + stop: () => void; + seekTo: (time: number) => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + toggleLoop: () => void; + updateTimeRange: () => TimeRange; + setTimeRange: (startTime: number, endTime: number) => void; + getProgress: () => number; + getCurrentVesselPositions: () => VesselPosition[]; + reset: () => void; +} /** * 재생 상태 (레거시 호환) @@ -18,12 +65,14 @@ export const PlaybackState = { PLAYING: 'PLAYING', PAUSED: 'PAUSED', STOPPED: 'STOPPED', -}; +} as const; + +export type PlaybackStateType = (typeof PlaybackState)[keyof typeof PlaybackState]; /** * 두 지점 간의 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: [number, number], p2: [number, number]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -36,7 +85,13 @@ function calculateHeading(p1, p2) { /** * 두 지점 사이의 위치를 시간 기반으로 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition( + p1: [number, number], + p2: [number, number], + t1: number, + t2: number, + currentTime: number, +): [number, number] { if (t1 === t2) return p1; const ratio = (currentTime - t1) / (t2 - t1); return [ @@ -48,7 +103,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 청크 기반 데이터에서 시간 범위 추출 */ -function getTimeRangeFromVessels() { +function getTimeRangeFromVessels(): TimeRange | null { const vesselChunks = useMergedTrackStore.getState().vesselChunks; if (vesselChunks.size === 0) { @@ -74,12 +129,12 @@ function getTimeRangeFromVessels() { } // 커서 기반 선형 탐색용 (vesselId → lastIndex) -const positionCursors = new Map(); +const positionCursors = new Map(); /** * 애니메이션 스토어 */ -const useAnimationStore = create(subscribeWithSelector((set, get) => ({ +const useAnimationStore = create()(subscribeWithSelector((set, get) => ({ // ========== 재생 상태 ========== isPlaying: false, playbackState: PlaybackState.IDLE, // 레거시 호환 @@ -133,7 +188,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ }); // 애니메이션 루프 시작 - const animate = (timestamp) => { + const animate = (timestamp: number) => { const state = get(); if (!state.isPlaying) return; @@ -207,7 +262,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 특정 시간으로 이동 */ - seekTo: (time) => { + seekTo: (time: number) => { const state = get(); const clampedTime = Math.max(state.startTime, Math.min(time, state.endTime)); set({ currentTime: clampedTime }); @@ -216,14 +271,14 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 현재 시간 설정 (레거시 호환) */ - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { set({ currentTime: time }); }, /** * 재생 속도 설정 */ - setPlaybackSpeed: (speed) => { + setPlaybackSpeed: (speed: number) => { set({ playbackSpeed: speed }); }, @@ -240,7 +295,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 시간 범위 업데이트 */ - updateTimeRange: () => { + updateTimeRange: (): TimeRange => { const timeRange = getTimeRangeFromVessels(); if (timeRange) { const state = get(); @@ -264,7 +319,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 시간 범위 직접 설정 */ - setTimeRange: (startTime, endTime) => { + setTimeRange: (startTime: number, endTime: number) => { set({ startTime, endTime, @@ -275,7 +330,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 진행률 계산 (0 ~ 100) */ - getProgress: () => { + getProgress: (): number => { const { currentTime, startTime, endTime } = get(); if (endTime === startTime) return 0; return ((currentTime - startTime) / (endTime - startTime)) * 100; @@ -286,10 +341,10 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ * 재생 중: 커서에서 선형 전진 O(1~2) * seek/역방향: 이진 탐색 fallback O(log n) */ - getCurrentVesselPositions: () => { + getCurrentVesselPositions: (): VesselPosition[] => { const { currentTime } = get(); const vesselChunks = useMergedTrackStore.getState().vesselChunks; - const positions = []; + const positions: VesselPosition[] = []; vesselChunks.forEach((vessel, vesselId) => { if (!vessel) return; @@ -332,9 +387,9 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let finalPosition; - let heading; - let speed; + let finalPosition: [number, number]; + let heading: number; + let speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { finalPosition = mergedPath.geometry[idx1]; diff --git a/src/replay/stores/mergedTrackStore.js b/src/replay/stores/mergedTrackStore.ts similarity index 67% rename from src/replay/stores/mergedTrackStore.js rename to src/replay/stores/mergedTrackStore.ts index bec13ea8..fced726d 100644 --- a/src/replay/stores/mergedTrackStore.js +++ b/src/replay/stores/mergedTrackStore.ts @@ -5,12 +5,64 @@ * 청크 기반 선박 항적 데이터 저장 및 관리 */ import { create } from 'zustand'; -import { parseTimestamp } from '../types/replay.types'; +import { parseTimestamp, TrackData, TrackChunkResponse } from '../types/replay.types'; + +// ===== 인터페이스 ===== + +interface CachedPath { + geometry: [number, number][]; + timestamps: (string | number)[]; + timestampsMs: number[]; + speeds: number[]; + lastUpdated: number; +} + +interface VesselChunks { + vesselId: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + chunks: TrackData[]; + cachedPath: CachedPath | null; + totalDistance: number; + maxSpeed: number; + avgSpeed: number; +} + +interface TimeRange { + start: number; + end: number; +} + +interface SpatialBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +interface MergedTrackState { + // ===== 상태 ===== + vesselChunks: Map; + rawChunks: TrackChunkResponse[]; + timeRange: TimeRange | null; + spatialBounds: SpatialBounds | null; + + // ===== 액션 ===== + addChunkOptimized: (chunkResponse: TrackChunkResponse) => void; + getMergedPath: (vesselId: string) => CachedPath | null; + addRawChunk: (chunkResponse: TrackChunkResponse) => void; + clear: () => void; + getAllVesselIds: () => string[]; + getVesselChunks: (vesselId: string) => VesselChunks | null; +} /** * 청크 기반 선박 데이터 병합 */ -function mergeVesselChunks(existingChunks, newChunk) { +function mergeVesselChunks(existingChunks: VesselChunks | undefined, newChunk: TrackData): VesselChunks { if (!existingChunks) { return { vesselId: newChunk.vesselId, @@ -18,6 +70,7 @@ function mergeVesselChunks(existingChunks, newChunk) { targetId: newChunk.targetId, shipName: newChunk.shipName, shipKindCode: newChunk.shipKindCode, + nationalCode: newChunk.nationalCode, chunks: [newChunk], cachedPath: null, totalDistance: newChunk.totalDistance || 0, @@ -28,8 +81,8 @@ function mergeVesselChunks(existingChunks, newChunk) { // 기존 청크에 새 청크 추가 (시간순 정렬) const chunks = [...existingChunks.chunks, newChunk].sort((a, b) => { - const timeA = parseTimestamp(a.timestamps[0]); - const timeB = parseTimestamp(b.timestamps[0]); + const timeA = parseTimestamp(a.timestamps![0]); + const timeB = parseTimestamp(b.timestamps![0]); return timeA - timeB; }); @@ -45,11 +98,11 @@ function mergeVesselChunks(existingChunks, newChunk) { /** * 병합된 경로 생성 (캐싱) */ -function buildCachedPath(chunks) { - const geometry = []; - const timestamps = []; - const timestampsMs = []; - const speeds = []; +function buildCachedPath(chunks: TrackData[]): CachedPath { + const geometry: [number, number][] = []; + const timestamps: (string | number)[] = []; + const timestampsMs: number[] = []; + const speeds: number[] = []; chunks.forEach((chunk) => { if (chunk.geometry) { @@ -78,7 +131,7 @@ function buildCachedPath(chunks) { /** * MergedTrackStore */ -const useMergedTrackStore = create((set, get) => ({ +const useMergedTrackStore = create((set, get) => ({ // ===== 상태 ===== // 청크 기반 저장소 (메인) @@ -96,7 +149,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 청크 추가 (최적화) */ - addChunkOptimized: (chunkResponse) => { + addChunkOptimized: (chunkResponse: TrackChunkResponse) => { const tracks = chunkResponse.tracks || []; set((state) => { @@ -136,7 +189,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 병합된 경로 반환 (캐시 사용) */ - getMergedPath: (vesselId) => { + getMergedPath: (vesselId: string): CachedPath | null => { const vesselChunks = get().vesselChunks.get(vesselId); if (!vesselChunks) return null; @@ -164,7 +217,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 원본 청크 추가 */ - addRawChunk: (chunkResponse) => { + addRawChunk: (chunkResponse: TrackChunkResponse) => { set((state) => ({ rawChunks: [...state.rawChunks, chunkResponse], })); @@ -185,14 +238,14 @@ const useMergedTrackStore = create((set, get) => ({ /** * 모든 선박 ID 반환 */ - getAllVesselIds: () => { + getAllVesselIds: (): string[] => { return Array.from(get().vesselChunks.keys()); }, /** * 선박 데이터 반환 */ - getVesselChunks: (vesselId) => { + getVesselChunks: (vesselId: string): VesselChunks | null => { return get().vesselChunks.get(vesselId) || null; }, })); diff --git a/src/replay/stores/playbackTrailStore.js b/src/replay/stores/playbackTrailStore.ts similarity index 79% rename from src/replay/stores/playbackTrailStore.js rename to src/replay/stores/playbackTrailStore.ts index d1994b96..f54818e1 100644 --- a/src/replay/stores/playbackTrailStore.js +++ b/src/replay/stores/playbackTrailStore.ts @@ -13,6 +13,68 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +// ===== 인터페이스 ===== + +interface TrailPoint { + lon: number; + lat: number; + frameIndex: number; + vesselId: string; +} + +interface LastPosition { + lon: number; + lat: number; +} + +interface RecordFramePosition { + vesselId: string; + lon: number; + lat: number; + shipKindCode?: string; +} + +interface TrailConfig { + maxFrames?: number; + maxPointSize?: number; + minPointSize?: number; +} + +interface PlaybackTrailState { + /** 항적표시 토글 상태 */ + isEnabled: boolean; + /** 선박별 항적 포인트 Map (vesselId -> TrailPoint[]) */ + trails: Map; + /** 선박별 마지막 기록 위치 (거리 필터용) */ + lastPositions: Map; + /** 선박별 선종 코드 (필터 동기화용) */ + vesselKindCodes: Map; + /** 현재 프레임 인덱스 */ + frameIndex: number; + /** 프로그레스 바 스크러빙 중 여부 */ + isScrubbing: boolean; + /** 현재 재생 배속 */ + playbackSpeed: number; + /** 유지할 최대 프레임 수 */ + maxFrames: number; + /** 포인트 최대 크기 (px) */ + maxPointSize: number; + /** 포인트 최소 크기 (px) */ + minPointSize: number; + + // ===== 액션 ===== + setEnabled: (enabled: boolean) => void; + clearTrails: () => void; + setScrubbing: (scrubbing: boolean) => void; + removeTrailsByFilter: (activeKindCodes: Set) => void; + recordFrame: (positions: RecordFramePosition[]) => void; + getVisiblePoints: () => TrailPoint[]; + getOpacity: (pointFrameIndex: number) => number; + getPointSize: (pointFrameIndex: number) => number; + updatePlaybackSpeed: (speed: number) => void; + setConfig: (config: TrailConfig) => void; +} + // 프레임 설정 (역비례 계산 — 배속 무관 동일 시각적 궤적 길이) const REFERENCE_SPEED = 1000; // 기준 배속 (1000x에서 궤적 길이가 적절) const REFERENCE_FRAMES = 60; // 기준 배속에서의 프레임 수 @@ -34,7 +96,7 @@ const MIN_TRAIL_DISTANCE_SQ = 0.000001; * * 1000x: 60, 500x: 120, 100x: 150(cap), 50x~1x: 150(cap) */ -const calculateMaxFrames = (playbackSpeed) => { +const calculateMaxFrames = (playbackSpeed: number): number => { if (playbackSpeed <= 0) return REFERENCE_FRAMES; const frames = Math.round(REFERENCE_FRAMES * REFERENCE_SPEED / playbackSpeed); return Math.max(MIN_MAX_FRAMES, Math.min(MAX_MAX_FRAMES, frames)); @@ -43,7 +105,7 @@ const calculateMaxFrames = (playbackSpeed) => { /** * 재생 항적 스토어 */ -const usePlaybackTrailStore = create( +const usePlaybackTrailStore = create()( subscribeWithSelector((set, get) => ({ // ========== 상태 ========== @@ -82,7 +144,7 @@ const usePlaybackTrailStore = create( /** * 토글 ON/OFF */ - setEnabled: (enabled) => { + setEnabled: (enabled: boolean) => { if (!enabled) { set({ isEnabled: false, @@ -113,7 +175,7 @@ const usePlaybackTrailStore = create( * true: 궤적 클리어 + 기록 중단 * false: 기록 재개 (재생 상태이면 자동으로 다시 그려짐) */ - setScrubbing: (scrubbing) => { + setScrubbing: (scrubbing: boolean) => { if (scrubbing) { set({ isScrubbing: true, @@ -130,9 +192,8 @@ const usePlaybackTrailStore = create( /** * 선종 필터와 궤적 동기화 * activeKindCodes에 포함되지 않는 선종의 궤적을 즉시 제거 - * @param {Set} activeKindCodes - 현재 활성화된 선종 코드 Set */ - removeTrailsByFilter: (activeKindCodes) => { + removeTrailsByFilter: (activeKindCodes: Set) => { const state = get(); if (state.trails.size === 0) return; @@ -162,9 +223,8 @@ const usePlaybackTrailStore = create( /** * 프레임 기록 (매 렌더링마다 호출) * 거리 기반 필터: 이전 위치와 비교해 MIN_TRAIL_DISTANCE_SQ 미만이면 스킵 - * @param {Array<{vesselId: string, lon: number, lat: number, shipKindCode: string}>} positions */ - recordFrame: (positions) => { + recordFrame: (positions: RecordFramePosition[]) => { const state = get(); if (!state.isEnabled || state.isScrubbing || positions.length === 0) return; @@ -236,11 +296,10 @@ const usePlaybackTrailStore = create( /** * 모든 가시 포인트 반환 (렌더링용) - * @returns {Array} */ - getVisiblePoints: () => { + getVisiblePoints: (): TrailPoint[] => { const state = get(); - const result = []; + const result: TrailPoint[] = []; state.trails.forEach((points) => { for (let i = 0; i < points.length; i++) { @@ -257,7 +316,7 @@ const usePlaybackTrailStore = create( /** * 포인트 투명도 계산 (0~1) */ - getOpacity: (pointFrameIndex) => { + getOpacity: (pointFrameIndex: number): number => { const state = get(); const frameAge = state.frameIndex - pointFrameIndex; if (frameAge >= state.maxFrames) return 0; @@ -268,7 +327,7 @@ const usePlaybackTrailStore = create( /** * 포인트 크기 계산 (px) */ - getPointSize: (pointFrameIndex) => { + getPointSize: (pointFrameIndex: number): number => { const state = get(); const frameAge = state.frameIndex - pointFrameIndex; if (frameAge >= state.maxFrames) return state.minPointSize; @@ -280,7 +339,7 @@ const usePlaybackTrailStore = create( /** * 재생 배속 업데이트 (maxFrames 자동 재계산) */ - updatePlaybackSpeed: (speed) => { + updatePlaybackSpeed: (speed: number) => { set({ playbackSpeed: speed, maxFrames: calculateMaxFrames(speed), @@ -294,7 +353,7 @@ const usePlaybackTrailStore = create( /** * 설정 변경 */ - setConfig: (config) => { + setConfig: (config: TrailConfig) => { set({ maxFrames: config.maxFrames ?? get().maxFrames, maxPointSize: config.maxPointSize ?? get().maxPointSize, diff --git a/src/replay/stores/replayStore.js b/src/replay/stores/replayStore.ts similarity index 77% rename from src/replay/stores/replayStore.js rename to src/replay/stores/replayStore.ts index e5bef0fc..f1a2ee56 100644 --- a/src/replay/stores/replayStore.js +++ b/src/replay/stores/replayStore.ts @@ -8,9 +8,15 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { ConnectionState, + ConnectionStateType, VesselState, + VesselStateType, FilterModuleType, DEFAULT_FILTER_MODULE, + FilterModules, + FilterModuleConfig, + FilterModuleTypeValue, + TrackQueryRequest, } from '../types/replay.types'; import useMergedTrackStore from './mergedTrackStore'; import usePlaybackTrailStore from './playbackTrailStore'; @@ -31,10 +37,73 @@ import { SIGNAL_KIND_CODE_BUOY, } from '../../types/constants'; +// ===== 인터페이스 ===== + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface Viewport { [key: string]: any; } // TODO: OL viewport type + +interface ReplayState { + // ===== 쿼리/연결 상태 ===== + currentQuery: TrackQueryRequest | null; + queryId: string | null; + connectionState: ConnectionStateType; + queryCompleted: boolean; + + // ===== 진행률 ===== + progress: number; + receivedChunks: number; + totalChunks: number | null; + lastReceivedTimestamp: number | null; + + // ===== 필터 ===== + sigSrcCdFilter: Set; + shipKindCodeFilter: Set; + + // ===== 선박 상태 ===== + vesselStates: Map; + deletedVesselIds: Set; + selectedVesselIds: Set; + + // ===== 필터 모듈 (3계층) ===== + filterModules: FilterModules; + + // ===== 뷰포트 ===== + currentViewport: Viewport | null; + currentZoomLevel: number; + + // ===== 리플레이 선종별 카운트 ===== + replayShipCounts: Record; + replayTotalCount: number; + + // ===== 하이라이트 ===== + highlightedVesselId: string | null; + + // ===== 액션 ===== + setCurrentQuery: (query: TrackQueryRequest | null) => void; + setQueryId: (queryId: string | null) => void; + setConnectionState: (state: ConnectionStateType) => void; + setQueryCompleted: (completed: boolean) => void; + updateProgress: (received: number, total: number | null, timestamp: number) => void; + setProgress: (progress: number) => void; + toggleSigSrcCd: (code: string) => void; + toggleShipKindCode: (code: string) => void; + setVesselState: (vesselId: string, state: VesselStateType) => void; + getVesselState: (vesselId: string) => VesselStateType; + toggleVesselState: (vesselId: string, targetState: VesselStateType) => void; + clearVesselState: (vesselId: string) => void; + updateFilterModule: (moduleId: FilterModuleTypeValue, config: Partial) => void; + setFilterModuleAll: (moduleId: FilterModuleTypeValue, enabled: boolean) => void; + setReplayShipCounts: (counts: Record) => void; + setHighlightedVesselId: (vesselId: string | null) => void; + setViewport: (viewport: Viewport | null) => void; + setZoomLevel: (zoom: number) => void; + reset: () => void; +} + /** * 초기 선종별 카운트 (리플레이용) */ -const initialReplayShipCounts = { +const initialReplayShipCounts: Record = { [SIGNAL_KIND_CODE_FISHING]: 0, [SIGNAL_KIND_CODE_KCGV]: 0, [SIGNAL_KIND_CODE_PASSENGER]: 0, @@ -74,7 +143,7 @@ const initialShipKindCodeFilter = new Set([ /** * ReplayStore */ -const useReplayStore = create( +const useReplayStore = create()( subscribeWithSelector((set, get) => ({ // ===== 쿼리/연결 상태 ===== currentQuery: null, // TrackQueryRequest @@ -142,7 +211,7 @@ const useReplayStore = create( receivedChunks: received, totalChunks: total, lastReceivedTimestamp: timestamp, - progress: total > 0 ? (received / total) * 100 : 0, + progress: total !== null && total > 0 ? (received / total) * 100 : 0, }), setProgress: (progress) => set({ progress }), diff --git a/src/replay/types/replay.types.js b/src/replay/types/replay.types.ts similarity index 55% rename from src/replay/types/replay.types.js rename to src/replay/types/replay.types.ts index 30e9661e..d229dffc 100644 --- a/src/replay/types/replay.types.js +++ b/src/replay/types/replay.types.ts @@ -11,7 +11,9 @@ export const ConnectionState = { CONNECTING: 'CONNECTING', CONNECTED: 'CONNECTED', ERROR: 'ERROR', -}; +} as const; + +export type ConnectionStateType = (typeof ConnectionState)[keyof typeof ConnectionState]; // ===================================== // 쿼리 상태 @@ -21,7 +23,9 @@ export const QueryStatus = { COMPLETED: 'COMPLETED', ERROR: 'ERROR', CANCELLED: 'CANCELLED', -}; +} as const; + +export type QueryStatusType = (typeof QueryStatus)[keyof typeof QueryStatus]; // ===================================== // 선박 상태 (기본/선택/삭제) @@ -30,7 +34,9 @@ export const VesselState = { NORMAL: 'NORMAL', SELECTED: 'SELECTED', DELETED: 'DELETED', -}; +} as const; + +export type VesselStateType = (typeof VesselState)[keyof typeof VesselState]; // ===================================== // 간소화 모드 @@ -39,12 +45,12 @@ export const SimplificationMode = { AUTO: 'AUTO', // 자동 (zoom 기반) ADAPTIVE: 'ADAPTIVE', // 적응형 AGGRESSIVE: 'AGGRESSIVE', // 공격적 (최대 압축) -}; +} as const; // ===================================== // 배속 옵션 // ===================================== -export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000]; +export const PLAYBACK_SPEEDS: readonly number[] = [1, 10, 50, 100, 500, 1000]; // ===================================== // 필터 모듈 타입 @@ -53,17 +59,94 @@ export const FilterModuleType = { CUSTOM: 'custom', // 선박 아이콘 PATH: 'path', // 항적 라인 LABEL: 'label', // 라벨 -}; +} as const; + +export type FilterModuleTypeValue = (typeof FilterModuleType)[keyof typeof FilterModuleType]; + +// ===================================== +// 필터 모듈 설정 인터페이스 +// ===================================== +export interface FilterModuleConfig { + showNormal: boolean; + showSelected: boolean; + showDeleted: boolean; +} + +export interface FilterModules { + [FilterModuleType.CUSTOM]: FilterModuleConfig; + [FilterModuleType.PATH]: FilterModuleConfig; + [FilterModuleType.LABEL]: FilterModuleConfig; +} // ===================================== // 기본 필터 설정 // ===================================== -export const DEFAULT_FILTER_MODULE = { +export const DEFAULT_FILTER_MODULE: FilterModuleConfig = { showNormal: true, showSelected: true, showDeleted: false, }; +// ===================================== +// 트랙 관련 인터페이스 +// ===================================== + +export interface TrackChunkResponse { + queryId: string; + chunkIndex: number; + totalChunks?: number | null; + tracks?: TrackData[]; + mergedTracks?: TrackData[]; + compactTracks?: TrackData[]; + isLastChunk?: boolean; + metadata?: Record | null; + estimatedSize?: number; +} + +export interface TrackData { + vesselId: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + geometry?: [number, number][]; + timestamps?: (string | number)[]; + speeds?: number[]; + totalDistance?: number; + maxSpeed?: number; + avgSpeed?: number; +} + +export interface QueryStatusUpdate { + queryId: string; + status: QueryStatusType; + error?: string; +} + +export interface NormalizedChunkResponse { + queryId: string; + chunkIndex: number; + totalChunks: number | null; + tracks: TrackData[]; + estimatedSize: number; + metadata: Record | null; +} + +export interface TrackQueryRequest { + startTime: string; + endTime: string; + vesselIds?: string[]; + chunkedMode?: boolean; + chunkSize?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewport?: any; // TODO: OL viewport type + simplificationMode?: string; + zoomLevel?: number; + minDistance?: number; + isIntegration?: string; +} + // ===================================== // 요청/응답 타입 체크 // ===================================== @@ -71,28 +154,30 @@ export const DEFAULT_FILTER_MODULE = { /** * TrackChunkResponse 타입 체크 */ -export function isTrackChunkResponse(data) { +export function isTrackChunkResponse(data: unknown): data is TrackChunkResponse { return ( - data && + data !== null && + data !== undefined && typeof data === 'object' && - typeof data.queryId === 'string' && - typeof data.chunkIndex === 'number' && - (Array.isArray(data.tracks) || - Array.isArray(data.mergedTracks) || - Array.isArray(data.compactTracks)) + typeof (data as TrackChunkResponse).queryId === 'string' && + typeof (data as TrackChunkResponse).chunkIndex === 'number' && + (Array.isArray((data as TrackChunkResponse).tracks) || + Array.isArray((data as TrackChunkResponse).mergedTracks) || + Array.isArray((data as TrackChunkResponse).compactTracks)) ); } /** * QueryStatusUpdate 타입 체크 */ -export function isQueryStatusUpdate(data) { +export function isQueryStatusUpdate(data: unknown): data is QueryStatusUpdate { return ( - data && + data !== null && + data !== undefined && typeof data === 'object' && - typeof data.queryId === 'string' && - typeof data.status === 'string' && - Object.values(QueryStatus).includes(data.status) + typeof (data as QueryStatusUpdate).queryId === 'string' && + typeof (data as QueryStatusUpdate).status === 'string' && + (Object.values(QueryStatus) as string[]).includes((data as QueryStatusUpdate).status) ); } @@ -104,7 +189,7 @@ export function isQueryStatusUpdate(data) { * 청크 응답에서 tracks 추출 * 다양한 필드명 지원 (tracks, mergedTracks, compactTracks) */ -export function extractTracks(chunkResponse) { +export function extractTracks(chunkResponse: TrackChunkResponse): TrackData[] { return ( chunkResponse.tracks || chunkResponse.mergedTracks || @@ -116,7 +201,7 @@ export function extractTracks(chunkResponse) { /** * 청크 응답 정규화 */ -export function normalizeChunkResponse(raw) { +export function normalizeChunkResponse(raw: TrackChunkResponse): NormalizedChunkResponse { return { queryId: raw.queryId, chunkIndex: raw.chunkIndex, @@ -140,7 +225,7 @@ export function normalizeChunkResponse(raw) { * - KST 문자열 ('YYYY-MM-DD HH:mm:ss') → 브라우저 로컬 시간대로 해석 * - ISO 형식 → Date.parse() */ -export function parseTimestamp(timestamp) { +export function parseTimestamp(timestamp: string | number): number { if (typeof timestamp === 'number') { // 10억보다 작으면 초 단위로 간주 (2001년 9월 이전은 초 단위) if (timestamp < 10000000000) { @@ -174,7 +259,7 @@ export function parseTimestamp(timestamp) { /** * 배속 라벨 생성 */ -export function getSpeedLabel(speed) { +export function getSpeedLabel(speed: number): string { if (speed === 1) return '1x'; if (speed < 1000) return `${speed}x`; return `${speed / 1000}k`; @@ -183,7 +268,7 @@ export function getSpeedLabel(speed) { /** * 선박 종류 한글 라벨 */ -export const SHIP_KIND_LABELS = { +export const SHIP_KIND_LABELS: Record = { '000020': '어선', '000021': '경비함정', '000022': '여객선', @@ -197,7 +282,7 @@ export const SHIP_KIND_LABELS = { /** * 신호원 한글 라벨 */ -export const SIGNAL_SOURCE_LABELS = { +export const SIGNAL_SOURCE_LABELS: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', diff --git a/src/replay/utils/replayLayerRegistry.js b/src/replay/utils/replayLayerRegistry.ts similarity index 65% rename from src/replay/utils/replayLayerRegistry.js rename to src/replay/utils/replayLayerRegistry.ts index 399d5d4a..98b52802 100644 --- a/src/replay/utils/replayLayerRegistry.js +++ b/src/replay/utils/replayLayerRegistry.ts @@ -6,14 +6,16 @@ * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerReplayLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerReplayLayers(layers: Layer[]): void { window.__replayLayers__ = layers; } -export function getReplayLayers() { +export function getReplayLayers(): Layer[] { return window.__replayLayers__ || []; } -export function unregisterReplayLayers() { +export function unregisterReplayLayers(): void { window.__replayLayers__ = []; } diff --git a/src/stores/authStore.js b/src/stores/authStore.ts similarity index 74% rename from src/stores/authStore.js rename to src/stores/authStore.ts index 16ce87e6..531b7e2c 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.ts @@ -1,8 +1,39 @@ import { create } from 'zustand'; import { SESSION_TIMEOUT_MS } from '../types/constants'; +/** 사용자 정보 */ +interface User { + userName: string; + userId: string; + groupId: string; + loginDate: number; + accountNo: string | null; + accountRoll: string | null; + lat: string | null; + lon: string | null; + shipName: string | null; + targetId: string | null; +} + +/** 세션 체크 결과 */ +interface SessionCheckResult { + valid: boolean; + reason?: string; +} + +/** 인증 스토어 상태 */ +interface AuthStoreState { + // state + user: User | null; + isAuthenticated: boolean; + isChecking: boolean; + // actions + checkSession: () => SessionCheckResult; + handleSessionExpired: () => void; +} + /** 로컬 개발 모의 사용자 (포트가 달라 localStorage 공유 불가) */ -const DEV_MOCK_USER = { +const DEV_MOCK_USER: User = { userName: 'DevUser', userId: 'dev', groupId: '2', @@ -19,7 +50,7 @@ const DEV_MOCK_USER = { * localStorage에서 사용자 정보 읽기 * 메인 프로젝트(mda-react-front) 로그인 시 저장된 값 */ -function readUserFromStorage() { +function readUserFromStorage(): User | null { const userName = localStorage.getItem('userName'); const userId = localStorage.getItem('userId'); const groupId = localStorage.getItem('groupId'); @@ -48,7 +79,7 @@ function readUserFromStorage() { }; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create()((set) => ({ user: null, isAuthenticated: false, isChecking: true, diff --git a/src/stores/favoriteStore.js b/src/stores/favoriteStore.ts similarity index 60% rename from src/stores/favoriteStore.js rename to src/stores/favoriteStore.ts index f1415eb7..709a6de6 100644 --- a/src/stores/favoriteStore.js +++ b/src/stores/favoriteStore.ts @@ -1,11 +1,42 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +/** 관심선박 API 응답 아이템 */ +interface FavoriteItem { + signalSourceCode?: string; + targetId?: string; + [key: string]: unknown; +} + +/** 관심구역 API 응답 아이템 */ +interface RealmItem { + [key: string]: unknown; +} + /** * 관심선박 + 관심구역 스토어 * 참조: mda-react-front/src/shared/model/favoriteStore.ts */ -const useFavoriteStore = create(subscribeWithSelector((set) => ({ +interface FavoriteStoreState { + // === 관심선박 === + favoriteList: FavoriteItem[]; + favoriteSet: Set; + isFavoriteEnabled: boolean; + // === 관심구역 === + realmList: RealmItem[]; + isRealmVisible: boolean; + // === 액션 === + /** + * 관심선박 목록 설정 + * API 응답의 item.targetId는 originalTargetId에 해당 + */ + setFavoriteList: (list: FavoriteItem[]) => void; + toggleFavoriteEnabled: () => void; + setRealmList: (list: RealmItem[]) => void; + toggleRealmVisible: () => void; +} + +const useFavoriteStore = create()(subscribeWithSelector((set) => ({ // === 관심선박 === favoriteList: [], // API 원본 배열 favoriteSet: new Set(), // O(1) lookup: signalSourceCode_originalTargetId @@ -17,12 +48,8 @@ const useFavoriteStore = create(subscribeWithSelector((set) => ({ // === 액션 === - /** - * 관심선박 목록 설정 - * API 응답의 item.targetId는 originalTargetId에 해당 - */ setFavoriteList: (list) => { - const newSet = new Set(); + const newSet = new Set(); list.forEach((item) => { if (item.signalSourceCode && item.targetId) { newSet.add(`${item.signalSourceCode}_${item.targetId}`); diff --git a/src/stores/mapStore.js b/src/stores/mapStore.ts similarity index 61% rename from src/stores/mapStore.js rename to src/stores/mapStore.ts index 09ef5ee5..b9b6c0c8 100644 --- a/src/stores/mapStore.js +++ b/src/stores/mapStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +import type Map from 'ol/Map'; /** * 배경지도 타입 @@ -11,7 +12,9 @@ export const BASE_MAP_TYPES = { NORMAL: 'normal', ENC: 'enc', DARK: 'dark', -}; +} as const; + +type BaseMapType = typeof BASE_MAP_TYPES[keyof typeof BASE_MAP_TYPES]; /** * 테마 타입 (배경지도에 연동) @@ -21,22 +24,34 @@ export const BASE_MAP_TYPES = { export const THEME_TYPES = { LIGHT: 'light', DARK: 'dark', -}; +} as const; + +type ThemeType = typeof THEME_TYPES[keyof typeof THEME_TYPES]; /** * 배경지도 -> 테마 매핑 */ -const BASE_MAP_TO_THEME = { +const BASE_MAP_TO_THEME: Record = { [BASE_MAP_TYPES.NORMAL]: THEME_TYPES.LIGHT, [BASE_MAP_TYPES.ENC]: THEME_TYPES.LIGHT, [BASE_MAP_TYPES.DARK]: THEME_TYPES.DARK, }; +/** RGBA 색상 (4-tuple) */ +type RgbaColor = [number, number, number, number]; + +interface ThemeColorSet { + shipLabel: RgbaColor; + shipLabelOutline: RgbaColor; + speedVector: RgbaColor; + shipDim: RgbaColor; +} + /** * 테마별 색상 정의 * - 선박 레이어에서 사용 */ -export const THEME_COLORS = { +export const THEME_COLORS: Record = { [THEME_TYPES.LIGHT]: { shipLabel: [30, 30, 30, 255], shipLabelOutline: [255, 255, 255, 255], @@ -51,10 +66,55 @@ export const THEME_COLORS = { }, }; +type MeasureTool = 'distance' | 'area' | 'rangeRing'; +type AreaShape = 'circle' | 'rectangle' | 'Box' | 'Polygon' | 'Circle'; + +interface LayerVisibility { + baseMap: boolean; + ships: boolean; + weather: boolean; + satellite: boolean; +} + +interface MapStoreState { + // 지도 인스턴스 + map: Map | null; + setMap: (map: Map | null) => void; + + // 배경지도 타입 + baseMapType: BaseMapType; + setBaseMapType: (type: BaseMapType) => void; + + // 테마 셀렉터 + getTheme: () => ThemeType; + getThemeColors: () => ThemeColorSet; + + // 줌 레벨 + zoom: number; + setZoom: (zoom: number) => void; + zoomIn: () => void; + zoomOut: () => void; + + // 중심 좌표 [lon, lat] + center: [number, number]; + setCenter: (center: [number, number]) => void; + + // 측정 도구 + activeMeasureTool: MeasureTool | null; + areaShape: AreaShape | null; + setMeasureTool: (tool: MeasureTool) => void; + setAreaShape: (shape: AreaShape) => void; + clearMeasure: () => void; + + // 레이어 가시성 + layerVisibility: LayerVisibility; + toggleLayer: (layerName: keyof LayerVisibility) => void; +} + /** * 지도 상태 관리 스토어 */ -export const useMapStore = create(subscribeWithSelector((set, get) => ({ +export const useMapStore = create()(subscribeWithSelector((set, get) => ({ // 지도 인스턴스 map: null, setMap: (map) => set({ map }), diff --git a/src/stores/shipStore.js b/src/stores/shipStore.ts similarity index 77% rename from src/stores/shipStore.js rename to src/stores/shipStore.ts index b9b5d5f9..0b12360b 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.ts @@ -29,11 +29,12 @@ import { SOURCE_TO_ACTIVE_KEY, USER_SETTING_CODES, } from '../types/constants'; +import type { ShipFeature } from '../types/ship'; // ===================== // 국적 코드 매핑 (ShipBatchRenderer.js와 동일) // ===================== -function mapNationalCode(nationalCode) { +function mapNationalCode(nationalCode: string | undefined): string { if (!nationalCode) return 'OTHER'; const code = nationalCode.toUpperCase(); if (code === 'KR' || code === 'KOR' || code === '440') return 'KR'; @@ -44,10 +45,10 @@ function mapNationalCode(nationalCode) { } // ===================== -// 서버 수신시간 파싱 (receivedTime → ms timestamp) +// 서버 수신시간 파싱 (receivedTime -> ms timestamp) // 형식: "YYYYMMDDHHmmss" // ===================== -function parseReceivedTime(receivedTime) { +function parseReceivedTime(receivedTime: string | undefined): number { if (!receivedTime || receivedTime.length < 14) return Date.now(); const y = receivedTime.slice(0, 4); const M = receivedTime.slice(4, 6); @@ -62,53 +63,34 @@ function parseReceivedTime(receivedTime) { // ===================== // 타임아웃 상수 (카운트 사이클에서 상태 전환/삭제 판정) // ===================== -// -// ■ 영해안 (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 = 60 * 1000; // 90초 — 단독 레이더 비통합, 무수신 시 삭제 +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 = 60 * 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']; +const EQUIPMENT_KEYS = ['ais', 'vpass', 'enav', 'vtsAis', 'dMfHf', 'vtsRadar'] as const; -function isAnyEquipmentActive(ship) { +function isAnyEquipmentActive(ship: ShipFeature): boolean { return EQUIPMENT_KEYS.some(key => ship[key] === '1'); } /** * 동적 대표 Set 생성 - * 참조: mda-react-front/docs/dynamic-priority.md §4.1 + * 참조: mda-react-front/docs/dynamic-priority.md 4.1 * * O(N) 단일 패스로 통합선박별 동적 대표 featureId의 Set 반환. * 통합선박(targetId에 '_' 포함)만 처리하며, 단독선박은 스킵. - * - * @param {Map} features - 전체 선박 feature 맵 - * @param {Set} enabledSources - 필터에서 ON된 신호원 코드 Set - * @param {Set} darkSignalIds - 다크시그널 선박 ID Set - * @returns {Set} 동적 대표 featureId 집합 */ -function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { - const bestByTargetId = new Map(); // targetId → { featureId, rank } +function buildDynamicPrioritySet( + features: Map, + enabledSources: Set, + darkSignalIds: Set, +): Set { + const bestByTargetId = new Map(); features.forEach((ship, featureId) => { // 다크시그널 스킵 @@ -134,7 +116,7 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { } }); - const result = new Set(); + const result = new Set(); bestByTargetId.forEach(({ featureId }) => result.add(featureId)); return result; } @@ -142,7 +124,7 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { /** * 초기 선박 종류별 카운트 */ -const initialKindCounts = { +const initialKindCounts: Record = { [SIGNAL_KIND_CODE_FISHING]: 0, [SIGNAL_KIND_CODE_KCGV]: 0, [SIGNAL_KIND_CODE_PASSENGER]: 0, @@ -157,7 +139,7 @@ const initialKindCounts = { /** * 초기 선박 종류별 표시 설정 */ -const initialKindVisibility = { +const initialKindVisibility: Record = { [SIGNAL_KIND_CODE_FISHING]: true, [SIGNAL_KIND_CODE_KCGV]: true, [SIGNAL_KIND_CODE_PASSENGER]: true, @@ -171,7 +153,7 @@ const initialKindVisibility = { /** * 초기 신호원별 표시 설정 */ -const initialSourceVisibility = { +const initialSourceVisibility: Record = { [SIGNAL_SOURCE_CODE_AIS]: true, [SIGNAL_SOURCE_CODE_VPASS]: true, [SIGNAL_SOURCE_CODE_ENAV]: true, @@ -183,7 +165,7 @@ const initialSourceVisibility = { /** * 초기 국적별 표시 설정 */ -const initialNationalVisibility = { +const initialNationalVisibility: Record = { [NATIONAL_CODE_KR]: true, [NATIONAL_CODE_CN]: true, [NATIONAL_CODE_JP]: true, @@ -191,23 +173,150 @@ const initialNationalVisibility = { [NATIONAL_CODE_OTHER]: true, }; +// ===================== +// AI 모드 토글 인터페이스 +// ===================== +interface AiModeVisibility { + mmsiChange: boolean; + chinaPermission: boolean; + govShip: boolean; + sseZoneContact: boolean; + nonPermission: boolean; + northKoreaAi: boolean; +} + +// ===================== +// 선명표시 옵션 인터페이스 +// ===================== +interface LabelOptions { + showShipName: boolean; + showSpeedVector: boolean; + showShipSize: boolean; + showSignalStatus: boolean; +} + +// ===================== +// 컨텍스트 메뉴 / 호버 / 모달 인터페이스 +// ===================== +interface ContextMenuInfo { + x: number; + y: number; + ships: ShipFeature[]; +} + +interface HoverInfo { + ship: ShipFeature; + x: number; + y: number; +} + +interface DetailModal { + ship: ShipFeature; + id: string; + initialPos: { x: number; y: number }; +} + +// ===================== +// 필터 설정 아이템 인터페이스 +// ===================== +interface FilterSettingItem { + settingCode: string; + settingValue: string; +} + +interface FilterSettingOutput { + code: string; + value: string; +} + +// ===================== +// 선박 스토어 State +// ===================== +interface ShipStoreState { + // 상태 + features: Map; + darkSignalIds: Set; + kindCounts: Record; + kindVisibility: Record; + sourceVisibility: Record; + nationalVisibility: Record; + selectedShipId: string | null; + selectedShipIds: string[]; + contextMenu: ContextMenuInfo | null; + hoverInfo: HoverInfo | null; + detailModals: DetailModal[]; + lastModalPos: { x: number; y: number } | null; + aiModeVisibility: AiModeVisibility; + hazardVisible: boolean; + darkSignalVisible: boolean; + darkSignalCount: number; + isShipVisible: boolean; + isIntegrate: boolean; + showLabels: boolean; + labelOptions: LabelOptions; + isConnected: boolean; + showLegend: boolean; + changedIds: Set; + totalCount: number; + + // 액션 + mergeFeatures: (ships: ShipFeature[]) => void; + applyCleanup: (deleteIds: string[], darkSignalConvertIds: string[]) => void; + addOrUpdateFeature: (ship: ShipFeature) => void; + deleteFeatureById: (featureId: string) => void; + deleteFeaturesByIds: (featureIds: string[]) => void; + toggleKindVisibility: (kindCode: string) => void; + toggleSourceVisibility: (sourceCode: string) => void; + toggleNationalVisibility: (nationalCode: string) => void; + toggleAiModeEnabled: () => void; + toggleAiModeVisibility: (key: keyof AiModeVisibility) => void; + toggleHazardVisible: () => void; + toggleDarkSignalVisible: () => void; + clearDarkSignals: () => void; + toggleShipVisible: () => void; + toggleShowLabels: () => void; + toggleLabelOption: (optionKey: keyof LabelOptions) => void; + setLabelOptions: (options: Partial) => void; + toggleIntegrate: () => void; + selectShip: (featureId: string | null) => void; + setSelectedShipIds: (ids: string[]) => void; + clearSelectedShips: () => void; + openContextMenu: (info: ContextMenuInfo) => void; + closeContextMenu: () => void; + syncSelectedWithIntegrateMode: (toIntegrateMode: boolean) => void; + setHoverInfo: (info: HoverInfo | null) => void; + openDetailModal: (ship: ShipFeature) => void; + updateModalPos: (modalId: string, pos: { x: number; y: number }) => void; + closeDetailModal: (modalId: string) => void; + closeAllDetailModals: () => void; + setConnected: (connected: boolean) => void; + toggleShowLegend: () => void; + applyFilterSettings: (filterArray: FilterSettingItem[]) => void; + buildFilterSettings: () => FilterSettingOutput[]; + clearFeatures: () => void; + clearChangedIds: () => void; + setKindCounts: (counts: Record) => void; + getVisibleShips: () => ShipFeature[]; + getSelectedShip: () => ShipFeature | undefined; + getSelectedShips: () => ShipFeature[]; + getDownloadShips: () => ShipFeature[]; +} + /** * 선박 스토어 */ -const useShipStore = create(subscribeWithSelector((set, get) => ({ +const useShipStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 상태 (State) // ===================== /** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId - * ※ immutable 패턴: 변경 시 new Map() 생성 → Zustand 참조 비교로 변경 감지 - * (메인 프로젝트 동일 구조) */ - features: new Map(), + * immutable 패턴: 변경 시 new Map() 생성 -> Zustand 참조 비교로 변경 감지 */ + features: new Map(), - /** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조) - * 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds - * ※ immutable 패턴: 변경 시 new Set() 생성 → Zustand 참조 비교로 변경 감지 */ - darkSignalIds: new Set(), + /** 다크시그널 선박 ID Set (features와 별도 관리) + * immutable 패턴: 변경 시 new Set() 생성 -> Zustand 참조 비교로 변경 감지 */ + darkSignalIds: new Set(), /** 선박 종류별 카운트 */ kindCounts: { ...initialKindCounts }, @@ -227,26 +336,26 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** Ctrl+Drag 다중 선택된 featureId 배열 (제한 없음) */ selectedShipIds: [], - /** 컨텍스트 메뉴 상태 { x, y, ships: [] } | null */ + /** 컨텍스트 메뉴 상태 */ contextMenu: null, - /** 호버 중인 선박 정보 { ship, x, y } | null */ + /** 호버 중인 선박 정보 */ hoverInfo: null, - /** 상세 모달 배열 (최대 3개) [{ ship, id, initialPos }] */ + /** 상세 모달 배열 (최대 3개) */ detailModals: [], /** 마지막 모달 위치 (새 모달 초기 위치 계산용) */ lastModalPos: null, - /** AI 모드 서브 토글 (메인 토글은 컴포넌트에서 every()로 파생 — 선종/국적/신호와 동일 패턴) */ + /** AI 모드 서브 토글 */ aiModeVisibility: { - mmsiChange: false, // MMSI 변조 - chinaPermission: false, // 중국 허가선박 - govShip: false, // 관공선 - sseZoneContact: false, // 비정상 접촉 - nonPermission: false, // 비정상 선박 - northKoreaAi: false, // 북한선박 + mmsiChange: false, + chinaPermission: false, + govShip: false, + sseZoneContact: false, + nonPermission: false, + northKoreaAi: false, }, /** 위험물 표시 여부 */ @@ -269,10 +378,10 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** 선명표시 옵션 (개발 중 기본 모두 활성화) */ labelOptions: { - showShipName: true, // 선박명 - showSpeedVector: true, // 속도벡터 - showShipSize: true, // 선박크기 - showSignalStatus: false, // 신호상태 + showShipName: true, + showSpeedVector: true, + showShipSize: true, + showSignalStatus: false, }, /** STOMP 연결 상태 */ @@ -282,7 +391,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ showLegend: true, /** 변경된 선박 ID 추적 (렌더링 최적화용) */ - changedIds: new Set(), + changedIds: new Set(), /** 총 선박 수 */ totalCount: 0, @@ -294,10 +403,9 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 여러 선박 데이터 병합 (bulk update) * 카운트는 ShipBatchRenderer의 렌더 사이클에서 계산 (메인 프로젝트 동일) - * @param {Array} ships - 선박 데이터 배열 */ mergeFeatures: (ships) => { - // ※ immutable 패턴: 배치 단위로 변경 후 1회만 new Map()/new Set() 생성 + // immutable 패턴: 배치 단위로 변경 후 1회만 new Map()/new Set() 생성 const state = get(); const newFeatures = new Map(state.features); const newDarkSignalIds = new Set(state.darkSignalIds); @@ -313,16 +421,15 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } // 타임스탬프 비교: 기존 데이터보다 오래된 메시지는 무시 - // 참조: mda-react-front/src/shared/model/deckStore.ts - mergeFeatures (line 163) const newTimestamp = parseReceivedTime(ship.receivedTime); const currentFeature = newFeatures.get(featureId); - if (currentFeature && newTimestamp < currentFeature.receivedTimestamp) { - return; // 이전 시간대 데이터 → 무시 + if (currentFeature && currentFeature.receivedTimestamp !== undefined && newTimestamp < currentFeature.receivedTimestamp) { + return; // 이전 시간대 데이터 -> 무시 } const hasActive = isAnyEquipmentActive(ship); - // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제) + // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 -> 저장하지 않음 (완전 삭제) if (!ship.lost && !hasActive) { newFeatures.delete(featureId); if (newDarkSignalIds.delete(featureId)) darkChanged = true; @@ -342,7 +449,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ newFeatures.set(featureId, { ...ship, receivedTimestamp: newTimestamp }); }); - // immutable 참조 변경 → Zustand 감지 + // immutable 참조 변경 -> Zustand 감지 set({ features: newFeatures, ...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}), @@ -352,8 +459,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 타임아웃 cleanup 적용 (ShipBatchRenderer에서 호출) * immutable 패턴으로 삭제 + 다크시그널 전환을 한 번에 처리 - * @param {Array} deleteIds - 삭제할 featureId 배열 - * @param {Array} darkSignalConvertIds - 다크시그널로 전환할 featureId 배열 */ applyCleanup: (deleteIds, darkSignalConvertIds) => { if (deleteIds.length === 0 && darkSignalConvertIds.length === 0) return; @@ -374,7 +479,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 단일 선박 추가/업데이트 - * @param {Object} ship - 선박 데이터 */ addOrUpdateFeature: (ship) => { get().mergeFeatures([ship]); @@ -382,13 +486,12 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선박 삭제 - * @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId) */ deleteFeatureById: (featureId) => { const state = get(); const newFeatures = new Map(state.features); newFeatures.delete(featureId); - const updates = { features: newFeatures }; + const updates: Partial = { features: newFeatures }; if (state.darkSignalIds.has(featureId)) { const newDark = new Set(state.darkSignalIds); @@ -405,7 +508,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 여러 선박 삭제 - * @param {Array} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId) */ deleteFeaturesByIds: (featureIds) => { const state = get(); @@ -421,14 +523,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ set({ features: newFeatures, ...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}), - selectedShipId: featureIds.includes(state.selectedShipId) ? null : state.selectedShipId, + selectedShipId: state.selectedShipId !== null && featureIds.includes(state.selectedShipId) ? null : state.selectedShipId, }); }, /** * 선박 종류별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} kindCode - 선박 종류 코드 + * 필터 변경 -> useShipLayer subscription -> immediateRender -> 카운트 재계산 */ toggleKindVisibility: (kindCode) => { set((state) => ({ @@ -441,8 +542,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 신호원별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} sourceCode - 신호원 코드 */ toggleSourceVisibility: (sourceCode) => { set((state) => ({ @@ -455,8 +554,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 국적별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} nationalCode - 국적 코드 */ toggleNationalVisibility: (nationalCode) => { set((state) => ({ @@ -468,7 +565,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ }, /** - * 다크시그널 표시 토글 + * AI 모드 전체 토글 */ toggleAiModeEnabled: () => { set((state) => { @@ -512,7 +609,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ state.darkSignalIds.forEach((fid) => { newFeatures.delete(fid); }); - set({ features: newFeatures, darkSignalIds: new Set() }); + set({ features: newFeatures, darkSignalIds: new Set() }); }, /** @@ -534,8 +631,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ }, /** - * 선명표시 옵션 설정 - * @param {string} optionKey - 옵션 키 (showShipName, showSpeedVector, showShipSize, showSignalStatus) + * 선명표시 옵션 토글 */ toggleLabelOption: (optionKey) => { set((state) => ({ @@ -548,7 +644,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선명표시 옵션 직접 설정 - * @param {Object} options - 옵션 객체 */ setLabelOptions: (options) => { set((state) => ({ @@ -572,7 +667,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선박 선택 - * @param {string|null} featureId - 선택할 선박 ID (null이면 선택 해제, signalSourceCode + targetId) */ selectShip: (featureId) => { set({ selectedShipId: featureId }); @@ -580,7 +674,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * Ctrl+Drag 다중 선택 설정 - * @param {Array} ids - featureId 배열 */ setSelectedShipIds: (ids) => set({ selectedShipIds: ids }), @@ -591,7 +684,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 컨텍스트 메뉴 열기 - * @param {{ x: number, y: number, ships: Array }} info */ openContextMenu: (info) => set({ contextMenu: info }), @@ -603,7 +695,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 통합모드 전환 시 selectedShipIds 동기화 * 참조: mda-react-front/src/shared/model/deckStore.ts - syncSelectedFeaturesWithIntegrateMode - * @param {boolean} toIntegrateMode - 전환 후 통합모드 ON 여부 */ syncSelectedWithIntegrateMode: (toIntegrateMode) => { const { selectedShipIds, features } = get(); @@ -615,13 +706,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ { index: 2, signalSourceCode: '000002', dataKey: 'enav' }, { index: 3, signalSourceCode: '000004', dataKey: 'vtsAis' }, { index: 4, signalSourceCode: '000016', dataKey: 'dMfHf' }, - // index 5 = VTS-Radar → 확장 시 제외 - ]; + // index 5 = VTS-Radar -> 확장 시 제외 + ] as const; if (toIntegrateMode) { - // OFF → ON: 개별 장비 → 대표(isPriority) 선박으로 축소 - const newIds = []; - const seenTargetIds = new Set(); + // OFF -> ON: 개별 장비 -> 대표(isPriority) 선박으로 축소 + const newIds: string[] = []; + const seenTargetIds = new Set(); selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -636,7 +727,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ if (seenTargetIds.has(tid)) return; seenTargetIds.add(tid); - let priorityFid = null; + let priorityFid: string | null = null; features.forEach((s, id) => { if (s.targetId === tid && s.isPriority) priorityFid = id; }); @@ -645,8 +736,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ set({ selectedShipIds: newIds }); } else { - // ON → OFF: 대표 선박 → isActive인 개별 장비로 확장 - const newIds = []; + // ON -> OFF: 대표 선박 -> isActive인 개별 장비로 확장 + const newIds: string[] = []; selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -681,7 +772,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 호버 정보 설정 - * @param {Object|null} info - { ship, x, y } 또는 null */ setHoverInfo: (info) => { set({ hoverInfo: info }); @@ -690,8 +780,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 상세 모달 열기 (최대 3개, 4번째부터 FIFO 제거) * 새 모달은 마지막 모달의 현재 위치 기준 우측 140px 오프셋으로 생성 - * 참조: mda-react-front/src/shared/model/deckStore.ts - setAddDetailModal - * @param {Object} ship - 선박 데이터 */ openDetailModal: (ship) => { set((state) => { @@ -707,7 +795,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ 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; @@ -724,12 +811,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } // 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측 - // 최초 모달은 화면 중앙 근처에서 시작 (가로: 화면 중앙 - 200px, 세로: 100px) const defaultX = typeof window !== 'undefined' ? Math.max(100, (window.innerWidth / 2) - 200) : 400; const basePos = state.lastModalPos || { x: defaultX - 140, y: 100 }; const initialPos = { x: basePos.x + 140, y: basePos.y }; - const newModal = { ship: displayShip, id: displayShip.featureId, initialPos }; + const newModal: DetailModal = { ship: displayShip, id: displayShip.featureId, initialPos }; let modals = [...state.detailModals, newModal]; // 3개 초과 시 가장 오래된 모달 제거 @@ -746,16 +832,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 모달 위치 업데이트 (드래그 후 호출) - * @param {string} modalId - 모달 ID - * @param {{ x: number, y: number }} pos - 현재 위치 */ - updateModalPos: (modalId, pos) => { + updateModalPos: (_modalId, pos) => { set({ lastModalPos: pos }); }, /** * 특정 상세 모달 닫기 - * @param {string} modalId - 모달 ID (featureId) */ closeDetailModal: (modalId) => { set((state) => ({ @@ -772,7 +855,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * STOMP 연결 상태 설정 - * @param {boolean} connected - 연결 상태 */ setConnected: (connected) => { set({ isConnected: connected }); @@ -788,15 +870,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 서버에서 불러온 필터 설정 배열을 스토어에 적용 * 참조: mda-react-front/src/common/userSetting.ts - * @param {Array<{settingCode: string, settingValue: string}>} filterArray */ applyFilterSettings: (filterArray) => { if (!Array.isArray(filterArray) || filterArray.length === 0) return; - const toBoolean = (item) => item?.settingValue === 'true'; + const toBoolean = (item: FilterSettingItem | undefined): boolean => item?.settingValue === 'true'; - // settingCode → settingValue 맵 생성 - const map = {}; + // settingCode -> settingValue 맵 생성 + const map: Record = {}; filterArray.forEach((item) => { if (item?.settingCode) map[item.settingCode] = item; }); @@ -828,7 +909,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ [SIGNAL_SOURCE_CODE_RADAR]: toBoolean(map[USER_SETTING_CODES.RADAR]), }, darkSignalVisible: toBoolean(map[USER_SETTING_CODES.LOST_SIGNAL]), - // AI 모드 (메인 토글은 하위 토글에서 파생 — 서버에 000039가 없을 수 있음) + // AI 모드 (메인 토글은 하위 토글에서 파생) aiModeVisibility: { mmsiChange: toBoolean(map[USER_SETTING_CODES.MMSI_CHANGE]), chinaPermission: toBoolean(map[USER_SETTING_CODES.CHINA_PERMISSION]), @@ -844,7 +925,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 현재 필터 상태를 서버 저장 형식으로 직렬화 - * @returns {Array<{code: string, value: string}>} */ buildFilterSettings: () => { const { kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible, aiModeVisibility, hazardVisible } = get(); @@ -887,8 +967,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ */ clearFeatures: () => { set({ - features: new Map(), - darkSignalIds: new Set(), + features: new Map(), + darkSignalIds: new Set(), kindCounts: { ...initialKindCounts }, selectedShipId: null, selectedShipIds: [], @@ -902,12 +982,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * 변경 ID 초기화 (렌더링 후 호출) */ clearChangedIds: () => { - set({ changedIds: new Set() }); + set({ changedIds: new Set() }); }, /** * 선박 종류별 카운트 직접 설정 (서버 count 토픽용) - * @param {Object} counts - 종류별 카운트 객체 */ setKindCounts: (counts) => { const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0); @@ -923,14 +1002,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 표시 가능한 선박 목록 (필터 적용) - * @returns {Array} 필터링된 선박 배열 */ getVisibleShips: () => { const state = get(); if (!state.isShipVisible) return []; const { features, darkSignalIds, kindVisibility, sourceVisibility, darkSignalVisible } = state; - const result = []; + const result: ShipFeature[] = []; features.forEach((ship, featureId) => { // 다크시그널은 독립 필터 (선종/신호원/국적 필터 무시) @@ -953,22 +1031,20 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선택된 선박 정보 - * @returns {Object|null} 선박 데이터 또는 null */ getSelectedShip: () => { const { features, selectedShipId } = get(); - return selectedShipId ? features.get(selectedShipId) : null; + return selectedShipId ? features.get(selectedShipId) : undefined; }, /** * 선택된 모든 선박 정보 (하이라이트 표시용) * selectedShipIds(박스선택) + detailModals(상세모달) 통합 - * @returns {Array} 선박 데이터 배열 */ getSelectedShips: () => { const { features, selectedShipIds, detailModals } = get(); - const result = []; - const seen = new Set(); + const result: ShipFeature[] = []; + const seen = new Set(); selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -1000,7 +1076,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * - 통합 모드: isPriority만 포함 * - 다크시그널: 독립 필터 적용 * - 일반: 선종/신호원/국적 필터 적용 - * @returns {Array} 다운로드용 선박 배열 (downloadTargetId 포함) */ getDownloadShips: () => { const state = get(); @@ -1010,14 +1085,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } = state; // 통합 모드: 동적 우선순위 Set 생성 - let dynamicPrioritySet = null; + let dynamicPrioritySet: Set | null = null; if (isIntegrate) { - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, on]) => { if (on) enabledSources.add(code); }); dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); } - const result = []; + const result: ShipFeature[] = []; features.forEach((ship, featureId) => { // 레이더 항상 제외 @@ -1025,7 +1100,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ // 통합 모드: 동적 대표만 포함 if (isIntegrate && ship.targetId && ship.targetId.includes('_')) { - if (!dynamicPrioritySet.has(featureId)) return; + if (!dynamicPrioritySet!.has(featureId)) return; } const downloadTargetId = isIntegrate ? ship.targetId : ship.originalTargetId; @@ -1063,4 +1138,16 @@ export { RADAR_TIMEOUT_MS, SIGNAL_SOURCE_RADAR, }; + +export type { + AiModeVisibility, + LabelOptions, + ContextMenuInfo, + HoverInfo, + DetailModal, + FilterSettingItem, + FilterSettingOutput, + ShipStoreState, +}; + export default useShipStore; diff --git a/src/stores/trackStore.js b/src/stores/trackStore.ts similarity index 76% rename from src/stores/trackStore.js rename to src/stores/trackStore.ts index 034391b4..6a1aff52 100644 --- a/src/stores/trackStore.js +++ b/src/stores/trackStore.ts @@ -15,11 +15,13 @@ import { toLocalISOString, buildVesselListForQuery, } from '../api/trackApi'; +import type { ProcessedTrack } from '../areaSearch/stores/areaSearchStore'; +import type { ShipFeature } from '../types/ship'; // ===================== // 선종별 항적 색상 (RGBA) // ===================== -export const SHIP_KIND_TRACK_COLORS = { +export const SHIP_KIND_TRACK_COLORS: Record = { '000020': [25, 116, 25, 150], // 어선 '000021': [0, 41, 255, 150], // 함정 '000022': [176, 42, 42, 150], // 여객선 @@ -28,36 +30,105 @@ export const SHIP_KIND_TRACK_COLORS = { '000025': [92, 30, 224, 150], // 관공선 '000027': [255, 135, 207, 150], // 기타 '000028': [232, 95, 27, 150], // 부이 -}; +} as const; -export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; +export const DEFAULT_TRACK_COLOR: number[] = [128, 128, 128, 150]; /** * 선종코드로 항적 색상 반환 */ -export function getShipKindTrackColor(shipKindCode) { +export function getShipKindTrackColor(shipKindCode: string): number[] { return SHIP_KIND_TRACK_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; } /** 기본 조회 기간 (3일) */ const DEFAULT_QUERY_DAYS = 3; +/** 항적 모달 아이템 */ +interface TrackModal { + ships: ShipFeature[]; + id: string; + isIntegrated: boolean; +} + +/** 현재 위치 보간 결과 */ +interface TrackPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipName: string; + shipKindCode: string; +} + +// ===================== +// 항적 스토어 State +// ===================== +interface TrackStoreState { + // 항적 데이터 + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + disabledSigSrcCds: Set; + + // 시간 범위 + dataStartTime: number; + dataEndTime: number; + requestedStartTime: number; + currentTime: number; + + // 로딩/에러 + isLoading: boolean; + error: string | null; + + // 표시 옵션 + showPoints: boolean; + showVirtualShip: boolean; + showLabels: boolean; + + // 모달 상태 + trackModals: TrackModal[]; + + // 액션 + setTracks: (tracks: ProcessedTrack[], requestedStartTime?: number) => void; + setCurrentTime: (time: number) => void; + setProgressByRatio: (ratio: number) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setShowPoints: (show: boolean) => void; + setShowVirtualShip: (show: boolean) => void; + setShowLabels: (show: boolean) => void; + toggleVesselEnabled: (vesselId: string) => void; + isVesselEnabled: (vesselId: string) => boolean; + toggleEquipment: (sigSrcCd: string) => void; + enableAllEquipment: () => void; + resetEquipmentToDefault: (ship: ShipFeature | null) => void; + isEquipmentEnabled: (sigSrcCd: string) => boolean; + getEnabledTracks: () => ProcessedTrack[]; + getCurrentPositions: () => TrackPosition[]; + getProgress: () => number; + openTrackModal: (ships: ShipFeature[]) => void; + queryTracks: (ships: ShipFeature[], startDate?: Date, endDate?: Date) => Promise; + closeTrackModal: (modalId: string) => void; + reset: () => void; +} + // ===================== // 항적 스토어 // ===================== -const useTrackStore = create(subscribeWithSelector((set, get) => ({ +const useTrackStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 항적 데이터 // ===================== - /** 조회된 항적 배열 (ProcessedTrack[]) */ + /** 조회된 항적 배열 */ tracks: [], /** 비활성화된 선박 ID Set */ - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), /** 비활성화된 장비(신호원) Set - 통합선박 장비필터용 */ - disabledSigSrcCds: new Set(), + disabledSigSrcCds: new Set(), // ===================== // 시간 범위 @@ -92,7 +163,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ // 모달 상태 // ===================== - /** 항적 조회 모달 배열 [{ ships, id, initialPos, isIntegrated }] */ + /** 항적 조회 모달 배열 */ trackModals: [], // ===================== @@ -111,8 +182,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ dataEndTime: 0, requestedStartTime: 0, currentTime: 0, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), }); return; } @@ -135,10 +206,10 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ dataStartTime: minTime, dataEndTime: maxTime, requestedStartTime: requestedStartTime || minTime, - // 핵심 수정: currentTime을 dataEndTime으로 설정 → 전체 항적 즉시 표시 + // 핵심 수정: currentTime을 dataEndTime으로 설정 -> 전체 항적 즉시 표시 currentTime: maxTime, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), error: null, }); }, @@ -210,18 +281,18 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** 장비 전체 활성화 */ enableAllEquipment: () => { - set({ disabledSigSrcCds: new Set(), disabledVesselIds: new Set() }); + set({ disabledSigSrcCds: new Set(), disabledVesselIds: new Set() }); }, /** 장비 기본값 복원 (ship의 active 장비만 활성) */ resetEquipmentToDefault: (ship) => { if (!ship) return; const { tracks } = get(); - const newDisabledSrc = new Set(); - const newDisabledVessels = new Set(); + const newDisabledSrc = new Set(); + const newDisabledVessels = new Set(); // ship 객체의 active 플래그로 판단 - const activeMap = { + const activeMap: Record = { '000001': ship.ais, '000003': ship.vpass, '000002': ship.enav, @@ -258,7 +329,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: () => { const { tracks, currentTime, disabledVesselIds } = get(); - const positions = []; + const positions: TrackPosition[] = []; tracks.forEach((track) => { if (disabledVesselIds.has(track.vesselId)) return; @@ -358,7 +429,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ // 통합선박 여부 판단 const isIntegrated = ships.length === 1 && !!ships[0].integrate; - const newModal = { ships, id, isIntegrated }; + const newModal: TrackModal = { ships, id, isIntegrated }; // 기존 모달 대체 (하나의 항적만 활성) set({ trackModals: [newModal] }); @@ -370,7 +441,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** * 항적 조회 실행 */ - queryTracks: async (ships, startDate, endDate) => { + queryTracks: async (ships, startDate?, endDate?) => { const now = new Date(); const start = startDate || new Date(now.getTime() - DEFAULT_QUERY_DAYS * 24 * 60 * 60 * 1000); const end = endDate || now; @@ -396,8 +467,9 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ }); get().setTracks(result, start.getTime()); - } catch (err) { - set({ error: err.message || '항적 조회 실패' }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '항적 조회 실패'; + set({ error: message }); } finally { set({ isLoading: false }); } @@ -406,7 +478,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** * 항적 모달 닫기 */ - closeTrackModal: (modalId) => { + closeTrackModal: (_modalId) => { set({ trackModals: [], tracks: [], @@ -415,8 +487,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ currentTime: 0, error: null, isLoading: false, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), }); }, @@ -424,8 +496,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ reset: () => { set({ tracks: [], - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, diff --git a/src/stores/trackingModeStore.js b/src/stores/trackingModeStore.ts similarity index 60% rename from src/stores/trackingModeStore.js rename to src/stores/trackingModeStore.ts index 2c18416b..4c30be0f 100644 --- a/src/stores/trackingModeStore.js +++ b/src/stores/trackingModeStore.ts @@ -5,21 +5,29 @@ */ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +import type { ShipFeature } from '../types/ship'; -// 반경 옵션 (NM) -export const RADIUS_OPTIONS = [10, 25, 50, 100, 200]; +/** 추적 모드 */ +type TrackingMode = 'map' | 'ship'; -// NM to meters 변환 (1 NM = 1852m) +/** 반경 옵션 (NM) */ +export const RADIUS_OPTIONS = [10, 25, 50, 100, 200] as const; + +/** NM to meters 변환 (1 NM = 1852m) */ export const NM_TO_METERS = 1852; +/** 추적 중심 좌표 */ +interface TrackedCenter { + lon: number; + lat: number; +} + /** * 경비함정 여부 판별 * - originalTargetId가 '#'으로 시작 * - 또는 IP 형태 (10.xxx.xxx.xxx) - * @param {string} originalTargetId - * @returns {boolean} */ -export function isPatrolShip(originalTargetId) { +export function isPatrolShip(originalTargetId: string | undefined | null): boolean { if (!originalTargetId) return false; // '#'으로 시작 if (originalTargetId.startsWith('#')) return true; @@ -30,15 +38,10 @@ export function isPatrolShip(originalTargetId) { /** * 두 좌표 간 거리 계산 (Haversine, 미터 단위) - * @param {number} lon1 - * @param {number} lat1 - * @param {number} lon2 - * @param {number} lat2 - * @returns {number} 거리 (미터) */ -export function calculateDistance(lon1, lat1, lon2, lat2) { +export function calculateDistance(lon1: number, lat1: number, lon2: number, lat2: number): number { const R = 6371000; // 지구 반지름 (미터) - const toRad = (deg) => (deg * Math.PI) / 180; + const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); @@ -52,29 +55,44 @@ export function calculateDistance(lon1, lat1, lon2, lat2) { /** * 선박이 반경 내에 있는지 확인 - * @param {Object} ship - 선박 데이터 (longitude, latitude) - * @param {number} centerLon - 중심 경도 - * @param {number} centerLat - 중심 위도 - * @param {number} radiusNM - 반경 (NM) - * @returns {boolean} */ -export function isWithinRadius(ship, centerLon, centerLat, radiusNM) { +export function isWithinRadius(ship: { longitude: number; latitude: number }, centerLon: number, centerLat: number, radiusNM: number): boolean { if (!ship.longitude || !ship.latitude) return false; const radiusMeters = radiusNM * NM_TO_METERS; const distance = calculateDistance(centerLon, centerLat, ship.longitude, ship.latitude); return distance <= radiusMeters; } -/** - * 추적 모드 스토어 - */ -const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ +/** 추적 모드 스토어 상태 */ +interface TrackingModeStoreState { + // state + mode: TrackingMode; + trackedShipId: string | null; + trackedShip: ShipFeature | null; + radiusNM: number; + showShipSelector: boolean; + // actions + setMapMode: () => void; + setShipMode: () => void; + selectTrackedShip: (featureId: string, ship: ShipFeature) => void; + updateTrackedShip: (ship: ShipFeature) => void; + setRadius: (radiusNM: number) => void; + toggleShipSelector: () => void; + closeShipSelector: () => void; + toggleMode: () => void; + // selectors + isShipMode: () => boolean; + hasTrackedShip: () => boolean; + getTrackedCenter: () => TrackedCenter | null; +} + +const useTrackingModeStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 상태 (State) // ===================== - /** 현재 모드: 'map' | 'ship' */ - mode: 'map', + /** 현재 모드 */ + mode: 'map' as TrackingMode, /** 추적 중인 함정 featureId */ trackedShipId: null, @@ -92,9 +110,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ // 액션 (Actions) // ===================== - /** - * 지도 모드로 전환 - */ + /** 지도 모드로 전환 */ setMapMode: () => { set({ mode: 'map', @@ -104,9 +120,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 선박 모드로 전환 (함정 선택 드롭다운 표시) - */ + /** 선박 모드로 전환 (함정 선택 드롭다운 표시) */ setShipMode: () => { set({ mode: 'ship', @@ -114,11 +128,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 함정 선택 - * @param {string} featureId - * @param {Object} ship - 선박 데이터 - */ + /** 함정 선택 */ selectTrackedShip: (featureId, ship) => { set({ mode: 'ship', @@ -128,39 +138,27 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 추적 중인 함정 데이터 업데이트 (실시간) - * @param {Object} ship - */ + /** 추적 중인 함정 데이터 업데이트 (실시간) */ updateTrackedShip: (ship) => { set({ trackedShip: ship }); }, - /** - * 반경 설정 - * @param {number} radiusNM - */ + /** 반경 설정 */ setRadius: (radiusNM) => { set({ radiusNM }); }, - /** - * 함정 선택 드롭다운 토글 - */ + /** 함정 선택 드롭다운 토글 */ toggleShipSelector: () => { set((state) => ({ showShipSelector: !state.showShipSelector })); }, - /** - * 함정 선택 드롭다운 닫기 - */ + /** 함정 선택 드롭다운 닫기 */ closeShipSelector: () => { set({ showShipSelector: false }); }, - /** - * 모드 토글 (지도 <-> 선박) - */ + /** 모드 토글 (지도 <-> 선박) */ toggleMode: () => { const { mode } = get(); if (mode === 'map') { @@ -174,20 +172,13 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ // 셀렉터 (Selectors) // ===================== - /** - * 선박 모드 활성화 여부 - */ + /** 선박 모드 활성화 여부 */ isShipMode: () => get().mode === 'ship', - /** - * 추적 중인 함정이 있는지 - */ + /** 추적 중인 함정이 있는지 */ hasTrackedShip: () => get().trackedShipId !== null, - /** - * 추적 중인 함정의 중심 좌표 - * @returns {{ lon: number, lat: number } | null} - */ + /** 추적 중인 함정의 중심 좌표 */ getTrackedCenter: () => { const { trackedShip } = get(); if (!trackedShip || !trackedShip.longitude || !trackedShip.latitude) return null; diff --git a/src/stores/uiStore.js b/src/stores/uiStore.ts similarity index 54% rename from src/stores/uiStore.js rename to src/stores/uiStore.ts index 52211765..f2bda067 100644 --- a/src/stores/uiStore.js +++ b/src/stores/uiStore.ts @@ -1,9 +1,28 @@ import { create } from 'zustand'; -/** - * UI 상태 관리 스토어 - */ -export const useUIStore = create((set) => ({ +/** 모달 상태 */ +interface ModalState { + isOpen: boolean; + data: unknown; +} + +/** UI 상태 관리 스토어 */ +interface UIStoreState { + // state + isPanelOpen: boolean; + activeMenu: string; + isLoading: boolean; + modals: Record; + // actions + togglePanel: () => void; + setPanel: (isOpen: boolean) => void; + setActiveMenu: (menu: string) => void; + setLoading: (loading: boolean) => void; + openModal: (modalId: string, data?: unknown) => void; + closeModal: (modalId: string) => void; +} + +export const useUIStore = create()((set) => ({ // 사이드패널 열림 상태 isPanelOpen: true, togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), diff --git a/src/tracking/components/GlobalTrackQueryViewer.jsx b/src/tracking/components/GlobalTrackQueryViewer.tsx similarity index 96% rename from src/tracking/components/GlobalTrackQueryViewer.jsx rename to src/tracking/components/GlobalTrackQueryViewer.tsx index b4e4a9a7..8ebbbec3 100644 --- a/src/tracking/components/GlobalTrackQueryViewer.jsx +++ b/src/tracking/components/GlobalTrackQueryViewer.tsx @@ -7,7 +7,7 @@ * - 닫기 버튼으로 항적 레이어 및 데이터 정리 */ -import React, { useCallback, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTrackQueryStore } from '../stores/trackQueryStore'; import { TrackQueryViewer } from './TrackQueryViewer'; import { unregisterTrackQueryLayers } from '../utils/trackQueryLayerUtils'; diff --git a/src/tracking/components/TrackQueryTimeline.jsx b/src/tracking/components/TrackQueryTimeline.tsx similarity index 93% rename from src/tracking/components/TrackQueryTimeline.jsx rename to src/tracking/components/TrackQueryTimeline.tsx index 4df51c53..b05163ef 100644 --- a/src/tracking/components/TrackQueryTimeline.jsx +++ b/src/tracking/components/TrackQueryTimeline.tsx @@ -11,15 +11,18 @@ import { useTrackQueryAnimationStore, PLAYBACK_SPEED_OPTIONS } from '../stores/t import { useTrackQueryStore } from '../stores/trackQueryStore'; import './TrackQueryTimeline.scss'; +interface TrackQueryTimelineProps { + startTime: number; + endTime: number; + compact?: boolean; +} + +type MarkerType = 'start' | 'end'; + /** * 항적조회 타임라인 컨트롤 컴포넌트 - * - * @param {Object} props - * @param {number} props.startTime 데이터 시작 시간 - * @param {number} props.endTime 데이터 종료 시간 - * @param {boolean} [props.compact] 컴팩트 모드 */ -export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { +export const TrackQueryTimeline = ({ startTime, endTime, compact = false }: TrackQueryTimelineProps) => { // 애니메이션 스토어 const isPlaying = useTrackQueryAnimationStore(state => state.isPlaying); const animationCurrentTime = useTrackQueryAnimationStore(state => state.currentTime); @@ -43,11 +46,11 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 배속 드롭다운 상태 const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); - const sliderContainerRef = useRef(null); + const speedMenuRef = useRef(null); + const sliderContainerRef = useRef(null); // 구간반복 마커 드래그 상태 - const [draggingMarker, setDraggingMarker] = useState(null); + const [draggingMarker, setDraggingMarker] = useState(null); // 시간 범위 설정 useEffect(() => { @@ -80,8 +83,8 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 외부 클릭 시 드롭다운 닫기 useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -111,7 +114,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 배속 변경 const handleSpeedChange = useCallback( - (speed) => { + (speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, @@ -120,7 +123,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 슬라이더로 시간 변경 const handleSliderChange = useCallback( - (e) => { + (e: React.ChangeEvent) => { const newTime = parseFloat(e.target.value); setAnimationCurrentTime(newTime); const ratio = (newTime - startTime) / (endTime - startTime); @@ -136,7 +139,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 구간 마커 드래그 시작 const handleMarkerMouseDown = useCallback( - (marker) => (e) => { + (marker: MarkerType) => (e: React.MouseEvent) => { if (isPlaying) return; e.preventDefault(); e.stopPropagation(); @@ -149,8 +152,8 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { useEffect(() => { if (!draggingMarker || !sliderContainerRef.current) return; - const handleMouseMove = (e) => { - const rect = sliderContainerRef.current.getBoundingClientRect(); + const handleMouseMove = (e: MouseEvent) => { + const rect = sliderContainerRef.current!.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const newTime = startTime + ratio * (endTime - startTime); @@ -182,7 +185,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { const loopProgress = getLoopProgress(); // 시간 포맷팅 (날짜 + 시간) - YYYY-MM-DD HH:mm:ss - const formatTime = (timestamp) => { + const formatTime = (timestamp: number) => { if (!timestamp) return '---------- --:--:--'; const date = new Date(timestamp); const year = date.getFullYear(); diff --git a/src/tracking/components/TrackQueryViewer.jsx b/src/tracking/components/TrackQueryViewer.tsx similarity index 90% rename from src/tracking/components/TrackQueryViewer.jsx rename to src/tracking/components/TrackQueryViewer.tsx index 9e294333..b106112a 100644 --- a/src/tracking/components/TrackQueryViewer.jsx +++ b/src/tracking/components/TrackQueryViewer.tsx @@ -34,19 +34,22 @@ import { fromLonLat } from 'ol/proj'; import ShipTooltip from '../../components/ship/ShipTooltip'; import './TrackQueryViewer.scss'; +import type { Layer } from '@deck.gl/core'; +import type OlMap from 'ol/Map'; + /** 일 단위를 밀리초로 변환 */ const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** datetime-local 입력용 포맷 */ -function toDateTimeLocal(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toDateTimeLocal(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** * 유효한 선명인지 확인하고 포맷팅 */ -function formatShipName(name, fallback = '선명 없음') { +function formatShipName(name: string | undefined | null, fallback = '선명 없음'): string { if (!name) return fallback; const trimmed = name.trim(); if (!trimmed) return fallback; @@ -55,6 +58,29 @@ function formatShipName(name, fallback = '선명 없음') { return trimmed; } +/** 시간 범위 */ +interface TimeRange { + fromDate: string; + toDate: string; +} + +/** TrackQueryShipItem Props */ +interface TrackQueryShipItemProps { + vesselId: string; + shipName: string; + targetId: string; + shipKindCode: string; + sigSrcCd: string; + isEnabled: boolean; + isActive: boolean; + isHighlighted: boolean; + speed: number | null; + onToggle: (vesselId: string) => void; + onMouseEnter: (vesselId: string) => void; + onMouseLeave: () => void; + onContextMenu: (vesselId: string, e: React.MouseEvent) => void; +} + /** * 메모이제이션된 선박 목록 아이템 */ @@ -73,7 +99,7 @@ const TrackQueryShipItem = React.memo( onMouseEnter, onMouseLeave, onContextMenu, - }) => ( + }: TrackQueryShipItemProps) => (
        onToggle(vesselId)} @@ -99,19 +125,48 @@ const TrackQueryShipItem = React.memo( prev.speed === next.speed, ); +/** 호버된 선박 데이터 (ShipTooltip용) */ +interface HoveredShipData { + shipName: string; + targetId: string; + signalKindCode: string; + sog: number; + cog: number; +} + +/** 아이콘 호버 시 전달되는 선박 데이터 */ +interface IconHoverShipData { + shipName: string; + vesselId: string; + shipKindCode: string; + speed?: number; + heading?: number; +} + +/** 포인트 호버 정보 */ +interface PointHoverInfo { + vesselId: string; + position: [number, number]; + timestamp: number; + speed: number; + index: number; +} + +/** TrackQueryViewer Props */ +interface TrackQueryViewerProps { + compact?: boolean; + onClose?: () => void; + modalMode?: boolean; + isIntegrated?: boolean; + timeRange?: TimeRange; + onTimeRangeChange?: (timeRange: TimeRange) => void; + onQuery?: () => void; + isQuerying?: boolean; + showPlayback?: boolean; +} + /** * 항적조회 뷰어 메인 컴포넌트 - * - * @param {Object} props - * @param {boolean} [props.compact] 컴팩트 모드 - * @param {Function} [props.onClose] 닫기 핸들러 - * @param {boolean} [props.modalMode] 선박 모달 모드 - * @param {boolean} [props.isIntegrated] 통합선박 여부 - * @param {Object} [props.timeRange] 시간 범위 { fromDate, toDate } - * @param {Function} [props.onTimeRangeChange] 시간 범위 변경 핸들러 - * @param {Function} [props.onQuery] 조회 버튼 클릭 핸들러 - * @param {boolean} [props.isQuerying] 조회 중 상태 - * @param {boolean} [props.showPlayback] 재생 컨트롤 표시 여부 */ export const TrackQueryViewer = ({ compact = false, @@ -123,7 +178,7 @@ export const TrackQueryViewer = ({ onQuery, isQuerying = false, showPlayback = false, -}) => { +}: TrackQueryViewerProps) => { // 스토어 상태 const tracks = useTrackQueryStore(state => state.tracks); const currentTime = useTrackQueryStore(state => state.currentTime); @@ -169,26 +224,26 @@ export const TrackQueryViewer = ({ } = useEquipmentFilter(tracks); // 로컬 상태 - const [hoveredPoint, setHoveredPoint] = useState(null); - const [hoveredShip, setHoveredShip] = useState(null); - const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + const [, setHoveredPoint] = useState(null); + const [hoveredShip, setHoveredShip] = useState(null); + const [, setTooltipPosition] = useState({ x: 0, y: 0 }); const [shipTooltipPosition, setShipTooltipPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); - const [zoomLevel, setZoomLevel] = useState(undefined); + const [zoomLevel, setZoomLevel] = useState(undefined); const [staticLayerVersion, setStaticLayerVersion] = useState(0); - const progressBarRef = useRef(null); - const panelRef = useRef(null); + const progressBarRef = useRef(null); + const panelRef = useRef(null); const pathTriggerRef = useRef(0); const pointsTriggerRef = useRef(0); const dynamicTriggerRef = useRef(0); - const pathLayersRef = useRef([]); - const pointsLayerRef = useRef(null); - const liveConnectionLayerRef = useRef(null); + const pathLayersRef = useRef([]); + const pointsLayerRef = useRef(null); + const liveConnectionLayerRef = useRef(null); const liveConnectionTriggerRef = useRef(0); // 줌 레벨 변경 감지 (클러스터링용) - tracks 변경 시 재시도 useEffect(() => { - const mapInstance = window.__mainMap__; + const mapInstance = (window as unknown as Record).__mainMap__ as OlMap | undefined; if (!mapInstance) return; const view = mapInstance.getView(); @@ -247,7 +302,7 @@ export const TrackQueryViewer = ({ // 현재 시간에 활성 상태인 선박 ID Set const activeVesselIds = useMemo(() => { - const active = new Set(); + const active = new Set(); tracks.forEach(track => { if (track.timestampsMs.length === 0) return; const firstTime = track.timestampsMs[0]; @@ -261,7 +316,7 @@ export const TrackQueryViewer = ({ // 현재 시간의 선박 속도 Map const vesselSpeedMap = useMemo(() => { - const speedMap = new Map(); + const speedMap = new Map(); currentPositions.forEach(pos => { speedMap.set(pos.vesselId, pos.speed ?? null); }); @@ -290,13 +345,13 @@ export const TrackQueryViewer = ({ }, [setHideLiveShips, onClose]); // 포인트 호버 핸들러 - const handlePointHover = useCallback((info, x, y) => { + const handlePointHover = useCallback((info: PointHoverInfo | null, x: number, y: number) => { setHoveredPoint(info); setTooltipPosition({ x, y }); }, []); // 가상 선박 아이콘 호버 핸들러 - const handleIconHover = useCallback((shipData, x, y) => { + const handleIconHover = useCallback((shipData: IconHoverShipData | null, x: number, y: number) => { if (shipData) { // ShipTooltip 형식에 맞게 변환 setHoveredShip({ @@ -317,17 +372,17 @@ export const TrackQueryViewer = ({ // 선박 목록 우클릭 핸들러 (해당 위치로 지도 중심 이동) const handleShipContextMenu = useCallback( - (vesselId, e) => { + (vesselId: string, e: React.MouseEvent) => { e.preventDefault(); - const mapInstance = window.__mainMap__; + const mapInstance = (window as unknown as Record).__mainMap__ as OlMap | undefined; if (!mapInstance) return; const track = tracks.find(t => t.vesselId === vesselId); if (!track || !track.geometry || track.geometry.length === 0) return; - let targetLon; - let targetLat; + let targetLon: number; + let targetLat: number; const currentPos = currentPositions.find(p => p.vesselId === vesselId); if (currentPos) { @@ -336,7 +391,6 @@ export const TrackQueryViewer = ({ } else { if (!track.timestampsMs || track.timestampsMs.length === 0) return; const firstTime = track.timestampsMs[0]; - const lastTime = track.timestampsMs[track.timestampsMs.length - 1]; if (currentTime < firstTime) { targetLon = track.geometry[0][0]; @@ -480,7 +534,7 @@ export const TrackQueryViewer = ({ handleIconHover, ); - const allLayers = [ + const allLayers: Layer[] = [ ...pathLayersRef.current, ...(pointsLayerRef.current ? [pointsLayerRef.current] : []), ...dynamicLayers, @@ -493,13 +547,13 @@ export const TrackQueryViewer = ({ }, [tracks.length, enabledTracks, currentPositions, currentTime, isPlaying, showVirtualShip, showLabels, staticLayerVersion, handleIconHover]); // 프로그레스 바 드래그 핸들러 - const handleProgressMouseDown = useCallback((e) => { + const handleProgressMouseDown = useCallback((e: React.MouseEvent) => { setIsDragging(true); updateProgressFromMouse(e); }, []); const handleProgressMouseMove = useCallback( - (e) => { + (e: MouseEvent) => { if (isDragging) { updateProgressFromMouse(e); } @@ -512,7 +566,7 @@ export const TrackQueryViewer = ({ }, []); const updateProgressFromMouse = useCallback( - (e) => { + (e: MouseEvent | React.MouseEvent) => { if (!progressBarRef.current) return; const rect = progressBarRef.current.getBoundingClientRect(); @@ -537,8 +591,8 @@ export const TrackQueryViewer = ({ }, [isDragging, handleProgressMouseMove, handleProgressMouseUp]); // 패널 드래그 핸들러 - const handlePanelDragStart = useCallback((e) => { - if (e.target.closest('button')) return; + const handlePanelDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; e.preventDefault(); dragStartRef.current = { @@ -555,7 +609,7 @@ export const TrackQueryViewer = ({ useEffect(() => { if (!isDraggingPanel) return; - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { const newDeltaX = dragStartRef.current.deltaX + (e.clientX - dragStartRef.current.x); const newDeltaY = dragStartRef.current.deltaY + (e.clientY - dragStartRef.current.y); setDragDelta({ x: newDeltaX, y: newDeltaY }); @@ -575,7 +629,7 @@ export const TrackQueryViewer = ({ }, [isDraggingPanel]); // 시간 포맷팅 - const formatTime = useCallback((timestamp) => { + const formatTime = useCallback((timestamp: number) => { if (!timestamp) return '--:--:--'; const date = new Date(timestamp); return date.toLocaleTimeString('ko-KR', { @@ -585,7 +639,7 @@ export const TrackQueryViewer = ({ }); }, []); - const formatDate = useCallback((timestamp) => { + const formatDate = useCallback((timestamp: number) => { if (!timestamp) return '----.--.--'; const date = new Date(timestamp); return date.toLocaleDateString('ko-KR', { @@ -596,7 +650,7 @@ export const TrackQueryViewer = ({ }, []); // 조회 기간 검증 및 자동 조정 (blur 시 실행) - const validateAndAdjustTimeRange = useCallback((changedField) => { + const validateAndAdjustTimeRange = useCallback((changedField: 'from' | 'to') => { if (!timeRange || !onTimeRangeChange) return; const fromDate = new Date(timeRange.fromDate); @@ -605,7 +659,7 @@ export const TrackQueryViewer = ({ // 유효하지 않은 날짜면 무시 if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return; - const diffDays = (toDate - fromDate) / DAYS_TO_MS; + const diffDays = (toDate.getTime() - fromDate.getTime()) / DAYS_TO_MS; if (changedField === 'from') { // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 @@ -668,7 +722,7 @@ export const TrackQueryViewer = ({ const singleVessel = isSingleVessel ? tracks[0] : null; // 패널 스타일 (드래그 위치 적용) - const panelStyle = + const panelStyle: React.CSSProperties = dragDelta.x !== 0 || dragDelta.y !== 0 ? { transform: `translateX(calc(-50% + ${dragDelta.x}px)) translateY(${dragDelta.y}px)`, diff --git a/src/tracking/components/VesselListManager/VesselContextMenu.scss b/src/tracking/components/VesselListManager/VesselContextMenu.scss new file mode 100644 index 00000000..673eedf7 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselContextMenu.scss @@ -0,0 +1,83 @@ +.vessel-context-menu { + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + user-select: none; + font-size: 14px; + z-index: 10001; + animation: contextMenuFadeIn 0.15s ease-out; // 애니메이션 + .context-menu-header { + padding: 12px 16px; + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + .vessel-name { + display: block; + font-weight: 600; + color: #212529; + margin-bottom: 4px; + word-break: break-word; + } + + .vessel-id { + font-size: 12px; + color: #6c757d; + font-family: 'Courier New', monospace; + } + } + .context-menu-divider { + height: 1px; + background: #e9ecef; + margin: 0; + } + .context-menu-items { + padding: 8px 0; + + .context-menu-item { + width: 100%; + background: none; + border: none; + padding: 10px 16px; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: #495057; + + &:hover { + background: #f8f9fa; + } + + &:active { + background: #e9ecef; + } + + i { + width: 16px; + color: #213079; + font-size: 14px; + } + + span { + flex: 1; + } + } + } + @keyframes contextMenuFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselItem.scss b/src/tracking/components/VesselListManager/VesselItem.scss new file mode 100644 index 00000000..d5adb7f9 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselItem.scss @@ -0,0 +1,209 @@ +.vessel-item { + display: flex; + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + margin-bottom: 4px; + padding: 8px 12px; + cursor: grab; + transition: all 0.2s ease; + //min-height: 60px; // 가상 스크롤링을 위한 고정 높이 + //max-height: 60px; // 최대 높이 제한 + overflow: hidden; // 내용 넘침 방지 + + &:hover { + background: #f8f9fa; + border-color: #2494d3; + box-shadow: 0 2px 4px rgba(33, 48, 121, 0.1); + } + + &.dragging { + cursor: grabbing; + background: #e3f2fd; + border-color: #2196f3; + box-shadow: 0 8px 16px rgba(33, 150, 243, 0.3); + transform: rotate(5deg) scale(1.05); + z-index: 1000; + opacity: 0.9; + transition: all 0.2s ease; + } + + &.disabled { + cursor: not-allowed; + opacity: 0.6; + + &:hover { + background: #ffffff; + border-color: #e9ecef; + box-shadow: none; + } + } + + &.selected { + background: #e3f2fd; + border-color: #2196f3; + box-shadow: 0 2px 8px rgba(33, 150, 243, 0.15); + + .vessel-name { + color: #1976d2; + font-weight: 700; + } + } + + .vessel-item-content { + display: flex; + align-items: center; + width: 100%; + gap: 0 12px; + } + + .selection-checkbox { + flex-shrink: 0; + cursor: pointer; + position: relative; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 16px; + height: 16px; + border: 2px solid #ced4da; + border-radius: 3px; + background: #ffffff; + transition: all 0.2s ease; + display: block; + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 7px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + + input[type="checkbox"]:checked + .checkmark { + //background: #213079; + border: solid 2px #2494d3; + + //&::after { + // background-image: url("@/assets/img/pub/checkbox-22-on.png"); + // background-repeat: no-repeat; + // background-position: center; + // background-size: 70%; + // background-color: white; + // border: solid 2px #2494d3 !important; + //} + background-image: url("@/assets/img/pub/checkbox-22-on.png"); + background-repeat: no-repeat; + background-position: center; + background-size: 70%; + background-color: white; + border: solid 2px #2494d3 !important; + } + + &:hover .checkmark { + border-color: #213079; + } + } + + .ship-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + border-radius: 4px; + } + } + + .vessel-info { + flex: 1; + min-width: 0; // 텍스트 말줄임을 위한 설정 + + .vessel-name { + font-size: 14px; + font-weight: 600; + color: #212529; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + //margin-bottom: 2px; + display: flex; + align-items: center; + } + + .vessel-details { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #6c757d; + + .country-flag { + font-size: 14px; + } + + .ship-kind { + background: #e9ecef; + padding: 2px 6px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + } + + .signal-source { + color: #868e96; + font-size: 10px; + } + } + } + + .drag-handle { + flex-shrink: 0; + color: #adb5bd; + font-size: 12px; + cursor: grab; + padding: 4px; + + &:hover { + color: #6c757d; + } + } + + .drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(33, 150, 243, 0.1); + border-radius: 8px; + pointer-events: none; + } +} + +// 드래그 플레이스홀더 스타일 +.vessel-item-placeholder { + background: #f8f9fa; + border: 2px dashed #dee2e6; + border-radius: 8px; + height: 60px; + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #adb5bd; + font-size: 12px; +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselListManager.scss b/src/tracking/components/VesselListManager/VesselListManager.scss new file mode 100644 index 00000000..fcc7cb29 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselListManager.scss @@ -0,0 +1,287 @@ +.vessel-list-manager { + // position, top, left는 인라인 스타일에서 처리하므로 제거 + width: 900px; + max-width: calc(100vw - 40px); // 좌우 여백 최소화 + border-radius: 12px; + //box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + z-index: 1200; // ReplayV2(1060)보다 높은 z-index + + &.closed { + .manager-content { + display: none; + } + } + + &.dragging { + z-index: 1300; + + .manager-header { + cursor: grabbing !important; + background: linear-gradient(135deg, #3a5ba7 0%, #4a6bb8 100%); + } + } + + .manager-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: linear-gradient(135deg, #213079 0%, #2c4590 100%); + border-radius: 12px 12px 12px 12px; // 상단 배치시 모든 모서리 라운드 + color: white; + cursor: grab; + user-select: none; + + &:hover { + background: linear-gradient(135deg, #2c4590 0%, #3a5ba7 100%); + } + + &:active { + cursor: grabbing; + } + + .header-left { + display: flex; + align-items: center; + gap: 5px; + flex: 1; + } + + .drag-handle { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + cursor: grab; + padding: 2px; + transition: color 0.2s ease; + + &:hover { + color: rgba(255, 255, 255, 1); + } + + &:active { + cursor: grabbing; + } + } + + .toggle-button { + display: flex; + align-items: center; + gap: 5px; + background: none; + border: none; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + padding: 3px 6px; + border-radius: 5px; + flex: 1; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + + &:active { + background: rgba(255, 255, 255, 0.25); + transform: scale(0.98); + } + + i { + font-size: 12px; + transition: transform 0.2s ease; + } + + span { + white-space: nowrap; + } + } + + .vessel-counts { + display: flex; + align-items: center; + gap: 10px; + + .count-item { + display: flex; + align-items: center; + gap: 2px; + font-size: 11px; + font-weight: 500; + opacity: 0.9; + + .icon { + font-size: 11px; + } + + &.normal { + color: #a8e6a3; + } + + &.selected { + color: #87ceeb; + } + + &.deleted { + color: #ffb3ba; + } + } + } + } + + .manager-content { + max-height: 80vh; // 뷰포트 높이의 80%로 설정하여 더 유연하게 + overflow: hidden; + display: flex; + flex-direction: column; + + .vessel-lists-container { + //display: grid; + //grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 10px; + max-height: 450px; // 패널 영역 높이는 유지 + overflow: hidden; + flex: 1; // 남은 공간 활용 + + // 반응형 레이아웃 + @media (max-width: 700px) { + grid-template-columns: 1fr; + gap: 5px; + padding: 6px; + max-height: 500px; // 모바일에서는 더 높게 + } + + @media (max-width: 1000px) and (min-width: 701px) { + grid-template-columns: 1fr 1fr; + max-height: 400px; // 중간 화면에서는 2열 + .vessel-list-panel:last-child { + grid-column: 1 / -1; + } + } + + // 넓은 화면에서는 항상 3열로 표시 + @media (min-width: 1001px) { + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + padding: 10px; + max-height: 450px; + } + } + + .usage-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 10px; + background: #f8f9fa; + border-top: 1px solid #e9ecef; + color: #6c757d; + font-size: 10px; + font-style: italic; + + i { + color: #007bff; + } + } + } + + // 드래그 중 전체 컨테이너 스타일 조정 + &.dragging { + .manager-content { + user-select: none; + } + } + + // 접기/펼치기 애니메이션 (상단 배치용) + &.open { + .manager-header .toggle-button i { + transform: rotate(0deg); // 열릴 때 아래 화살표 + } + } + + &.closed { + .manager-header .toggle-button i { + transform: rotate(-90deg); // 닫힐 때 오른쪽 화살표 + } + } + + // ReplayV2에서 사용할 때의 스타일 조정 - 화면 전체 기준 고정 위치 + &.vessel-list-manager-replay { + //position: fixed !important; // 화면 전체 기준 고정 + //top: 100px !important; // 화면 상단에서 100px + //right: 50px !important; // 화면 우측에서 50px + //width: 1000px; + //max-width: 80vw !important; + //background-color: #99A3AE; + z-index: 9999 !important; // 모든 요소 위에 표시 + + @media (max-width: 1500px) { + right: 20px; + width: 600px; + max-width: calc(100vw - 40px); + } + + @media (max-width: 1200px) { + width: calc(100vw - 40px); + right: 20px; + left: 20px; + top: 60px; + } + + @media (max-width: 768px) { + top: 100px; // 모바일에서는 더 아래로 + } + } +} + +// 전역 드래그 스타일 (드래그 중인 아이템) +.vessel-item-drag-ghost { + background: #e3f2fd !important; + border-color: #2196f3 !important; + box-shadow: 0 8px 16px rgba(33, 150, 243, 0.3) !important; + transform: rotate(5deg) !important; + z-index: 9999 !important; +} + +// 반응형 조정 +@media (max-width: 1500px) { + .vessel-list-manager { + top: 80px; + width: calc(100vw - 40px); + right: 20px; + left: 20px; + max-width: none; + + .manager-content .vessel-lists-container { + padding: 8px; + } + } +} + +@media (max-width: 1200px) { + .vessel-list-manager { + .manager-header { + padding: 5px 8px; + + .toggle-button { + font-size: 11px; + } + + .vessel-counts { + gap: 8px; + + .count-item { + font-size: 10px; + } + } + } + + .manager-content .vessel-lists-container { + padding: 6px; + gap: 5px; + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselListPanel.scss b/src/tracking/components/VesselListManager/VesselListPanel.scss new file mode 100644 index 00000000..3e93a983 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselListPanel.scss @@ -0,0 +1,280 @@ +.vessel-list-panel { + background: #ffffff; + border-radius: 8px; + //border: 1px solid #e9ecef; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; // 가로 길이를 65%로 제한 + margin: 0 auto; // 중앙 정렬 + + .panel-header { + padding: 12px 16px; + border-bottom: 1px solid #e9ecef; + border-right: 4px solid #28a745; // 우측으로 이동, 인라인 스타일로 오버라이드됨 + background: #f8f9fa; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + + .panel-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: #212529; + + .panel-icon { + font-size: 16px; + } + + .panel-text { + flex: 1; + } + + .panel-count { + color: #6c757d; + font-weight: 500; + font-size: 13px; + background: #e9ecef; + padding: 2px 8px; + border-radius: 12px; + } + } + + .panel-select-all { + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f8f9fa; + } + + .panel-select-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: #495057; + user-select: none; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 16px; + height: 16px; + border: 2px solid #ced4da; + border-radius: 3px; + background: #ffffff; + transition: all 0.2s ease; + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 7px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + input[type="checkbox"]:checked + .checkmark { + background: #213079; + border-color: #213079; + + &::after { + opacity: 1; + } + } + + input[type="checkbox"]:indeterminate + .checkmark { + background: #6c757d; + border-color: #6c757d; + + &::after { + left: 3px; + top: 6px; + width: 8px; + height: 2px; + border: none; + background: white; + transform: none; + opacity: 1; + } + } + + .select-text { + white-space: nowrap; + font-size: 11px; + } + } + } + } + + .vessel-list-container { + flex: 1; + //padding: 12px; + //border: 2px dashed transparent; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + min-height: 200px; + max-height: 350px; // 조금 줄여서 3개 패널이 한 화면에 잘 맞도록 + overflow-y: auto; + transition: all 0.2s ease; + position: relative; + + &.drag-over { + border-style: solid; + box-shadow: inset 0 0 8px rgba(0, 123, 255, 0.1); + transform: scale(1.02); + transition: all 0.2s ease; + } + + // 드래그 타겟 하이라이트 효과 + &.drag-target { + border-color: #007bff !important; + background-color: rgba(0, 123, 255, 0.05) !important; + transform: scale(1.02); + box-shadow: 0 4px 20px rgba(33, 48, 121, 0.15); + transition: all 0.3s ease; + } + + .vessel-list { + display: flex; + flex-direction: column; + } + + .virtual-vessel-list { + flex: 1; + overflow-y: auto; + + // 가상 스크롤링 전용 스크롤바 스타일 + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } + } + + .empty-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 120px; + color: #adb5bd; + text-align: center; + + .empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; + } + + .empty-text { + font-size: 13px; + font-weight: 500; + } + } + + .drop-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.9); + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + color: #007bff; + font-weight: 600; + font-size: 14px; + pointer-events: none; + z-index: 10; + + i { + font-size: 20px; + margin-bottom: 8px; + animation: bounce 1s infinite; + } + + @keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } + } + } + } + + // 스크롤바 스타일링 + .vessel-list-container::-webkit-scrollbar { + width: 6px; + } + + .vessel-list-container::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + .vessel-list-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } +} + +// 상태별 색상 테마 +.vessel-list-panel[data-state="NORMAL"] { + .panel-header { + border-right-color: #28a745; + } +} + +.vessel-list-panel[data-state="SELECTED"] { + .panel-header { + border-right-color: #007bff; + } +} + +.vessel-list-panel[data-state="DELETED"] { + .panel-header { + border-right-color: #dc3545; + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselSearchFilter.scss b/src/tracking/components/VesselListManager/VesselSearchFilter.scss new file mode 100644 index 00000000..339e8cd1 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselSearchFilter.scss @@ -0,0 +1,356 @@ +.vessel-search-filter { + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + padding: 16px; + border-radius: 12px 12px 0 0; + + .search-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; + + .search-input-group { + position: relative; + flex: 1; + min-width: 200px; + + .search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #6c757d; + font-size: 14px; + pointer-events: none; + } + + .search-input { + width: 100%; + padding: 8px 16px 8px 36px; + border: 1px solid #ced4da; + border-radius: 8px; + font-size: 14px; + background: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: #213079; + box-shadow: 0 0 0 2px rgba(33, 48, 121, 0.1); + } + + &::placeholder { + color: #adb5bd; + } + } + + .clear-search-btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #6c757d; + cursor: pointer; + padding: 4px; + border-radius: 4px; + font-size: 12px; + + &:hover { + color: #495057; + background: #e9ecef; + } + } + } + + .select-all-group { + .select-all-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: #495057; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 18px; + height: 18px; + border: 2px solid #ced4da; + border-radius: 4px; + background: #ffffff; + transition: all 0.2s ease; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 5px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + input[type="checkbox"]:checked + .checkmark { + background: #213079; + border-color: #213079; + + &::after { + opacity: 1; + } + } + + input[type="checkbox"]:indeterminate + .checkmark { + background: #6c757d; + border-color: #6c757d; + + &::after { + left: 3px; + top: 7px; + width: 10px; + height: 2px; + border: none; + background: white; + transform: none; + opacity: 1; + } + } + + .select-all-text { + white-space: nowrap; + } + } + } + } + + .filter-row { + display: flex; + align-items: center; + gap: 0px 8px; + flex-wrap: wrap; + margin-bottom: 8px; + width: 100%; + justify-content: space-between; + + .filter-group { + display: flex; + align-items: center; + gap: 6px; + width: 120px; + + label { + font-size: 13px; + font-weight: 500; + color: #495057; + white-space: nowrap; + } + + .filter-select { + padding: 6px 8px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 13px; + background: #ffffff; + min-width: 100px; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #213079; + } + + // 국적 드롭다운은 더 넓게 + //&.country-select { + // min-width: 180px; + // max-width: 250px; + //} + } + } + + .clear-filters-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #ffffff; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 13px; + color: #6c757d; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: #f8f9fa; + border-color: #adb5bd; + color: #495057; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + i { + font-size: 12px; + } + } + } + + // 반응형 조정 + @media (max-width: 768px) { + padding: 12px; + + .search-row { + flex-direction: column; + gap: 12px; + + .search-input-group { + width: 100%; + } + + .select-all-group { + width: 100%; + } + } + + .filter-row { + flex-direction: column; + align-items: stretch; + gap: 8px; + + .filter-group { + justify-content: space-between; + + .filter-select { + flex: 1; + max-width: 200px; + } + } + + .clear-filters-btn { + align-self: flex-end; + } + } + + .filter-summary { + flex-direction: column; + gap: 8px; + align-items: stretch; + + .selected-actions { + flex-direction: column; + gap: 8px; + + .bulk-actions { + justify-content: center; + flex-wrap: wrap; + } + } + } + } +} + +.filter-summary { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: #6c757d; + + .result-count { + font-weight: 500; + } + + .selected-actions { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 8px; + + .selected-count { + background: #213079; + color: white; + padding: 6px 17.5px; + border-radius: 13px; + font-weight: 500; + } + + .bulk-actions { + display: flex; + align-items: center; + gap: 6px; + + .action-label { + font-size: 11px; + color: black; + font-weight: 500; + } + + .bulk-action-btn { + width: 64px; + height: 34px; + display: flex; + justify-content: center; + align-items: center; + //gap: 4px; + //padding: 4px 8px; + border: 1px solid #9eb7d2; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + background-color: #e5f1ff; + color: #426891; + + .icon { + font-size: 12px; + } + + &:hover { + background: #f8f9fa; + border-color: #adb5bd; + } + + &.normal { + &:hover { + background: #d4edda; + border-color: #28a745; + color: #155724; + } + } + + &.selected { + &:hover { + background: #d1ecf1; + border-color: #007bff; + color: #0c5460; + } + } + + &.deleted { + &:hover { + background: #f8d7da; + border-color: #dc3545; + color: #721c24; + } + } + } + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselTooltip.scss b/src/tracking/components/VesselListManager/VesselTooltip.scss new file mode 100644 index 00000000..41c89966 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselTooltip.scss @@ -0,0 +1,188 @@ +.vessel-tooltip { + position: fixed; + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 1400; // VesselListManager(1300)보다 높게 + max-width: 320px; + min-width: 280px; + pointer-events: none; + font-size: 13px; + + .tooltip-header { + padding: 12px 16px; + background: linear-gradient(135deg, #213079 0%, #2c4590 100%); + border-radius: 8px 8px 0 0; + color: white; + + .vessel-name { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .vessel-id { + font-size: 11px; + opacity: 0.8; + font-family: monospace; + } + } + + .tooltip-content { + padding: 12px 16px; + + .info-row { + display: flex; + align-items: flex-start; + margin-bottom: 8px; + gap: 8px; + + &:last-child { + margin-bottom: 0; + } + + label { + flex-shrink: 0; + width: 80px; + font-weight: 600; + color: #495057; + font-size: 12px; + } + + span { + flex: 1; + color: #212529; + word-break: break-word; + + &.ship-kind { + small { + display: block; + color: #6c757d; + font-size: 11px; + margin-top: 2px; + } + } + + &.country { + display: flex; + align-items: center; + gap: 4px; + } + + &.signal-source { + font-family: monospace; + font-size: 12px; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 4px; + } + + &.position { + font-family: monospace; + font-size: 11px; + color: #495057; + } + + &.update-time { + font-size: 11px; + color: #6c757d; + } + + &.state { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + + &.normal { + background: #d4edda; + color: #155724; + } + + &.selected { + background: #d1ecf1; + color: #0c5460; + } + + &.deleted { + background: #f8d7da; + color: #721c24; + } + } + } + } + } + + .tooltip-arrow { + position: absolute; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #ffffff; + + &::before { + content: ''; + position: absolute; + bottom: 1px; + left: -9px; + width: 0; + height: 0; + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 9px solid #e9ecef; + } + } + + // 애니메이션 + animation: tooltipFadeIn 0.2s ease-out; + + @keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translate(-50%, -100%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -100%) scale(1); + } + } + + // 반응형 조정 + @media (max-width: 480px) { + max-width: 280px; + min-width: 240px; + font-size: 12px; + + .tooltip-header { + padding: 10px 12px; + + .vessel-name { + font-size: 13px; + } + + .vessel-id { + font-size: 10px; + } + } + + .tooltip-content { + padding: 10px 12px; + + .info-row { + label { + width: 70px; + font-size: 11px; + } + } + } + } +} \ No newline at end of file diff --git a/src/tracking/hooks/useEquipmentFilter.js b/src/tracking/hooks/useEquipmentFilter.ts similarity index 79% rename from src/tracking/hooks/useEquipmentFilter.js rename to src/tracking/hooks/useEquipmentFilter.ts index f145eb85..4d044856 100644 --- a/src/tracking/hooks/useEquipmentFilter.js +++ b/src/tracking/hooks/useEquipmentFilter.ts @@ -15,24 +15,42 @@ import { SIGNAL_SOURCE_CODE_VPASS, SIGNAL_SOURCE_CODE_VTS_AIS, } from '../../types/constants'; +import type { ProcessedTrack } from '../stores/trackQueryStore'; + +/** 신호원 설정 항목 */ +interface SignalConfig { + key: string; + signalSourceCode: string; + name: string; + shortName: string; + background: string; + priority: number; + displayOrder: number; +} + +/** 장비 목록에 표시되는 확장 항목 */ +interface EquipmentItem extends SignalConfig { + isEnabled: boolean; + trackCount: number; +} /** * 신호 원천별 설정 */ -const SIGNAL_CONFIGS = { +const SIGNAL_CONFIGS: Record = { [SIGNAL_SOURCE_CODE_AIS]: { key: 'A', signalSourceCode: SIGNAL_SOURCE_CODE_AIS, name: 'AIS', shortName: 'AIS', background: '#C2A7DC', priority: 2, displayOrder: 1 }, [SIGNAL_SOURCE_CODE_VPASS]: { key: 'V', signalSourceCode: SIGNAL_SOURCE_CODE_VPASS, name: 'V-Pass', shortName: 'V-P', background: '#8FAEFC', priority: 3, displayOrder: 2 }, [SIGNAL_SOURCE_CODE_ENAV]: { key: 'E', signalSourceCode: SIGNAL_SOURCE_CODE_ENAV, name: 'E-Nav', shortName: 'E-N', background: '#74B2F0', priority: 4, displayOrder: 3 }, [SIGNAL_SOURCE_CODE_VTS_AIS]: { key: 'T', signalSourceCode: SIGNAL_SOURCE_CODE_VTS_AIS, name: 'VTS-AIS', shortName: 'VTS', background: '#4190DF', priority: 1, displayOrder: 4 }, [SIGNAL_SOURCE_CODE_D_MF_HF]: { key: 'D', signalSourceCode: SIGNAL_SOURCE_CODE_D_MF_HF, name: 'D-MF/HF', shortName: 'DMF', background: '#459EF6', priority: 5, displayOrder: 5 }, [SIGNAL_SOURCE_CODE_RADAR]: { key: 'R', signalSourceCode: SIGNAL_SOURCE_CODE_RADAR, name: 'VTS-RT', shortName: 'RT', background: '#4577F6', priority: 6, displayOrder: 6 }, -}; +} as const; /** * 항적 데이터에서 사용된 장비 목록 추출 */ -const extractEquipmentsFromTracks = (tracks) => { - const equipmentCounts = new Map(); +const extractEquipmentsFromTracks = (tracks: ProcessedTrack[]): Map => { + const equipmentCounts = new Map(); tracks.forEach(track => { const sigSrcCd = track.sigSrcCd; @@ -48,18 +66,18 @@ const extractEquipmentsFromTracks = (tracks) => { /** * 장비 필터 훅 * - * @param {Array} tracks 항적 데이터 배열 - * @returns {Object} 장비 필터 관련 상태 및 함수 + * @param tracks 항적 데이터 배열 + * @returns 장비 필터 관련 상태 및 함수 */ -export const useEquipmentFilter = (tracks) => { - const [enabledSet, setEnabledSet] = useState(new Set()); +export const useEquipmentFilter = (tracks: ProcessedTrack[]) => { + const [enabledSet, setEnabledSet] = useState>(new Set()); const equipmentData = useMemo(() => { return extractEquipmentsFromTracks(tracks); }, [tracks]); - const equipments = useMemo(() => { - const items = []; + const equipments = useMemo(() => { + const items: EquipmentItem[] = []; equipmentData.forEach((count, sigSrcCd) => { const config = SIGNAL_CONFIGS[sigSrcCd]; @@ -75,7 +93,7 @@ export const useEquipmentFilter = (tracks) => { return items.sort((a, b) => a.displayOrder - b.displayOrder); }, [equipmentData, enabledSet]); - const highestPriorityEquipment = useMemo(() => { + const highestPriorityEquipment = useMemo(() => { if (equipments.length === 0) return null; return equipments.reduce((prev, curr) => (curr.priority < prev.priority ? curr : prev)); }, [equipments]); @@ -95,7 +113,7 @@ export const useEquipmentFilter = (tracks) => { } }, [equipmentData]); - const toggleEquipment = useCallback((signalSourceCode) => { + const toggleEquipment = useCallback((signalSourceCode: string) => { setEnabledSet(prev => { const next = new Set(prev); if (next.has(signalSourceCode)) { diff --git a/src/tracking/hooks/useTrackHighlight.js b/src/tracking/hooks/useTrackHighlight.ts similarity index 91% rename from src/tracking/hooks/useTrackHighlight.js rename to src/tracking/hooks/useTrackHighlight.ts index ea72f710..69c8f610 100644 --- a/src/tracking/hooks/useTrackHighlight.js +++ b/src/tracking/hooks/useTrackHighlight.ts @@ -14,21 +14,21 @@ export const useTrackHighlight = () => { const setHighlightedVesselId = useTrackQueryStore(state => state.setHighlightedVesselId); const handleListItemHover = useCallback( - (vesselId) => { + (vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId], ); const handlePathHover = useCallback( - (vesselId) => { + (vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId], ); const isHighlighted = useCallback( - (vesselId) => { + (vesselId: string) => { return highlightedVesselId === vesselId; }, [highlightedVesselId], diff --git a/src/tracking/services/trackQueryApi.js b/src/tracking/services/trackQueryApi.ts similarity index 70% rename from src/tracking/services/trackQueryApi.js rename to src/tracking/services/trackQueryApi.ts index a4e76c7f..71ad63b9 100644 --- a/src/tracking/services/trackQueryApi.js +++ b/src/tracking/services/trackQueryApi.ts @@ -5,6 +5,71 @@ * dark 프로젝트: axios 대신 fetch API 사용 */ import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY } from '../../types/constants'; +import type { ProcessedTrack } from '../stores/trackQueryStore'; +import type { LonLat } from '../types/trackQuery.types'; + +// ========== 타입 정의 ========== + +/** 조회 대상 선박 */ +export interface VesselQueryTarget { + sigSrcCd: string; + targetId: string; +} + +/** 항적 조회 요청 파라미터 */ +export interface FetchVesselTracksParams { + startTime: string; + endTime: string; + vessels: VesselQueryTarget[]; + isIntegration?: string; +} + +/** API 응답 항적 데이터 (raw) */ +interface ApiTrackData { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode?: string; + nationalCode?: string; + integrationTargetId?: string; + geometry: LonLat[]; + timestamps: string[]; + speeds?: number[]; + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; +} + +/** 레이더 타겟 조회 결과 */ +interface RadarTargetCheckResult { + canQuery: boolean; + vessel?: VesselQueryTarget; + errorMessage?: string; +} + +/** 항적조회 선박 목록 빌드 결과 */ +export interface BuildVesselListResult { + canQuery: boolean; + vessels: VesselQueryTarget[]; + errorMessage?: string; +} + +/** shipStore의 선박 데이터 (필요한 프로퍼티만) */ +interface ShipFeature { + targetId: string; + signalSourceCode: string; + originalTargetId?: string; + integrate?: boolean; + ais?: string; + vpass?: string; + enav?: string; + vtsAis?: string; + dMfHf?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic properties from shipStore + [key: string]: any; +} /** API 엔드포인트 */ const API_ENDPOINT = '/api/v2/tracks/vessels'; @@ -12,15 +77,11 @@ const API_ENDPOINT = '/api/v2/tracks/vessels'; /** * 선박 항적 조회 (v2 API) * - * @param {Object} params 조회 파라미터 - * @param {string} params.startTime ISO 8601 형식 시작 시간 - * @param {string} params.endTime ISO 8601 형식 종료 시간 - * @param {Array<{sigSrcCd: string, targetId: string}>} params.vessels 조회 대상 선박 목록 - * @param {string} [params.isIntegration] 통합선박 조회 시 "1" - * @returns {Promise} 항적 데이터 배열 + * @param params 조회 파라미터 + * @returns 항적 데이터 배열 */ -export async function fetchVesselTracksV2(params) { - const request = { +export async function fetchVesselTracksV2(params: FetchVesselTracksParams): Promise { + const request: Record = { startTime: params.startTime, endTime: params.endTime, vessels: params.vessels, @@ -45,7 +106,13 @@ export async function fetchVesselTracksV2(params) { /** * 단일 선박 항적 조회 헬퍼 */ -export async function fetchSingleVesselTrackV2(sigSrcCd, targetId, startTime, endTime, isIntegration) { +export async function fetchSingleVesselTrackV2( + sigSrcCd: string, + targetId: string, + startTime: string, + endTime: string, + isIntegration?: string, +): Promise { const tracks = await fetchVesselTracksV2({ startTime, endTime, @@ -58,7 +125,7 @@ export async function fetchSingleVesselTrackV2(sigSrcCd, targetId, startTime, en /** * 유효한 선명인지 확인 */ -function isValidApiShipName(name) { +function isValidApiShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -72,10 +139,10 @@ function isValidApiShipName(name) { * - timestamps를 밀리초로 변환 * - 시간순 정렬 보장 * - * @param {Array} tracks API 응답 항적 배열 - * @returns {Array} ProcessedTrackV2 배열 + * @param tracks API 응답 항적 배열 + * @returns ProcessedTrackV2 배열 */ -export function convertToProcessedTracks(tracks) { +export function convertToProcessedTracks(tracks: ApiTrackData[]): ProcessedTrack[] { return tracks.map(track => { // timestamps를 밀리초로 변환 const timestampsMs = track.timestamps.map(ts => { @@ -114,15 +181,15 @@ export function convertToProcessedTracks(tracks) { * 통합선박 TARGET_ID 파싱 * 형식: AIS_VPASS_ENAV_VTSAIS_DMFHF * - * @param {string} targetId 통합 TARGET_ID - * @returns {Array<{sigSrcCd: string, targetId: string}>|null} + * @param targetId 통합 TARGET_ID + * @returns 선박 목록 또는 null */ -export function parseIntegratedTargetId(targetId) { +export function parseIntegratedTargetId(targetId: string): VesselQueryTarget[] | null { const parts = targetId.split('_'); if (parts.length !== 5) return null; const [ais, vpass, enav, vtsAis, dmfhf] = parts; - const vessels = []; + const vessels: VesselQueryTarget[] = []; if (ais) vessels.push({ sigSrcCd: '000001', targetId: ais }); if (vpass) vessels.push({ sigSrcCd: '000003', targetId: vpass }); @@ -134,23 +201,23 @@ export function parseIntegratedTargetId(targetId) { } /** 통합선박 여부 확인 */ -export function isIntegratedVessel(signalSourceCode) { +export function isIntegratedVessel(signalSourceCode: string): boolean { return signalSourceCode === '999999'; } /** 레이더 타겟 여부 확인 */ -export function isRadarTarget(signalSourceCode) { +export function isRadarTarget(signalSourceCode: string): boolean { return signalSourceCode === '000005'; } /** * 레이더 타겟 항적조회 가능 여부 확인 * - * @param {string} targetId 레이더 타겟의 TARGET_ID - * @param {string} [isPriority] IS_PRIORITY 값 - * @returns {{ canQuery: boolean, vessel?: Object, errorMessage?: string }} + * @param targetId 레이더 타겟의 TARGET_ID + * @param _isPriority IS_PRIORITY 값 + * @returns 조회 가능 여부 및 선박 정보 */ -export function checkRadarTargetForTrackQuery(targetId, isPriority) { +export function checkRadarTargetForTrackQuery(targetId: string, _isPriority?: string): RadarTargetCheckResult { const isIntegrated = targetId.includes('_'); if (!isIntegrated) { @@ -184,10 +251,10 @@ export function checkRadarTargetForTrackQuery(targetId, isPriority) { /** * 통합선박 전체 장비 조회용 선박 목록 반환 * - * @param {string} targetId 통합선박 TARGET_ID - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param targetId 통합선박 TARGET_ID + * @returns 선박 목록 */ -export function getAllVesselsForIntegratedShip(targetId) { +export function getAllVesselsForIntegratedShip(targetId: string): VesselQueryTarget[] { if (!targetId.includes('_')) return []; const vessels = parseIntegratedTargetId(targetId); return vessels || []; @@ -197,10 +264,10 @@ export function getAllVesselsForIntegratedShip(targetId) { * 통합선박의 활성화된 장비만 조회용 선박 목록 반환 * dark 프로젝트의 ship 객체 프로퍼티 기반 * - * @param {Object} ship shipStore의 선박 데이터 - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param ship shipStore의 선박 데이터 + * @returns 선박 목록 */ -export function getActiveVesselsForIntegratedShip(ship) { +export function getActiveVesselsForIntegratedShip(ship: ShipFeature): VesselQueryTarget[] { const targetId = ship.targetId; if (!targetId || !targetId.includes('_')) return []; @@ -208,7 +275,7 @@ export function getActiveVesselsForIntegratedShip(ship) { if (parts.length !== 5) return []; const [ais, vpass, enav, vtsAis, dmfhf] = parts; - const vessels = []; + const vessels: VesselQueryTarget[] = []; if (ais && ship.ais === '1') vessels.push({ sigSrcCd: '000001', targetId: ais }); if (vpass && ship.vpass === '1') vessels.push({ sigSrcCd: '000003', targetId: vpass }); @@ -222,18 +289,18 @@ export function getActiveVesselsForIntegratedShip(ship) { /** * 통합선박 TARGET_ID 여부 확인 */ -export function isIntegratedTargetId(targetId) { - return targetId && targetId.includes('_'); +export function isIntegratedTargetId(targetId: string): boolean { + return !!targetId && targetId.includes('_'); } /** * 통합선박에서 활성화된 장비 중 우선순위가 가장 높은 선박 반환 * dark 프로젝트의 ship 객체 프로퍼티 기반 * - * @param {Object} ship shipStore의 선박 데이터 - * @returns {{ sigSrcCd: string, targetId: string }|null} + * @param ship shipStore의 선박 데이터 + * @returns 최고 우선순위 선박 또는 null */ -export function getHighestPriorityActiveVessel(ship) { +export function getHighestPriorityActiveVessel(ship: ShipFeature): VesselQueryTarget | null { const targetId = ship.targetId; if (!targetId || !targetId.includes('_')) return null; @@ -269,13 +336,18 @@ export function getHighestPriorityActiveVessel(ship) { * - 레이더 타겟 → 제외 * - 나머지 → sigSrcCd + originalTargetId 직접 전달 * - * @param {Object} ship shipStore의 선박 데이터 - * @param {'modal'|'rightClick'} mode 조회 모드 - * @param {boolean} isIntegrate 통합선박 ON/OFF 상태 - * @param {Map} [features] shipStore.features (레이더+단독선박 통합 시 비레이더 탐색용) - * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + * @param ship shipStore의 선박 데이터 + * @param mode 조회 모드 + * @param isIntegrate 통합선박 ON/OFF 상태 + * @param features shipStore.features (레이더+단독선박 통합 시 비레이더 탐색용) + * @returns 조회 가능 여부 및 선박 목록 */ -export function buildVesselListForQuery(ship, mode, isIntegrate, features) { +export function buildVesselListForQuery( + ship: ShipFeature, + mode: 'modal' | 'rightClick', + isIntegrate: boolean, + features?: Map, +): BuildVesselListResult { const sigSrcCd = ship.signalSourceCode || ''; const targetId = ship.targetId || ''; const isRadar = isRadarTarget(sigSrcCd); @@ -336,23 +408,26 @@ export function buildVesselListForQuery(ship, mode, isIntegrate, features) { * 레이더 + 단독선박 통합 시 비레이더 feature를 탐색하여 요청 파라미터 생성 * shipStore.features에서 같은 targetId의 비레이더 신호원을 찾아 우선순위 기반 선택 * - * @param {Object} ship 레이더 타겟 ship 객체 - * @param {Map} features shipStore.features - * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + * @param ship 레이더 타겟 ship 객체 + * @param features shipStore.features + * @returns 조회 가능 여부 및 선박 목록 */ -function resolveStandaloneForRadar(ship, features) { +function resolveStandaloneForRadar( + ship: ShipFeature, + features?: Map, +): BuildVesselListResult { if (!features) { return { canQuery: false, vessels: [], errorMessage: '통합된 단독선박 정보를 찾을 수 없습니다.' }; } const targetId = ship.targetId; - let bestVessel = null; + let bestVessel: VesselQueryTarget | null = null; let bestRank = 99; features.forEach((f) => { if (f.targetId !== targetId) return; if (isRadarTarget(f.signalSourceCode)) return; - const rank = SOURCE_PRIORITY_RANK[f.signalSourceCode] ?? 99; - const activeKey = SOURCE_TO_ACTIVE_KEY[f.signalSourceCode]; + const rank = (SOURCE_PRIORITY_RANK as Record)[f.signalSourceCode] ?? 99; + const activeKey = (SOURCE_TO_ACTIVE_KEY as Record)[f.signalSourceCode]; // is_active 확인 if (activeKey && f[activeKey] === '1' && rank < bestRank) { bestRank = rank; @@ -378,11 +453,11 @@ function resolveStandaloneForRadar(ship, features) { * 항적조회 요청 파라미터 중복 제거 * (sigSrcCd, targetId) 조합이 동일한 항목 제거 * - * @param {Array<{sigSrcCd: string, targetId: string}>} vessels - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param vessels 선박 목록 + * @returns 중복 제거된 선박 목록 */ -export function deduplicateVessels(vessels) { - const seen = new Set(); +export function deduplicateVessels(vessels: VesselQueryTarget[]): VesselQueryTarget[] { + const seen = new Set(); return vessels.filter(v => { const key = `${v.sigSrcCd}_${v.targetId}`; if (seen.has(key)) return false; diff --git a/src/tracking/stores/trackQueryAnimationStore.js b/src/tracking/stores/trackQueryAnimationStore.ts similarity index 77% rename from src/tracking/stores/trackQueryAnimationStore.js rename to src/tracking/stores/trackQueryAnimationStore.ts index 79e19142..4ffd9286 100644 --- a/src/tracking/stores/trackQueryAnimationStore.js +++ b/src/tracking/stores/trackQueryAnimationStore.ts @@ -8,12 +8,38 @@ */ import { create } from 'zustand'; -// 애니메이션 프레임 관리용 변수 (스토어 외부) -let animationFrameId = null; -let lastFrameTime = null; +// ========== 타입 정의 ========== -export const useTrackQueryAnimationStore = create((set, get) => { - const animate = () => { +interface TrackQueryAnimationState { + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + loop: boolean; + loopStart: number; + loopEnd: number; + + play: () => void; + pause: () => void; + stop: () => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + toggleLoop: () => void; + setLoopSection: (start: number, end: number) => void; + resetLoopSection: () => void; + setTimeRange: (start: number, end: number) => void; + getProgress: () => number; + getLoopProgress: () => { start: number; end: number }; + reset: () => void; +} + +// 애니메이션 프레임 관리용 변수 (스토어 외부) +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; + +export const useTrackQueryAnimationStore = create()((set, get) => { + const animate = (): void => { const state = get(); if (!state.isPlaying) return; @@ -93,17 +119,17 @@ export const useTrackQueryAnimationStore = create((set, get) => { set({ isPlaying: false, currentTime: get().startTime }); }, - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { const { startTime, endTime } = get(); const clampedTime = Math.max(startTime, Math.min(endTime, time)); set({ currentTime: clampedTime }); }, - setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + setPlaybackSpeed: (speed: number) => set({ playbackSpeed: speed }), toggleLoop: () => set({ loop: !get().loop }), - setLoopSection: (start, end) => { + setLoopSection: (start: number, end: number) => { const { startTime, endTime } = get(); const clampedStart = Math.max(startTime, Math.min(end, start)); const clampedEnd = Math.max(start, Math.min(endTime, end)); @@ -115,7 +141,7 @@ export const useTrackQueryAnimationStore = create((set, get) => { set({ loopStart: startTime, loopEnd: endTime }); }, - setTimeRange: (start, end) => { + setTimeRange: (start: number, end: number) => { set({ startTime: start, endTime: end, @@ -162,4 +188,4 @@ export const useTrackQueryAnimationStore = create((set, get) => { }); /** 재생 가능한 배속 옵션 */ -export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000]; +export const PLAYBACK_SPEED_OPTIONS: number[] = [1, 5, 10, 50, 100, 1000]; diff --git a/src/tracking/stores/trackQueryStore.js b/src/tracking/stores/trackQueryStore.ts similarity index 61% rename from src/tracking/stores/trackQueryStore.js rename to src/tracking/stores/trackQueryStore.ts index 19d56b1b..ef0b1722 100644 --- a/src/tracking/stores/trackQueryStore.js +++ b/src/tracking/stores/trackQueryStore.ts @@ -8,11 +8,109 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import useShipStore from '../../stores/shipStore'; +import type { LonLat } from '../types/trackQuery.types'; + +// ========== 타입 정의 ========== + +/** 가공된 항적 데이터 */ +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId?: string; + geometry: LonLat[]; + timestampsMs: number[]; + speeds: number[]; + stats?: { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; + }; +} + +/** 선박 현재 위치 */ +export interface VesselPosition { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + position: LonLat; + heading: number; + speed: number; + timestamp: number; +} + +/** 호버된 포인트 정보 */ +export interface HoveredPointInfo { + vesselId: string; + position: LonLat; + timestamp: number; + speed: number; + index: number; +} + +/** 라이브 선박 정보 결과 */ +interface LiveShipInfo { + shipName: string | null; + shipKindCode: string | null; + integrationTargetId: string | null; +} + +/** 항적조회 스토어 상태 */ +interface TrackQueryState { + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + dataStartTime: number; + dataEndTime: number; + requestedStartTime: number; + currentTime: number; + isLoading: boolean; + error: string | null; + showPoints: boolean; + showVirtualShip: boolean; + hideLiveShips: boolean; + showLabels: boolean; + isModalMode: boolean; + modalSourceId: string | null; + highlightedVesselId: string | null; + showPlayback: boolean; + + // 포인트 호버 정보 + hoveredPoint: HoveredPointInfo | null; + hoveredPointPosition: { x: number; y: number }; + + setTracks: (tracks: ProcessedTrack[], requestedStartTime: number, showPlayback?: boolean) => void; + setCurrentTime: (time: number) => void; + setProgressByRatio: (ratio: number) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setShowPoints: (show: boolean) => void; + setShowVirtualShip: (show: boolean) => void; + setHideLiveShips: (hide: boolean) => void; + setShowLabels: (show: boolean) => void; + setModalMode: (isModal: boolean, sourceId?: string | null) => void; + getModalSourceId: () => string | null; + setHighlightedVesselId: (vesselId: string | null) => void; + setHoveredPoint: (point: HoveredPointInfo | null, x?: number, y?: number) => void; + clearHoveredPoint: () => void; + toggleVesselEnabled: (vesselId: string) => void; + isVesselEnabled: (vesselId: string) => boolean; + getEnabledTracks: () => ProcessedTrack[]; + getProgress: () => number; + getCurrentPositions: () => VesselPosition[]; + reset: () => void; +} /** * 유효한 선명인지 확인 */ -function isValidShipName(name) { +function isValidShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -26,12 +124,13 @@ function isValidShipName(name) { * 라이브 선박 데이터 가져오기 * dark 프로젝트의 shipStore.features 기반 */ -function getLiveShipInfo(sigSrcCd, targetId) { +function getLiveShipInfo(sigSrcCd: string, targetId: string): LiveShipInfo { const { features } = useShipStore.getState(); const featureKey = `${sigSrcCd}${targetId}`; - const liveShip = features.get(featureKey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shipStore is not yet typed + const liveShip = (features as Map).get(featureKey); - const result = { shipName: null, shipKindCode: null, integrationTargetId: null }; + const result: LiveShipInfo = { shipName: null, shipKindCode: null, integrationTargetId: null }; if (liveShip) { if (isValidShipName(liveShip.shipName)) { @@ -51,10 +150,10 @@ function getLiveShipInfo(sigSrcCd, targetId) { /** * 항적 데이터에 라이브 선박 정보 병합 */ -function mergeWithLiveData(tracks) { +function mergeWithLiveData(tracks: ProcessedTrack[]): ProcessedTrack[] { return tracks.map(track => { const liveInfo = getLiveShipInfo(track.sigSrcCd, track.targetId); - let updated = { ...track }; + const updated = { ...track }; let hasChanges = false; if (liveInfo.shipName) { @@ -88,7 +187,7 @@ function mergeWithLiveData(tracks) { /** * 두 지점 사이의 선박 위치를 시간 기반으로 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: LonLat, p2: LonLat, t1: number, t2: number, currentTime: number): LonLat { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -103,7 +202,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 두 지점 간의 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: LonLat, p2: LonLat): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; @@ -120,9 +219,9 @@ function calculateHeading(p1, p2) { /** * 항적조회 스토어 */ -export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ +export const useTrackQueryStore = create()(subscribeWithSelector((set, get) => ({ tracks: [], - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, @@ -146,7 +245,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ * 항적 데이터 설정 * 라이브 선박 데이터와 병합하여 선명 정보 보완 */ - setTracks: (tracks, requestedStartTime, showPlayback = false) => { + setTracks: (tracks: ProcessedTrack[], requestedStartTime: number, showPlayback = false) => { if (tracks.length === 0) { set({ tracks: [], @@ -186,27 +285,27 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ }); }, - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { const { dataStartTime, dataEndTime } = get(); const clampedTime = Math.max(dataStartTime, Math.min(dataEndTime, time)); set({ currentTime: clampedTime }); }, - setProgressByRatio: (ratio) => { + setProgressByRatio: (ratio: number) => { const { dataStartTime, dataEndTime } = get(); const clampedRatio = Math.max(0, Math.min(1, ratio)); const newTime = dataStartTime + (dataEndTime - dataStartTime) * clampedRatio; set({ currentTime: newTime }); }, - setLoading: (loading) => set({ isLoading: loading }), - setError: (error) => set({ error }), - setShowPoints: (show) => set({ showPoints: show }), - setShowVirtualShip: (show) => set({ showVirtualShip: show }), - setHideLiveShips: (hide) => set({ hideLiveShips: hide }), - setShowLabels: (show) => set({ showLabels: show }), + setLoading: (loading: boolean) => set({ isLoading: loading }), + setError: (error: string | null) => set({ error }), + setShowPoints: (show: boolean) => set({ showPoints: show }), + setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }), + setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }), + setShowLabels: (show: boolean) => set({ showLabels: show }), - setModalMode: (isModal, sourceId) => { + setModalMode: (isModal: boolean, sourceId?: string | null) => { set({ isModalMode: isModal, modalSourceId: isModal ? (sourceId || null) : null, @@ -215,9 +314,9 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ getModalSourceId: () => get().modalSourceId, - setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }), + setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }), - setHoveredPoint: (point, x, y) => set({ + setHoveredPoint: (point: HoveredPointInfo | null, x?: number, y?: number) => set({ hoveredPoint: point, hoveredPointPosition: { x: x || 0, y: y || 0 }, }), @@ -227,7 +326,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ hoveredPointPosition: { x: 0, y: 0 }, }), - toggleVesselEnabled: (vesselId) => { + toggleVesselEnabled: (vesselId: string) => { const { disabledVesselIds } = get(); const newDisabled = new Set(disabledVesselIds); if (newDisabled.has(vesselId)) { @@ -238,7 +337,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ set({ disabledVesselIds: newDisabled }); }, - isVesselEnabled: (vesselId) => !get().disabledVesselIds.has(vesselId), + isVesselEnabled: (vesselId: string) => !get().disabledVesselIds.has(vesselId), getEnabledTracks: () => { const { tracks, disabledVesselIds } = get(); @@ -257,7 +356,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: () => { const { tracks, currentTime, disabledVesselIds } = get(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach(track => { if (disabledVesselIds.has(track.vesselId)) return; @@ -285,9 +384,9 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, left - 1); const idx2 = Math.min(timestampsMs.length - 1, left); - let position; - let heading; - let speed; + let position: LonLat; + let heading: number; + let speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -328,7 +427,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ reset: () => { set({ tracks: [], - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, @@ -350,10 +449,10 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ }))); // 편의 셀렉터 -export const useTrackQueryTracks = () => useTrackQueryStore(state => state.tracks); -export const useTrackQueryCurrentTime = () => useTrackQueryStore(state => state.currentTime); -export const useTrackQueryIsLoading = () => useTrackQueryStore(state => state.isLoading); -export const useTrackQueryShowPoints = () => useTrackQueryStore(state => state.showPoints); -export const useTrackQueryShowVirtualShip = () => useTrackQueryStore(state => state.showVirtualShip); +export const useTrackQueryTracks = (): ProcessedTrack[] => useTrackQueryStore(state => state.tracks); +export const useTrackQueryCurrentTime = (): number => useTrackQueryStore(state => state.currentTime); +export const useTrackQueryIsLoading = (): boolean => useTrackQueryStore(state => state.isLoading); +export const useTrackQueryShowPoints = (): boolean => useTrackQueryStore(state => state.showPoints); +export const useTrackQueryShowVirtualShip = (): boolean => useTrackQueryStore(state => state.showVirtualShip); export default useTrackQueryStore; diff --git a/src/tracking/types/trackQuery.types.js b/src/tracking/types/trackQuery.types.ts similarity index 62% rename from src/tracking/types/trackQuery.types.js rename to src/tracking/types/trackQuery.types.ts index 6c35ff35..4efc492a 100644 --- a/src/tracking/types/trackQuery.types.js +++ b/src/tracking/types/trackQuery.types.ts @@ -7,10 +7,24 @@ * TypeScript 타입은 JSDoc 주석으로 대체 */ +// ========== 공통 타입 ========== + +/** RGBA 색상 튜플 */ +export type RGBAColor = [number, number, number, number]; + +/** 좌표 [경도, 위도] */ +export type LonLat = [number, number]; + +/** 선종 코드 */ +export type ShipKindCode = '000020' | '000021' | '000022' | '000023' | '000024' | '000025' | '000027' | '000028'; + +/** 신호원 코드 */ +export type SignalSourceCode = '000001' | '000002' | '000003' | '000004' | '000005' | '000016' | '999999'; + // ========== 선종 색상 매핑 ========== /** 선종별 색상 정의 (RGBA, 60% 투명도) */ -export const SHIP_KIND_COLORS = { +export const SHIP_KIND_COLORS: Record = { '000020': [25, 116, 25, 150], // 어선 - 녹색 '000021': [0, 41, 255, 150], // 함정 - 파란색 '000022': [176, 42, 42, 150], // 여객선 - 빨간색 @@ -22,17 +36,17 @@ export const SHIP_KIND_COLORS = { }; /** 기본 색상 (선종 미확인 시) */ -export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; +export const DEFAULT_TRACK_COLOR: RGBAColor = [128, 128, 128, 150]; /** 선종 코드로 색상 가져오기 */ -export function getShipKindColor(shipKindCode) { +export function getShipKindColor(shipKindCode: string | undefined | null): RGBAColor { if (!shipKindCode) return DEFAULT_TRACK_COLOR; return SHIP_KIND_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; } /** 선종 코드 → 선종명 */ -export function getShipKindName(shipKindCode) { - const names = { +export function getShipKindName(shipKindCode: string | undefined | null): string { + const names: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -46,8 +60,8 @@ export function getShipKindName(shipKindCode) { } /** 신호원 코드 → 신호원명 */ -export function getSignalSourceName(sigSrcCd) { - const names = { +export function getSignalSourceName(sigSrcCd: string | undefined | null): string { + const names: Record = { '000001': 'AIS', '000002': 'E-Nav', '000003': 'V-Pass', diff --git a/src/tracking/utils/resetTrackQuery.js b/src/tracking/utils/resetTrackQuery.ts similarity index 82% rename from src/tracking/utils/resetTrackQuery.js rename to src/tracking/utils/resetTrackQuery.ts index d439e11f..00775947 100644 --- a/src/tracking/utils/resetTrackQuery.js +++ b/src/tracking/utils/resetTrackQuery.ts @@ -7,13 +7,14 @@ import { useTrackQueryStore } from '../stores/trackQueryStore'; import { unregisterTrackQueryLayers } from './trackQueryLayerUtils'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; +import type Map from 'ol/Map'; /** * 항적조회 상태 및 레이어 초기화 * - * @param {Object} [map] OpenLayers Map 인스턴스 + * @param map OpenLayers Map 인스턴스 */ -export const resetTrackQuery = (map) => { +export const resetTrackQuery = (map?: Map): void => { // 스토어 초기화 useTrackQueryStore.getState().reset(); diff --git a/src/tracking/utils/shipIconUtil.js b/src/tracking/utils/shipIconUtil.ts similarity index 56% rename from src/tracking/utils/shipIconUtil.js rename to src/tracking/utils/shipIconUtil.ts index f76e4eb9..dfd063ca 100644 --- a/src/tracking/utils/shipIconUtil.js +++ b/src/tracking/utils/shipIconUtil.ts @@ -9,27 +9,27 @@ import { ICON_MAPPING_KIND_MOVING, ICON_MAPPING_KIND_STOPPING } from '../../type /** * 선박 종류 코드와 항해 상태로 아이콘 이름 가져오기 * - * @param {string} shipKindCode 선박 종류 코드 (000020-000028) - * @param {boolean} [isMoving=true] 항해 중 여부 - * @returns {string} 아이콘 매핑 키 + * @param shipKindCode 선박 종류 코드 (000020-000028) + * @param isMoving 항해 중 여부 + * @returns 아이콘 매핑 키 */ -export function getShipIconUrl(shipKindCode, isMoving = true) { +export function getShipIconUrl(shipKindCode: string, isMoving = true): string { const iconMapping = isMoving ? ICON_MAPPING_KIND_MOVING : ICON_MAPPING_KIND_STOPPING; - return iconMapping[shipKindCode] || iconMapping['000027']; + return (iconMapping as Record)[shipKindCode] || (iconMapping as Record)['000027']; } /** * 속도 기반 선박 아이콘 가져오기 * 0.5 knots를 기준으로 항해 중 여부를 결정 * - * @param {string} shipKindCode 선박 종류 코드 - * @param {number} [speed=0] 선박 속도 (knots) - * @returns {string} 아이콘 매핑 키 + * @param shipKindCode 선박 종류 코드 + * @param speed 선박 속도 (knots) + * @returns 아이콘 매핑 키 */ -export function getV2ShipIconUrl(shipKindCode, speed = 0) { +export function getV2ShipIconUrl(shipKindCode: string, speed = 0): string { const isMoving = speed > 0.5; return getShipIconUrl(shipKindCode, isMoving); } @@ -37,11 +37,11 @@ export function getV2ShipIconUrl(shipKindCode, speed = 0) { /** * 선박 종류 코드를 한글 이름으로 변환 * - * @param {string} shipKindCode 선박 종류 코드 - * @returns {string} 선박 타입 한글 이름 + * @param shipKindCode 선박 종류 코드 + * @returns 선박 타입 한글 이름 */ -export function getShipKindName(shipKindCode) { - const kindNames = { +export function getShipKindName(shipKindCode: string): string { + const kindNames: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -58,11 +58,11 @@ export function getShipKindName(shipKindCode) { /** * 신호 소스 코드를 이름으로 변환 * - * @param {string} signalSourceCode 신호 소스 코드 - * @returns {string} 신호 소스 이름 + * @param signalSourceCode 신호 소스 코드 + * @returns 신호 소스 이름 */ -export function getSignalSourceName(signalSourceCode) { - const sourceNames = { +export function getSignalSourceName(signalSourceCode: string): string { + const sourceNames: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', diff --git a/src/tracking/utils/trackQueryLayerUtils.js b/src/tracking/utils/trackQueryLayerUtils.ts similarity index 65% rename from src/tracking/utils/trackQueryLayerUtils.js rename to src/tracking/utils/trackQueryLayerUtils.ts index 0078fe4b..22556cdb 100644 --- a/src/tracking/utils/trackQueryLayerUtils.js +++ b/src/tracking/utils/trackQueryLayerUtils.ts @@ -9,8 +9,8 @@ import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; -import { getShipIconUrl } from './shipIconUtil'; -import { getShipKindColor } from '../types/trackQuery.types'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { getShipKindColor, type RGBAColor } from '../types/trackQuery.types'; import useShipStore from '../../stores/shipStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; import { @@ -19,6 +19,130 @@ import { } from '../../types/constants'; import atlasImg from '../../assets/img/icon/atlas.png'; +// ========== 인터페이스 ========== + +/** 항적 트랙 데이터 */ +interface TrackData { + vesselId: string; + geometry: [number, number][]; + timestampsMs: number[]; + speeds: number[]; + shipKindCode: string; + shipName: string; + sigSrcCd: string; + targetId: string; +} + +/** PathLayer 데이터 항목 */ +interface PathDataItem { + path: [number, number][]; + color: RGBAColor; + width: number; + vesselId: string; + shipKindCode: string; + shipName: string; +} + +/** 포인트 데이터 항목 */ +interface PointDataItem { + position: [number, number]; + color: [number, number, number, number]; + vesselId: string; + timestamp: number; + speed: number; + index: number; +} + +/** 가상 선박 위치 정보 */ +interface VirtualShipPosition { + position: [number, number]; + vesselId: string; + heading: number; + shipName: string; + speed: number; + shipKindCode: string; + targetId: string; +} + +/** 가상 선박 IconLayer 데이터 */ +interface VirtualShipIconData { + position: [number, number]; + icon: string; + size: number; + vesselId: string; + heading: number; + shipName: string; + speed: number; + shipKindCode: string; +} + +/** 가상 선박 글로우 데이터 */ +interface VirtualShipGlowData { + position: [number, number]; + color: [number, number, number, number]; + vesselId: string; +} + +/** 선명 라벨 데이터 */ +interface LabelData { + position: [number, number]; + text: string; + vesselId: string; +} + +/** 연결선 데이터 */ +interface ConnectionData { + path: [number, number][]; + color: [number, number, number, number]; + vesselId: string; +} + +/** 포인트 밀도 설정 */ +interface PointDensityConfig { + gridSizeMultiplier: number; + maxPointsPerCell: number; + minPointRadius: number; + maxPointRadius: number; +} + +/** 경로 레이어 옵션 */ +interface PathLayerOptions { + updateTrigger: number; + highlightedVesselId?: string | null; + loop?: boolean; + loopStart?: number; + loopEnd?: number; +} + +/** 포인트 레이어 옵션 */ +interface PointsLayerOptions { + updateTrigger: number; + zoomLevel?: number; +} + +/** 동적 레이어 옵션 */ +interface DynamicLayerOptions { + showVirtualShip: boolean; + showLabels?: boolean; + updateTrigger: number; +} + +/** 포인트 호버 정보 */ +interface PointHoverInfo { + vesselId: string; + position: [number, number]; + timestamp: number; + speed: number; + index: number; +} + +/** 포맷된 포인트 정보 */ +interface FormattedPointInfo { + time: string; + position: string; + speed: string; +} + /** 현재 테마 색상 가져오기 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -28,7 +152,7 @@ function getCurrentThemeColors() { // ========== 줌 레벨별 포인트 밀도 설정 ========== -const POINT_DENSITY_CONFIGS = { +const POINT_DENSITY_CONFIGS: Record = { 5: { gridSizeMultiplier: 400, maxPointsPerCell: 1, minPointRadius: 2, maxPointRadius: 3 }, 6: { gridSizeMultiplier: 300, maxPointsPerCell: 2, minPointRadius: 2, maxPointRadius: 3 }, 7: { gridSizeMultiplier: 200, maxPointsPerCell: 3, minPointRadius: 2, maxPointRadius: 4 }, @@ -41,7 +165,7 @@ const POINT_DENSITY_CONFIGS = { 14: { gridSizeMultiplier: 20, maxPointsPerCell: Infinity, minPointRadius: 4, maxPointRadius: 8 }, }; -const getPointDensityConfig = (zoomLevel) => { +const getPointDensityConfig = (zoomLevel: number): PointDensityConfig => { const clampedZoom = Math.max(5, Math.min(14, Math.floor(zoomLevel))); return POINT_DENSITY_CONFIGS[clampedZoom] || POINT_DENSITY_CONFIGS[10]; }; @@ -57,12 +181,12 @@ export const LAYER_IDS = { VIRTUAL_SHIP_LABEL: 'track-query-virtual-ship-label-layer', LIVE_CONNECTION: 'track-query-live-connection-layer', TOOLTIP: 'track-query-tooltip-layer', -}; +} as const; // ========== 데이터 생성 함수 ========== /** 항적 라인 PathLayer 데이터 생성 */ -export function createPathLayerData(tracks) { +export function createPathLayerData(tracks: TrackData[]): PathDataItem[] { return tracks.map(track => ({ path: track.geometry, color: getShipKindColor(track.shipKindCode), @@ -74,12 +198,12 @@ export function createPathLayerData(tracks) { } /** 포인트 ScatterplotLayer 데이터 생성 (클러스터링 적용) */ -export function createPointsLayerData(tracks, zoomLevel) { - const points = []; +export function createPointsLayerData(tracks: TrackData[], zoomLevel?: number): PointDataItem[] { + const points: PointDataItem[] = []; tracks.forEach(track => { const color = getShipKindColor(track.shipKindCode); - const pointColor = [ + const pointColor: [number, number, number, number] = [ Math.min(255, color[0] + 30), Math.min(255, color[1] + 30), Math.min(255, color[2] + 30), @@ -106,7 +230,7 @@ export function createPointsLayerData(tracks, zoomLevel) { } /** 항적 포인트 클러스터링 */ -function clusterTrackPoints(points, zoomLevel) { +function clusterTrackPoints(points: PointDataItem[], zoomLevel: number): PointDataItem[] { const config = getPointDensityConfig(zoomLevel); if (config.maxPointsPerCell === Infinity) { @@ -114,7 +238,7 @@ function clusterTrackPoints(points, zoomLevel) { } const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; - const gridCells = new Map(); + const gridCells = new Map(); for (const point of points) { const gridX = Math.floor(point.position[0] / gridSize); @@ -124,10 +248,10 @@ function clusterTrackPoints(points, zoomLevel) { if (!gridCells.has(gridKey)) { gridCells.set(gridKey, []); } - gridCells.get(gridKey).push(point); + gridCells.get(gridKey)!.push(point); } - const result = []; + const result: PointDataItem[] = []; gridCells.forEach(cellPoints => { if (cellPoints.length <= config.maxPointsPerCell) { @@ -146,7 +270,7 @@ function clusterTrackPoints(points, zoomLevel) { } /** 가상 선박 IconLayer 데이터 생성 */ -export function createVirtualShipData(positions) { +export function createVirtualShipData(positions: VirtualShipPosition[]): VirtualShipIconData[] { return positions.map(pos => ({ position: pos.position, icon: ICON_MAPPING_KIND_MOVING[pos.shipKindCode] || 'etcImg', @@ -160,19 +284,19 @@ export function createVirtualShipData(positions) { } /** 가상 선박 글로우 효과 데이터 생성 */ -export function createVirtualShipGlowData(positions) { +export function createVirtualShipGlowData(positions: VirtualShipPosition[]): VirtualShipGlowData[] { return positions.map(pos => { const color = getShipKindColor(pos.shipKindCode); return { position: pos.position, - color: [color[0], color[1], color[2], 120], + color: [color[0], color[1], color[2], 120] as [number, number, number, number], vesselId: pos.vesselId, }; }); } /** 유효한 선명인지 확인 */ -function isValidLabelShipName(name) { +function isValidLabelShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -182,7 +306,7 @@ function isValidLabelShipName(name) { } /** 선명 라벨 데이터 생성 */ -export function createVirtualShipLabelData(positions) { +export function createVirtualShipLabelData(positions: VirtualShipPosition[]): LabelData[] { return positions.map(pos => ({ position: pos.position, text: isValidLabelShipName(pos.shipName) ? pos.shipName : pos.targetId, @@ -195,25 +319,29 @@ export function createVirtualShipLabelData(positions) { /** * 경로 레이어 생성 */ -export function createPathLayers(tracks, options, onPathHover) { - const layers = []; +export function createPathLayers( + tracks: TrackData[], + options: PathLayerOptions, + onPathHover?: ((vesselId: string | null) => void) | null, +): Layer[] { + const layers: Layer[] = []; const { updateTrigger, highlightedVesselId = null } = options; if (tracks.length === 0) return layers; const pathData = createPathLayerData(tracks); - const pathLayer = new PathLayer({ + const pathLayer = new PathLayer({ id: LAYER_IDS.PATH, data: pathData, - getPath: d => d.path, - getColor: d => { + getPath: (d: PathDataItem) => d.path, + getColor: (d: PathDataItem) => { if (highlightedVesselId && highlightedVesselId === d.vesselId) { - return [255, 255, 0, 255]; + return [255, 255, 0, 255] as [number, number, number, number]; } return d.color; }, - getWidth: d => { + getWidth: (d: PathDataItem) => { if (highlightedVesselId && highlightedVesselId === d.vesselId) { return 6; } @@ -224,7 +352,7 @@ export function createPathLayers(tracks, options, onPathHover) { pickable: true, autoHighlight: true, highlightColor: [255, 255, 0, 220], - onHover: info => { + onHover: (info: PickingInfo) => { if (onPathHover) { onPathHover(info.object?.vesselId ?? null); } @@ -242,7 +370,11 @@ export function createPathLayers(tracks, options, onPathHover) { /** * 포인트 레이어 생성 (클러스터링 포함) */ -export function createPointsLayerOnly(tracks, options, onPointHover) { +export function createPointsLayerOnly( + tracks: TrackData[], + options: PointsLayerOptions, + onPointHover?: ((info: PointHoverInfo | null, x: number, y: number) => void) | null, +): ScatterplotLayer | null { const { updateTrigger, zoomLevel } = options; if (tracks.length === 0) return null; @@ -250,16 +382,16 @@ export function createPointsLayerOnly(tracks, options, onPointHover) { const pointsData = createPointsLayerData(tracks, zoomLevel); const pointConfig = zoomLevel ? getPointDensityConfig(zoomLevel) : { minPointRadius: 3, maxPointRadius: 6 }; - return new ScatterplotLayer({ + return new ScatterplotLayer({ id: LAYER_IDS.POINTS, data: pointsData, - getPosition: d => d.position, - getFillColor: d => d.color, + getPosition: (d: PointDataItem) => d.position, + getFillColor: (d: PointDataItem) => d.color, getRadius: 4, radiusMinPixels: pointConfig.minPointRadius, radiusMaxPixels: pointConfig.maxPointRadius, pickable: true, - onHover: info => { + onHover: (info: PickingInfo) => { if (onPointHover) { if (info.object) { const point = info.object; @@ -289,19 +421,24 @@ export function createPointsLayerOnly(tracks, options, onPointHover) { * 동적 레이어 생성 (가상 선박 아이콘, 라벨) * currentTime 변경 시마다 재생성 */ -export function createDynamicTrackLayers(positions, tracks, options, onIconHover) { - const layers = []; +export function createDynamicTrackLayers( + positions: VirtualShipPosition[], + _tracks: TrackData[], + options: DynamicLayerOptions, + onIconHover?: ((data: VirtualShipIconData | null, x: number, y: number) => void) | null, +): Layer[] { + const layers: Layer[] = []; const { showVirtualShip, showLabels = false, updateTrigger } = options; // 1. 가상 선박 글로우 효과 if (showVirtualShip && positions.length > 0) { const glowData = createVirtualShipGlowData(positions); - const glowLayer = new ScatterplotLayer({ + const glowLayer = new ScatterplotLayer({ id: LAYER_IDS.VIRTUAL_SHIP_GLOW, data: glowData, - getPosition: d => d.position, - getFillColor: d => d.color, + getPosition: (d: VirtualShipGlowData) => d.position, + getFillColor: (d: VirtualShipGlowData) => d.color, getRadius: 20, radiusMinPixels: 18, radiusMaxPixels: 28, @@ -313,18 +450,18 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover // 2. IconLayer - 가상 선박 const iconData = createVirtualShipData(positions); - const iconLayer = new IconLayer({ + const iconLayer = new IconLayer({ id: LAYER_IDS.VIRTUAL_SHIP, data: iconData, iconAtlas: atlasImg, iconMapping: ICON_ATLAS_MAPPING, - getIcon: d => d.icon, - getPosition: d => d.position, - getSize: d => d.size, + getIcon: (d: VirtualShipIconData) => d.icon, + getPosition: (d: VirtualShipIconData) => d.position, + getSize: (d: VirtualShipIconData) => d.size, sizeUnits: 'pixels', - getAngle: d => d.heading || 0, + getAngle: (d: VirtualShipIconData) => d.heading || 0, pickable: true, - onHover: info => { + onHover: (info: PickingInfo) => { if (onIconHover) { if (info.object) { onIconHover(info.object, info.x, info.y); @@ -345,11 +482,11 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover const labelData = createVirtualShipLabelData(positions); const themeColors = getCurrentThemeColors(); - const labelLayer = new TextLayer({ + const labelLayer = new TextLayer({ id: LAYER_IDS.VIRTUAL_SHIP_LABEL, data: labelData, - getPosition: d => d.position, - getText: d => d.text, + getPosition: (d: LabelData) => d.position, + getText: (d: LabelData) => d.text, getSize: 12, getColor: themeColors.shipLabel, getAngle: 0, @@ -374,7 +511,7 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover // ========== 포인트 정보 포맷팅 ========== /** 포인트 정보 포맷팅 (호버 툴팁용) */ -export function formatPointInfo(info) { +export function formatPointInfo(info: PointHoverInfo): FormattedPointInfo { const date = new Date(info.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -391,18 +528,15 @@ export function formatPointInfo(info) { }; } -// ========== 라이브 연결선 (항적 끝점 ↔ 라이브 선박) ========== +// ========== 라이브 연결선 (항적 끝점 <-> 라이브 선박) ========== /** * 마지막 항적점과 라이브 선박 사이 연결 데이터 생성 * 항적의 마지막 점과 실시간 선박 위치를 점선으로 연결 - * - * @param {Array} tracks - ProcessedTrack 배열 - * @returns {Array} 연결선 데이터 배열 [{ path, color, vesselId }] */ -export function createLiveConnectionData(tracks) { +export function createLiveConnectionData(tracks: TrackData[]): ConnectionData[] { const { features } = useShipStore.getState(); - const connections = []; + const connections: ConnectionData[] = []; if (tracks.length === 0) return connections; @@ -441,22 +575,20 @@ export function createLiveConnectionData(tracks) { /** * 라이브 선박 연결선 레이어 생성 (점선 스타일) * 항적 마지막 점과 라이브 선박 위치를 점선으로 연결 - * - * @param {Array} tracks - ProcessedTrack 배열 - * @param {number} updateTrigger - 업데이트 트리거 - * @returns {PathLayer|null} 연결선 레이어 */ -export function createLiveConnectionLayer(tracks, updateTrigger) { +export function createLiveConnectionLayer(tracks: TrackData[], updateTrigger: number): PathLayer | null { if (tracks.length === 0) return null; const connectionData = createLiveConnectionData(tracks); if (connectionData.length === 0) return null; - return new PathLayer({ + // getDashArray, dashJustified는 PathStyleExtension이 주입하는 prop이라 PathLayer 기본 타입 정의에 없음 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PathStyleExtension의 dash prop은 런타임에서만 인식됨 + const props: Record = { id: LAYER_IDS.LIVE_CONNECTION, data: connectionData, - getPath: d => d.path, - getColor: d => d.color, + getPath: (d: ConnectionData) => d.path, + getColor: (d: ConnectionData) => d.color, getWidth: 2, widthMinPixels: 2, widthMaxPixels: 3, @@ -467,22 +599,23 @@ export function createLiveConnectionLayer(tracks, updateTrigger) { updateTriggers: { getPath: updateTrigger, }, - }); + }; + return new PathLayer(props as any); } // ========== 전역 레이어 레지스트리 ========== /** 전역 레이어 레지스트리에 레이어 등록 */ -export function registerTrackQueryLayers(layers) { +export function registerTrackQueryLayers(layers: Layer[]): void { window.__trackQueryLayers__ = layers; } /** 전역 레이어 레지스트리에서 레이어 제거 */ -export function unregisterTrackQueryLayers() { +export function unregisterTrackQueryLayers(): void { window.__trackQueryLayers__ = []; } /** 등록된 TrackQuery 레이어 가져오기 */ -export function getTrackQueryLayers() { +export function getTrackQueryLayers(): Layer[] { return window.__trackQueryLayers__ || []; } diff --git a/src/tracking/utils/tracking.utils.js b/src/tracking/utils/tracking.utils.ts similarity index 66% rename from src/tracking/utils/tracking.utils.js rename to src/tracking/utils/tracking.utils.ts index 511a02ca..0e5a5575 100644 --- a/src/tracking/utils/tracking.utils.js +++ b/src/tracking/utils/tracking.utils.ts @@ -7,17 +7,19 @@ * - 날짜시간 포맷팅 */ +import type { LonLat } from '../types/trackQuery.types'; + /** * 두 지점 사이의 선박 위치를 시간 기반으로 보간 * - * @param {[number, number]} p1 시작 지점 좌표 [경도, 위도] - * @param {[number, number]} p2 종료 지점 좌표 [경도, 위도] - * @param {number} t1 시작 시간 (밀리초) - * @param {number} t2 종료 시간 (밀리초) - * @param {number} currentTime 현재 시간 (밀리초) - * @returns {[number, number]} 보간된 위치 [경도, 위도] + * @param p1 시작 지점 좌표 [경도, 위도] + * @param p2 종료 지점 좌표 [경도, 위도] + * @param t1 시작 시간 (밀리초) + * @param t2 종료 시간 (밀리초) + * @param currentTime 현재 시간 (밀리초) + * @returns 보간된 위치 [경도, 위도] */ -export function interpolatePosition(p1, p2, t1, t2, currentTime) { +export function interpolatePosition(p1: LonLat, p2: LonLat, t1: number, t2: number, currentTime: number): LonLat { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -33,11 +35,11 @@ export function interpolatePosition(p1, p2, t1, t2, currentTime) { * 두 지점 간의 방향(heading) 계산 * 북쪽을 0도로 하여 시계방향으로 각도를 계산 * - * @param {[number, number]} p1 시작 지점 [경도, 위도] - * @param {[number, number]} p2 종료 지점 [경도, 위도] - * @returns {number} 방향 각도 (도 단위) + * @param p1 시작 지점 [경도, 위도] + * @param p2 종료 지점 [경도, 위도] + * @returns 방향 각도 (도 단위) */ -export function calculateHeading(p1, p2) { +export function calculateHeading(p1: LonLat, p2: LonLat): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; @@ -54,10 +56,10 @@ export function calculateHeading(p1, p2) { /** * 날짜시간 포맷팅 (밀리초 -> "YYYY-MM-DD HH:mm:ss") * - * @param {number} timestamp 타임스탬프 (밀리초) - * @returns {string} 포맷된 날짜시간 문자열 + * @param timestamp 타임스탬프 (밀리초) + * @returns 포맷된 날짜시간 문자열 */ -export function formatDateTime(timestamp) { +export function formatDateTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -71,10 +73,10 @@ export function formatDateTime(timestamp) { /** * 시간 차이를 사람이 읽기 쉬운 형태로 변환 * - * @param {number} milliseconds 밀리초 - * @returns {string} 포맷된 시간 문자열 (예: "2시간 30분") + * @param milliseconds 밀리초 + * @returns 포맷된 시간 문자열 (예: "2시간 30분") */ -export function formatDuration(milliseconds) { +export function formatDuration(milliseconds: number): string { const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 00000000..a2601b34 --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,25 @@ +/* 정적 에셋 모듈 타입 선언 */ +declare module '*.svg' { + const src: string; + export default src; +} +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.jpg' { + const src: string; + export default src; +} +declare module '*.gif' { + const src: string; + export default src; +} +declare module '*.scss' { + const classes: Record; + export default classes; +} +declare module '*.css' { + const classes: Record; + export default classes; +} diff --git a/src/types/constants.js b/src/types/constants.ts similarity index 85% rename from src/types/constants.js rename to src/types/constants.ts index 73af0141..82557916 100644 --- a/src/types/constants.js +++ b/src/types/constants.ts @@ -14,31 +14,40 @@ export const SIGNAL_SOURCE_CODE_VTS_AIS = '000004'; export const SIGNAL_SOURCE_CODE_RADAR = '000005'; export const SIGNAL_SOURCE_CODE_D_MF_HF = '000016'; +/** 신호원 코드 유니온 타입 */ +export type SignalSourceCode = + | typeof SIGNAL_SOURCE_CODE_AIS + | typeof SIGNAL_SOURCE_CODE_ENAV + | typeof SIGNAL_SOURCE_CODE_VPASS + | typeof SIGNAL_SOURCE_CODE_VTS_AIS + | typeof SIGNAL_SOURCE_CODE_RADAR + | typeof SIGNAL_SOURCE_CODE_D_MF_HF; + // ===================== // 신호원 우선순위 (동적 대표 선정용, 숫자가 작을수록 높은 우선순위) // 참조: mda-react-front/docs/dynamic-priority.md §1 // ===================== -export const SOURCE_PRIORITY_RANK = { +export const SOURCE_PRIORITY_RANK: Record = { '000005': 0, // VTS-Radar '000004': 1, // VTS-AIS '000001': 2, // AIS '000003': 3, // V-Pass '000002': 4, // E-Nav '000016': 5, // D-MF/HF -}; +} as const; // ===================== // 신호원 코드 → ship 객체의 is_active 프로퍼티 키 매핑 // 참조: mda-react-front/docs/dynamic-priority.md §3 // ===================== -export const SOURCE_TO_ACTIVE_KEY = { +export const SOURCE_TO_ACTIVE_KEY: Record = { '000001': 'ais', '000003': 'vpass', '000002': 'enav', '000004': 'vtsAis', '000016': 'dMfHf', '000005': 'vtsRadar', -}; +} as const; // ===================== // 선박 종류 코드 (Ship Kind Code) @@ -52,6 +61,17 @@ export const SIGNAL_KIND_CODE_GOV = '000025'; // 관공선 export const SIGNAL_KIND_CODE_NORMAL = '000027'; // 일반 export const SIGNAL_KIND_CODE_BUOY = '000028'; // 부이 +/** 선박 종류 코드 유니온 타입 */ +export type SignalKindCode = + | typeof SIGNAL_KIND_CODE_FISHING + | typeof SIGNAL_KIND_CODE_KCGV + | typeof SIGNAL_KIND_CODE_PASSENGER + | typeof SIGNAL_KIND_CODE_CARGO + | typeof SIGNAL_KIND_CODE_TANKER + | typeof SIGNAL_KIND_CODE_GOV + | typeof SIGNAL_KIND_CODE_NORMAL + | typeof SIGNAL_KIND_CODE_BUOY; + // ===================== // STOMP 메시지 배열 인덱스 // 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget() @@ -95,12 +115,12 @@ export const SHIP_MSG_INDEX = { NATIONAL_CODE: 35, // 국적 코드 IS_PRIORITY: 36, // 1=priority (통합선박에서 대표 신호원) ORIGINAL_TARGET_ID: 37, // 개별 장비 고유 TARGET_ID -}; +} as const; // ===================== // 선박 종류별 한글 라벨 // ===================== -export const SHIP_KIND_LABELS = { +export const SHIP_KIND_LABELS: Record = { [SIGNAL_KIND_CODE_FISHING]: '어선', [SIGNAL_KIND_CODE_KCGV]: '경비함정', [SIGNAL_KIND_CODE_PASSENGER]: '여객선', @@ -114,7 +134,7 @@ export const SHIP_KIND_LABELS = { // ===================== // 신호원별 한글 라벨 // ===================== -export const SIGNAL_SOURCE_LABELS = { +export const SIGNAL_SOURCE_LABELS: Record = { [SIGNAL_SOURCE_CODE_AIS]: 'AIS', [SIGNAL_SOURCE_CODE_ENAV]: 'e-Nav', [SIGNAL_SOURCE_CODE_VPASS]: 'V-PASS', @@ -128,7 +148,18 @@ export const SIGNAL_SOURCE_LABELS = { // A=AIS, V=VPASS, E=ENAV, T=VTS_AIS, D=D_MF_HF, R=RADAR // 참조: mda-react-front/src/util/realTimeLayerUtil.ts (라인 733-776) // ===================== -export const SIGNAL_FLAG_CONFIGS = [ + +/** AVETDR 플래그 설정 아이템 인터페이스 */ +export interface SignalFlagConfig { + key: string; + name: string; + activeColor: string; + inactiveColor: string; + signalSourceCode: string; + dataKey: string; +} + +export const SIGNAL_FLAG_CONFIGS: readonly SignalFlagConfig[] = [ { key: 'A', name: 'AIS', @@ -177,10 +208,10 @@ export const SIGNAL_FLAG_CONFIGS = [ signalSourceCode: SIGNAL_SOURCE_CODE_RADAR, dataKey: 'vtsRadar', }, -]; +] as const; // 레거시 호환 -export const AVETDR_COLORS = { +export const AVETDR_COLORS: Record = { A: { active: '#C2A7DC', inactive: '#444' }, V: { active: '#8FAEFC', inactive: '#444' }, E: { active: '#74B2F0', inactive: '#444' }, @@ -192,7 +223,7 @@ export const AVETDR_COLORS = { // ===================== // 선박 종류별 색상 (범례용) // ===================== -export const SHIP_KIND_COLORS = { +export const SHIP_KIND_COLORS: Record = { [SIGNAL_KIND_CODE_FISHING]: '#00C853', // 녹색 - 어선 [SIGNAL_KIND_CODE_KCGV]: '#FF5722', // 주황 - 경비함정 [SIGNAL_KIND_CODE_PASSENGER]: '#2196F3', // 파랑 - 여객선 @@ -212,7 +243,16 @@ export const SPEED_THRESHOLD = 1; // knots (메인 프로젝트 기준) // 아이콘 아틀라스 매핑 (atlas.png 스프라이트 시트) // 참조: mda-react-front/src/types/constants.ts // ===================== -export const ICON_ATLAS_MAPPING = { + +/** 아이콘 아틀라스 매핑 아이템 인터페이스 */ +export interface IconAtlasEntry { + x: number; + y: number; + width: number; + height: number; +} + +export const ICON_ATLAS_MAPPING: Record = { // 이동 중인 선박 아이콘 (화살표 형태) fishingImg: { x: 1, y: 518, width: 16, height: 27 }, kcgvImg: { x: 45, y: 115, width: 17, height: 27 }, @@ -255,7 +295,7 @@ export const ICON_ATLAS_MAPPING = { // ===================== // 선종별 이동 아이콘 매핑 // ===================== -export const ICON_MAPPING_KIND_MOVING = { +export const ICON_MAPPING_KIND_MOVING: Record = { [SIGNAL_KIND_CODE_FISHING]: 'fishingImg', [SIGNAL_KIND_CODE_KCGV]: 'kcgvImg', [SIGNAL_KIND_CODE_PASSENGER]: 'passImg', @@ -269,7 +309,7 @@ export const ICON_MAPPING_KIND_MOVING = { // ===================== // 선종별 정지 아이콘 매핑 // ===================== -export const ICON_MAPPING_KIND_STOPPING = { +export const ICON_MAPPING_KIND_STOPPING: Record = { [SIGNAL_KIND_CODE_FISHING]: 'fishingStopImg', [SIGNAL_KIND_CODE_KCGV]: 'kcgvStopImg', [SIGNAL_KIND_CODE_PASSENGER]: 'passStopImg', @@ -297,12 +337,19 @@ export const STOMP_TOPICS = { SHIP_THROTTLED: '/topic/ship-throttled-', // + {N}s COUNT: '/topic/count', SHIP_DELETE: '/topic/ship-delete', -}; +} as const; // ===================== // 기본 선박 종류 목록 (필터/범례용) // ===================== -export const SHIP_KIND_LIST = [ + +/** 선박 종류 목록 아이템 인터페이스 */ +export interface ShipKindItem { + code: string; + label: string; +} + +export const SHIP_KIND_LIST: readonly ShipKindItem[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_KCGV, label: '경비함정' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, @@ -311,19 +358,26 @@ export const SHIP_KIND_LIST = [ { code: SIGNAL_KIND_CODE_GOV, label: '관공선' }, { code: SIGNAL_KIND_CODE_NORMAL, label: '일반' }, { code: SIGNAL_KIND_CODE_BUOY, label: '부이' }, -]; +] as const; // ===================== // 신호원 목록 (필터용) // ===================== -export const SIGNAL_SOURCE_LIST = [ + +/** 신호원 목록 아이템 인터페이스 */ +export interface SignalSourceItem { + code: string; + label: string; +} + +export const SIGNAL_SOURCE_LIST: readonly SignalSourceItem[] = [ { code: SIGNAL_SOURCE_CODE_AIS, label: 'AIS' }, { code: SIGNAL_SOURCE_CODE_VPASS, label: 'V-PASS' }, { code: SIGNAL_SOURCE_CODE_ENAV, label: 'e-Nav' }, { code: SIGNAL_SOURCE_CODE_VTS_AIS, label: 'VTS AIS' }, { code: SIGNAL_SOURCE_CODE_D_MF_HF, label: 'D MF/HF' }, { code: SIGNAL_SOURCE_CODE_RADAR, label: 'RADAR' }, -]; +] as const; // ===================== // 항적/리플레이 조회기간 설정 @@ -376,4 +430,4 @@ export const USER_SETTING_CODES = { NORTH_KOREA_AI: '000077', // 북한선박 // 위험물 HAZARD: '000027', -}; +} as const; diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 00000000..4d5c27bf --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,19 @@ +/** + * Window 인터페이스 확장 - 레이어 레지스트리 전역 프로퍼티 + * + * 각 레이어 레지스트리 모듈(replayLayerRegistry, areaSearchLayerRegistry, + * stsLayerRegistry, trackQueryLayerUtils)이 window 객체에 레이어 배열을 저장 + */ + +import type { Layer } from '@deck.gl/core'; + +declare global { + interface Window { + __replayLayers__?: Layer[]; + __areaSearchLayers__?: Layer[]; + __stsLayers__?: Layer[]; + __trackQueryLayers__?: Layer[]; + } +} + +export {}; diff --git a/src/types/ol-ext.d.ts b/src/types/ol-ext.d.ts new file mode 100644 index 00000000..b215f96f --- /dev/null +++ b/src/types/ol-ext.d.ts @@ -0,0 +1,2 @@ +/* ol-ext 타입 선언 (공식 타입 미제공) */ +declare module 'ol-ext/*'; diff --git a/src/types/ship.ts b/src/types/ship.ts new file mode 100644 index 00000000..692cc551 --- /dev/null +++ b/src/types/ship.ts @@ -0,0 +1,81 @@ +/** 선박 feature 객체 (shipStore에서 관리하는 단위) */ +export interface ShipFeature { + /** 고유 식별자 (signalSourceCode + originalTargetId) */ + featureId: string; + /** 통합 TARGET_ID (AIS_VPASS_ENAV_VTSAIS_DMFHF 형식) */ + targetId: string; + /** 개별 장비 고유 TARGET_ID */ + originalTargetId: string; + /** 신호원 코드 (000001=AIS, 000002=ENAV, ...) */ + signalSourceCode: string; + /** 선종 코드 (000020=어선, 000021=경비함정, ...) */ + signalKindCode: string; + /** 경도 */ + longitude: number; + /** 위도 */ + latitude: number; + /** 속력 (knots) */ + sog: number; + /** 침로 (degrees) */ + cog: number; + /** 선박명 */ + shipName: string; + /** 국적 코드 */ + nationalCode: string; + /** AIS 통합 플래그 */ + ais: string; + /** VPASS 통합 플래그 */ + vpass: string; + /** ENAV 통합 플래그 */ + enav: string; + /** VTS_AIS 통합 플래그 */ + vtsAis: string; + /** D_MF_HF 통합 플래그 */ + dMfHf: string; + /** VTS_RADAR 통합 플래그 */ + vtsRadar: string; + /** 통합 여부 */ + integrate: boolean; + /** 통합선박에서 대표 신호원 여부 */ + isPriority: boolean; + /** 수신 시간 (YYYYMMDDHHmmss 형식) */ + receivedTime: string; + /** 수신 타임스탬프 (ms) - mergeFeatures에서 계산 */ + receivedTimestamp?: number; + /** 소실 신호 여부 */ + lost?: boolean; + /** 위험물 카테고리 */ + hazardousCategory?: string; + /** IMO 번호 */ + imo?: string; + /** 흘수 */ + draught?: string; + /** 선박 크기 A */ + dimA?: string; + /** 선박 크기 B */ + dimB?: string; + /** 선박 크기 C */ + dimC?: string; + /** 선박 크기 D */ + dimD?: string; + /** 선박 타입 */ + shipType?: string; + /** 콜사인 */ + callsign?: string; + /** 선수 방위 */ + heading?: number; + /** 목적지 */ + destination?: string; + /** AIS 상태 */ + status?: string; + /** 선체 길이 */ + length?: number; + /** 선체 폭 */ + width?: number; + /** 원시 데이터 */ + _raw?: unknown; + /** CSV 다운로드용 targetId */ + downloadTargetId?: string; + /** 동적 키 접근 허용 */ + [key: string]: unknown; +} diff --git a/src/utils/assetPath.js b/src/utils/assetPath.ts similarity index 91% rename from src/utils/assetPath.js rename to src/utils/assetPath.ts index 7b286ff7..91d21587 100644 --- a/src/utils/assetPath.js +++ b/src/utils/assetPath.ts @@ -17,7 +17,7 @@ * @param {string} path - '/'로 시작하는 에셋 경로 (예: '/images/icon.svg') * @returns {string} base URL이 적용된 전체 경로 */ -export function assetPath(path) { +export function assetPath(path: string): string { // import.meta.env.BASE_URL은 항상 '/'로 끝남 (예: '/', '/kcgv/') const base = import.meta.env.BASE_URL; @@ -32,7 +32,7 @@ export function assetPath(path) { * @param {string} filename - 이미지 파일명 (예: 'icon.svg', 'photo.png') * @returns {string} base URL이 적용된 전체 이미지 경로 */ -export function imagePath(filename) { +export function imagePath(filename: string): string { return assetPath(`/images/${filename}`); } diff --git a/src/utils/csvDownload.js b/src/utils/csvDownload.ts similarity index 68% rename from src/utils/csvDownload.js rename to src/utils/csvDownload.ts index 69f6acf8..7f6e1af0 100644 --- a/src/utils/csvDownload.js +++ b/src/utils/csvDownload.ts @@ -6,17 +6,44 @@ import { Polygon } from 'ol/geom'; import { shipTypeMap } from '../assets/data/shiptype'; import { SHIP_KIND_LABELS, SIGNAL_SOURCE_LABELS } from '../types/constants'; +/** 다운로드용 선박 데이터 */ +interface DownloadShip { + downloadTargetId: string; + shipName: string; + signalKindCode: string; + shipType: string; + signalSourceCode: string; + sog: number; + cog: number; + longitude: string | number; + latitude: string | number; + draught: string; + receivedTime: string; +} + +/** 해구도 캐시 엔트리 */ +interface TrenchEntry { + zoneName: string; + polygon: Polygon; +} + +/** GeoJSON Feature 형태 (largeTrench.json) */ +interface TrenchFeature { + properties: { zone_name: string }; + geometry: { coordinates: number[][][] }; +} + // 해구도 데이터 캐시 (첫 호출 시만 로딩) -let trenchCache = null; +let trenchCache: TrenchEntry[] | null = null; /** * 해구도 폴리곤 데이터 로딩 (동적 import, 캐시) */ -async function loadTrenchData() { +async function loadTrenchData(): Promise { if (trenchCache) return trenchCache; const data = await import('../assets/data/largeTrench.json'); const geojson = data.default || data; - trenchCache = geojson.features.map((f) => ({ + trenchCache = (geojson as { features: TrenchFeature[] }).features.map((f: TrenchFeature) => ({ zoneName: f.properties.zone_name, polygon: new Polygon(f.geometry.coordinates), })); @@ -28,13 +55,13 @@ async function loadTrenchData() { * @param {Array} ships - 선박 배열 (longitude, latitude 필드 필요) * @returns {Map} index → zone_name 매핑 */ -async function lookupTrenchNumbers(ships) { +async function lookupTrenchNumbers(ships: DownloadShip[]): Promise> { const trenchData = await loadTrenchData(); - const result = new Map(); + const result = new Map(); ships.forEach((ship, idx) => { - const lon = parseFloat(ship.longitude); - const lat = parseFloat(ship.latitude); + const lon = parseFloat(String(ship.longitude)); + const lat = parseFloat(String(ship.latitude)); if (isNaN(lon) || isNaN(lat)) { result.set(idx, 'X'); return; @@ -60,7 +87,7 @@ async function lookupTrenchNumbers(ships) { * 수신시간 포맷 변환 * "YYYYMMDDHHmmss" → "YYYY-MM-DD HH:mm:ss" */ -function formatRecvDateTime(raw) { +function formatRecvDateTime(raw: string | undefined | null): string { if (!raw || raw.length < 14) return raw || ''; return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)} ${raw.slice(8, 10)}:${raw.slice(10, 12)}:${raw.slice(12, 14)}`; } @@ -68,7 +95,7 @@ function formatRecvDateTime(raw) { /** * CSV 안전 필드 (쌍따옴표 감싸기) */ -function csvField(val) { +function csvField(val: string | number | null | undefined): string { const str = val == null ? '' : String(val); return `"${str.replace(/"/g, '""')}"`; } @@ -79,7 +106,7 @@ function csvField(val) { * @param {Map} trenchMap - index → zone_name 매핑 * @returns {string} CSV 문자열 (BOM 포함) */ -function buildCsvString(ships, trenchMap) { +function buildCsvString(ships: DownloadShip[], trenchMap: Map): string { const BOM = '\uFEFF'; const headers = [ '타겟ID', '선박명', '선종/기종', '선종/기종-유형', '신호', @@ -90,9 +117,9 @@ function buildCsvString(ships, trenchMap) { const fields = [ csvField(ship.downloadTargetId), csvField(ship.shipName), - csvField(SHIP_KIND_LABELS[ship.signalKindCode] || ship.signalKindCode), + csvField((SHIP_KIND_LABELS as Record)[ship.signalKindCode] || ship.signalKindCode), csvField(shipTypeMap.get(String(ship.shipType)) || ship.shipType), - csvField(SIGNAL_SOURCE_LABELS[ship.signalSourceCode] || ship.signalSourceCode), + csvField((SIGNAL_SOURCE_LABELS as Record)[ship.signalSourceCode] || ship.signalSourceCode), csvField(ship.sog), csvField(ship.cog), csvField(ship.longitude), @@ -111,7 +138,7 @@ function buildCsvString(ships, trenchMap) { * CSV 다운로드 트리거 * @param {Array} ships - getDownloadShips()에서 반환된 선박 배열 */ -export async function downloadShipCsv(ships) { +export async function downloadShipCsv(ships: DownloadShip[]): Promise { const trenchMap = await lookupTrenchNumbers(ships); const csvString = buildCsvString(ships, trenchMap); @@ -119,7 +146,7 @@ export async function downloadShipCsv(ships) { const url = URL.createObjectURL(blob); const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); const fileName = `ship_download_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`; const link = document.createElement('a'); diff --git a/src/utils/liveControl.js b/src/utils/liveControl.ts similarity index 86% rename from src/utils/liveControl.js rename to src/utils/liveControl.ts index 477dcc1a..0ebf37d3 100644 --- a/src/utils/liveControl.js +++ b/src/utils/liveControl.ts @@ -10,7 +10,7 @@ import { shipBatchRenderer } from '../map/ShipBatchRenderer'; /** * 라이브 선박 숨기기 */ -export function hideLiveShips() { +export function hideLiveShips(): void { useTrackQueryStore.getState().setHideLiveShips(true); shipBatchRenderer.immediateRender(); } @@ -18,7 +18,7 @@ export function hideLiveShips() { /** * 라이브 선박 표시 */ -export function showLiveShips() { +export function showLiveShips(): void { useTrackQueryStore.getState().setHideLiveShips(false); shipBatchRenderer.immediateRender(); } @@ -27,7 +27,7 @@ export function showLiveShips() { * 라이브 선박 표시 토글 * @returns {boolean} 토글 후 hideLiveShips 상태 */ -export function toggleLiveShips() { +export function toggleLiveShips(): boolean { const currentState = useTrackQueryStore.getState().hideLiveShips; const newState = !currentState; useTrackQueryStore.getState().setHideLiveShips(newState); @@ -39,6 +39,6 @@ export function toggleLiveShips() { * 라이브 선박 숨김 상태 확인 * @returns {boolean} true면 라이브 선박 숨김 상태 */ -export function isLiveShipsHidden() { +export function isLiveShipsHidden(): boolean { return useTrackQueryStore.getState().hideLiveShips; } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..1d326854 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; + readonly VITE_API_URL: string; + readonly VITE_SNP_API_TARGET: string; + readonly VITE_DEV_SKIP_AUTH: string; + readonly VITE_SHIP_THROTTLE: string; + readonly VITE_MAIN_APP_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/workers/signalWorker.js b/src/workers/signalWorker.ts similarity index 73% rename from src/workers/signalWorker.js rename to src/workers/signalWorker.ts index 2c710cc3..063513ca 100644 --- a/src/workers/signalWorker.js +++ b/src/workers/signalWorker.ts @@ -35,14 +35,49 @@ const IDX = { NATIONAL_CODE: 35, IS_PRIORITY: 36, ORIGINAL_TARGET_ID: 37, -}; +} as const; + +/** Worker에서 생성하는 선박 객체 */ +interface WorkerShipObject { + featureId: string; + receivedTimestamp: number; + targetId: string; + originalTargetId: string; + signalSourceCode: string; + shipName: string; + shipType: string; + longitude: number; + latitude: number; + sog: number; + cog: number; + receivedTime: string; + signalKindCode: string; + lost: boolean; + integrate: boolean; + isPriority: boolean; + hazardousCategory: string; + nationalCode: string; + imo: string; + draught: string; + dimA: string; + dimB: string; + dimC: string; + dimD: string; + ais: string | undefined; + vpass: string | undefined; + enav: string | undefined; + vtsAis: string | undefined; + dMfHf: string | undefined; + vtsRadar: string | undefined; + _raw: string[]; +} /** * 파이프 구분 문자열을 선박 객체로 변환 * @param {string[]} row - 파싱된 배열 - * @returns {Object} 선박 데이터 객체 + * @returns {WorkerShipObject} 선박 데이터 객체 */ -function rowToShipObject(row) { +function rowToShipObject(row: string[]): WorkerShipObject { const targetId = row[IDX.TARGET_ID] || ''; const originalTargetId = row[IDX.ORIGINAL_TARGET_ID] || ''; const signalSourceCode = row[IDX.SIGNAL_SOURCE_CODE] || ''; @@ -120,19 +155,22 @@ function rowToShipObject(row) { // Worker 초기화 로그 console.log('[SignalWorker] Initialized'); -self.onmessage = (e) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Worker global scope 타입 캐스팅 +const workerSelf = self as any; + +workerSelf.onmessage = (e: MessageEvent): void => { const rawMessages = e.data; - const ships = []; + const ships: WorkerShipObject[] = []; for (let i = 0; i < rawMessages.length; i++) { try { const row = rawMessages[i].split('|'); const ship = rowToShipObject(row); ships.push(ship); - } catch (err) { + } catch (_err) { // 파싱 에러는 무시하고 계속 진행 } } - self.postMessage(ships); + workerSelf.postMessage(ships); }; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 00000000..9c40c3c5 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting — strict mode */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 00000000..1e67612a --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.js b/vite.config.ts similarity index 53% rename from vite.config.js rename to vite.config.ts index a8445fef..958884b4 100644 --- a/vite.config.js +++ b/vite.config.ts @@ -1,32 +1,17 @@ -import { defineConfig, loadEnv } from 'vite'; +import { defineConfig, loadEnv, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; -import path from 'path'; -export default ({ mode, command }) => { +export default ({ mode, command }: { mode: string; command: string }) => { const env = loadEnv(mode, process.cwd(), ''); - // 환경 판별 - // - development: 로컬 개발 환경 (yarn dev) - // - dev: 개발서버 배포 환경 (yarn build:dev) - // - qa: QA 환경 (yarn build:qa) - // - production: 프로덕션 환경 (yarn build, yarn build:prod) const isLocalDev = mode === 'development'; - const isDev = mode === 'dev'; - const isQA = mode === 'qa'; - const isProd = !isLocalDev && !isDev && !isQA; // production 또는 기타 모드 const isBuild = command === 'build'; - // 배포 경로 설정 (예: '/kcgnv/', '/' 등) - // 모든 모드에서 VITE_BASE_URL 사용 (로컬 개발 프록시 모드 지원) const base = env.VITE_BASE_URL || '/'; console.log(`[Vite] Mode: ${mode}, Command: ${command}, Base: ${base}, isLocalDev: ${isLocalDev}`); - // 빌드 시 제외할 폴더 패턴 (로컬 개발 모드 제외) - // - publish: 퍼블리싱 미리보기 (개발용) - // - component/wrap: 레거시 퍼블리시 컴포넌트 - // 참고: tracking 폴더의 TS 파일은 JS 파일이 우선 사용되므로 자동 제외됨 - const excludePatterns = isBuild && !isLocalDev + const excludePatterns: RegExp[] = isBuild && !isLocalDev ? [ /[/\\]publish[/\\]/, /[/\\]component[/\\]wrap[/\\]/, @@ -36,13 +21,12 @@ export default ({ mode, command }) => { return defineConfig({ base, define: { - global: 'globalThis', // sockjs-client/stompjs용 global polyfill + global: 'globalThis', }, server: { host: true, port: 3000, proxy: { - // SNP-Batch AIS API (선박 위치 데이터) '/snp-api': { target: env.VITE_SNP_API_TARGET || 'http://211.208.115.83:8041', changeOrigin: true, @@ -52,11 +36,9 @@ export default ({ mode, command }) => { }, plugins: [ react(), - // 빌드 시 개발용 폴더 제외 플러그인 (로컬 개발 모드 제외) - isBuild && !isLocalDev && { + isBuild && !isLocalDev && ({ name: 'exclude-dev-folders', - resolveId(source, importer) { - // 제외 패턴에 매칭되는 import를 빈 모듈로 대체 + resolveId(source: string) { const normalizedSource = source.replace(/\\/g, '/'); for (const pattern of excludePatterns) { if (pattern.test(normalizedSource)) { @@ -65,21 +47,19 @@ export default ({ mode, command }) => { } return null; }, - load(id) { + load(id: string) { if (id === 'virtual:empty-module') { return 'export default null;'; } return null; }, - }, + } satisfies Plugin), ].filter(Boolean), resolve: { alias: { '@': '/src', }, }, - // 빌드 시 console.log, debugger 제거 (로컬 개발 모드 제외) - // 개발서버(dev), QA(qa), 프로덕션(prod) 빌드에서만 적용 esbuild: isBuild && !isLocalDev ? { drop: ['console', 'debugger'], diff --git a/yarn.lock b/yarn.lock index f3508fef..a67b7c2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -354,14 +354,14 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -779,12 +779,12 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@^1.2.8": +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== @@ -1148,6 +1148,11 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== +"@types/json-schema@^7.0.12": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/node@*": version "25.2.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.0.tgz#015b7d228470c1dcbfc17fe9c63039d216b4d782" @@ -1155,6 +1160,13 @@ dependencies: undici-types "~7.16.0" +"@types/node@^22.10.5": + version "22.19.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.11.tgz#7e1feaad24e4e36c52fa5558d5864bb4b272603e" + integrity sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w== + dependencies: + undici-types "~6.21.0" + "@types/offscreencanvas@^2019.6.4": version "2019.7.3" resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz#90267db13f64d6e9ccb5ae3eac92786a7c77a516" @@ -1165,11 +1177,120 @@ resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.7.tgz#aa0e4af9855d81153a29ff84cc44cce25298eda9" integrity sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A== +"@types/prop-types@*": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + "@types/rbush@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-4.0.0.tgz#b327bf54952e9c924ea6702c36904c2ce1d47f35" integrity sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ== +"@types/react-dom@^18.3.5": + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + +"@types/react@^18.3.18": + version "18.3.28" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781" + integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw== + dependencies: + "@types/prop-types" "*" + csstype "^3.2.2" + +"@types/semver@^7.5.0": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== + +"@typescript-eslint/eslint-plugin@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3" + integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA== + dependencies: + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/type-utils" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/parser@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" + integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== + dependencies: + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" + integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + +"@typescript-eslint/type-utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e" + integrity sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag== + dependencies: + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + debug "^4.3.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/types@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" + integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== + +"@typescript-eslint/typescript-estree@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" + integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134" + integrity sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + semver "^7.5.4" + +"@typescript-eslint/visitor-keys@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" + integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== + dependencies: + "@typescript-eslint/types" "6.21.0" + eslint-visitor-keys "^3.4.1" + "@ungap/structured-clone@^1.2.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" @@ -1268,6 +1389,11 @@ array-includes@^3.1.6, array-includes@^3.1.8: is-string "^1.1.1" math-intrinsics "^1.1.0" +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + array.prototype.findlast@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" @@ -1378,6 +1504,20 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + brotli@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" @@ -1545,6 +1685,11 @@ css-line-break@^2.1.0: dependencies: utrie "^1.0.2" +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -1584,7 +1729,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2: +debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -1631,6 +1776,13 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -1972,6 +2124,17 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-glob@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -2020,6 +2183,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -2189,6 +2359,13 @@ gl-matrix@^3.0.0, gl-matrix@^3.4.3: resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003" integrity sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ== +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" @@ -2223,6 +2400,18 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -2299,7 +2488,7 @@ ieee754@^1.1.12: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^5.2.0: +ignore@^5.2.0, ignore@^5.2.4: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== @@ -2451,7 +2640,7 @@ is-generator-function@^1.0.10: has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" -is-glob@^4.0.0, is-glob@^4.0.3: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -2476,6 +2665,11 @@ is-number-object@^1.1.1: call-bound "^1.0.3" has-tostringtag "^1.0.2" +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + is-path-inside@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" @@ -2723,6 +2917,19 @@ md5@^2.3.0: crypt "0.0.2" is-buffer "~1.1.6" +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" @@ -2735,6 +2942,13 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" +minimatch@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -2949,6 +3163,11 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + pbf@3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a" @@ -2977,6 +3196,11 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + picomatch@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" @@ -3298,6 +3522,11 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.5.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -3386,6 +3615,11 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + slice-source@0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/slice-source/-/slice-source-0.4.1.tgz#40a57ac03c6668b5da200e05378e000bf2a61d79" @@ -3540,6 +3774,18 @@ texture-compressor@^1.0.2: argparse "^1.0.10" image-size "^0.7.4" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-api-utils@^1.0.1: + version "1.4.3" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" + integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -3597,6 +3843,11 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" +typescript@~5.7.2: + version "5.7.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" + integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== + unbox-primitive@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" @@ -3607,6 +3858,11 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"