refactor: OpenLayers → MapLibre GL JS 완전 전환 (Phase 3 Step 2) #1
27
.eslintrc.cjs
Normal file
27
.eslintrc.cjs
Normal file
@ -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' },
|
||||
},
|
||||
};
|
||||
@ -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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -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은 추적
|
||||
|
||||
BIN
.yarn-offline-cache/@types-json-schema-7.0.15.tgz
Normal file
BIN
.yarn-offline-cache/@types-json-schema-7.0.15.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-node-22.19.11.tgz
Normal file
BIN
.yarn-offline-cache/@types-node-22.19.11.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-prop-types-15.7.15.tgz
Normal file
BIN
.yarn-offline-cache/@types-prop-types-15.7.15.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-react-18.3.28.tgz
Normal file
BIN
.yarn-offline-cache/@types-react-18.3.28.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-react-dom-18.3.7.tgz
Normal file
BIN
.yarn-offline-cache/@types-react-dom-18.3.7.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@types-semver-7.7.1.tgz
Normal file
BIN
.yarn-offline-cache/@types-semver-7.7.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-eslint-plugin-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-eslint-plugin-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-parser-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-parser-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-scope-manager-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-scope-manager-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-type-utils-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-type-utils-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-types-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-types-6.21.0.tgz
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-utils-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-utils-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/@typescript-eslint-visitor-keys-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/@typescript-eslint-visitor-keys-6.21.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/array-union-2.1.0.tgz
Normal file
BIN
.yarn-offline-cache/array-union-2.1.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/brace-expansion-2.0.2.tgz
Normal file
BIN
.yarn-offline-cache/brace-expansion-2.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/braces-3.0.3.tgz
Normal file
BIN
.yarn-offline-cache/braces-3.0.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/csstype-3.2.3.tgz
Normal file
BIN
.yarn-offline-cache/csstype-3.2.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/dir-glob-3.0.1.tgz
Normal file
BIN
.yarn-offline-cache/dir-glob-3.0.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/fast-glob-3.3.3.tgz
Normal file
BIN
.yarn-offline-cache/fast-glob-3.3.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/fill-range-7.1.1.tgz
Normal file
BIN
.yarn-offline-cache/fill-range-7.1.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/glob-parent-5.1.2.tgz
Normal file
BIN
.yarn-offline-cache/glob-parent-5.1.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/globby-11.1.0.tgz
Normal file
BIN
.yarn-offline-cache/globby-11.1.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/is-number-7.0.0.tgz
Normal file
BIN
.yarn-offline-cache/is-number-7.0.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/merge2-1.4.1.tgz
Normal file
BIN
.yarn-offline-cache/merge2-1.4.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/micromatch-4.0.8.tgz
Normal file
BIN
.yarn-offline-cache/micromatch-4.0.8.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/minimatch-9.0.3.tgz
Normal file
BIN
.yarn-offline-cache/minimatch-9.0.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/path-type-4.0.0.tgz
Normal file
BIN
.yarn-offline-cache/path-type-4.0.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/picomatch-2.3.1.tgz
Normal file
BIN
.yarn-offline-cache/picomatch-2.3.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/semver-7.7.4.tgz
Normal file
BIN
.yarn-offline-cache/semver-7.7.4.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/slash-3.0.0.tgz
Normal file
BIN
.yarn-offline-cache/slash-3.0.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/to-regex-range-5.0.1.tgz
Normal file
BIN
.yarn-offline-cache/to-regex-range-5.0.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/ts-api-utils-1.4.3.tgz
Normal file
BIN
.yarn-offline-cache/ts-api-utils-1.4.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/typescript-5.7.3.tgz
Normal file
BIN
.yarn-offline-cache/typescript-5.7.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/undici-types-6.21.0.tgz
Normal file
BIN
.yarn-offline-cache/undici-types-6.21.0.tgz
Normal file
Binary file not shown.
@ -11,6 +11,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
17
package.json
17
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Array>} AIS 타겟 데이터 배열
|
||||
* @returns {Promise<AisTargetResponse[]>} AIS 타겟 데이터 배열
|
||||
*/
|
||||
export async function searchAisTargets(minutes = 60) {
|
||||
export async function searchAisTargets(minutes: number = 60): Promise<AisTargetResponse[]> {
|
||||
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 '';
|
||||
@ -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<Array<{ commonCodeTypeName: string, commonCodeTypeNumber: string, commonCodeEtc: string }>>}
|
||||
* @returns {Promise<CommonCodeItem[]>}
|
||||
*/
|
||||
export async function fetchCommonCodeList(commonCodeTypeNumber) {
|
||||
export async function fetchCommonCodeList(commonCodeTypeNumber: string): Promise<CommonCodeItem[]> {
|
||||
try {
|
||||
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
|
||||
method: 'POST',
|
||||
@ -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<Array>} 관심선박 목록
|
||||
* @returns {Promise<FavoriteShipItem[]>} 관심선박 목록
|
||||
*/
|
||||
export async function fetchFavoriteShips() {
|
||||
export async function fetchFavoriteShips(): Promise<FavoriteShipItem[]> {
|
||||
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<Array>} 관심구역 목록
|
||||
* @returns {Promise<RealmItem[]>} 관심구역 목록
|
||||
*/
|
||||
export async function fetchRealms() {
|
||||
export async function fetchRealms(): Promise<RealmItem[]> {
|
||||
const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -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<Response> {
|
||||
// 로컬 개발: 세션 타임아웃 체크 우회
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,14 +4,15 @@
|
||||
*/
|
||||
|
||||
import { parsePipeMessage, rowToShipObject } from '../common/stompClient';
|
||||
import type { ShipFeature } from '../types/ship';
|
||||
|
||||
/**
|
||||
* 12분 이내 전체 선박 신호 조회
|
||||
* STOMP 구독 전에 호출하여 초기 선박 데이터 로드
|
||||
*
|
||||
* @returns {Promise<Array>} 선박 데이터 배열
|
||||
* @returns {Promise<ShipFeature[]>} 선박 데이터 배열
|
||||
*/
|
||||
export async function fetchAllSignals() {
|
||||
export async function fetchAllSignals(): Promise<ShipFeature[]> {
|
||||
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<string[]>} 파이프 구분 문자열 배열
|
||||
*/
|
||||
export async function fetchAllSignalsRaw() {
|
||||
export async function fetchAllSignalsRaw(): Promise<string[]> {
|
||||
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`);
|
||||
|
||||
@ -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<Array>} ProcessedTrack 배열
|
||||
* @param {TrackQueryParams} params
|
||||
* @returns {Promise<ProcessedTrack[]>} ProcessedTrack 배열
|
||||
*/
|
||||
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) {
|
||||
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }: TrackQueryParams): Promise<ProcessedTrack[]> {
|
||||
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);
|
||||
}
|
||||
@ -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<Array|null>} 설정 배열 또는 null (저장된 설정 없음)
|
||||
* @returns {Promise<FilterSettingItem[]|null>} 설정 배열 또는 null (저장된 설정 없음)
|
||||
*/
|
||||
export async function fetchUserFilter() {
|
||||
export async function fetchUserFilter(): Promise<FilterSettingItem[] | null> {
|
||||
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<unknown> {
|
||||
const response = await fetchWithAuth(SAVE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -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]);
|
||||
|
||||
@ -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<string | null>(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<HTMLUListElement>(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 }) {
|
||||
</div>
|
||||
|
||||
<ul className="vessel-list" ref={listRef}>
|
||||
{tracks.map((track) => {
|
||||
{tracks.map((track: ProcessedTrack) => {
|
||||
const isDisabled = disabledVesselIds.has(track.vesselId);
|
||||
const isHighlighted = highlightedVesselId === track.vesselId;
|
||||
const color = getShipKindColor(track.shipKindCode);
|
||||
@ -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<HTMLDivElement>(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<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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<string | number, Zone>();
|
||||
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 (
|
||||
<div
|
||||
@ -77,7 +79,7 @@ export default function AreaSearchTooltip() {
|
||||
<img
|
||||
src={flagUrl}
|
||||
alt="국기"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@ -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<HTMLInputElement>) => {
|
||||
useStsStore.getState().setMinContactDuration(Number(e.target.value));
|
||||
}, []);
|
||||
|
||||
const handleDistanceChange = useCallback((e) => {
|
||||
const handleDistanceChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
useStsStore.getState().setMaxContactDistance(Number(e.target.value));
|
||||
}, []);
|
||||
|
||||
const [detailGroupIndex, setDetailGroupIndex] = useState(null);
|
||||
const [detailGroupIndex, setDetailGroupIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDetailClick = useCallback((idx) => {
|
||||
const handleDetailClick = useCallback((idx: number) => {
|
||||
setDetailGroupIndex(idx);
|
||||
}, []);
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<OlMap | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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 }) {
|
||||
<div className="sts-detail-modal__header" onMouseDown={handleMouseDown}>
|
||||
<div className="sts-detail-modal__title">
|
||||
<VesselBadge vessel={vessel1} track={vessel1Track} />
|
||||
<span className="sts-detail-modal__arrow">↔</span>
|
||||
<span className="sts-detail-modal__arrow">{'\u2194'}</span>
|
||||
<VesselBadge vessel={vessel2} track={vessel2Track} />
|
||||
</div>
|
||||
<button type="button" className="sts-detail-modal__close" onClick={onClose}>
|
||||
@ -318,7 +326,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
||||
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
|
||||
/>
|
||||
|
||||
{/* 접촉 요약 — 그리드 2열 */}
|
||||
{/* 접촉 요약 -- 그리드 2열 */}
|
||||
<div className="sts-detail-modal__section">
|
||||
<h4 className="sts-detail-modal__section-title">접촉 요약</h4>
|
||||
<div className="sts-detail-modal__summary-grid">
|
||||
@ -362,7 +370,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
||||
<div className="sts-detail-modal__section">
|
||||
<h4 className="sts-detail-modal__section-title">접촉 이력 ({group.contacts.length}회)</h4>
|
||||
<div className="sts-detail-modal__contact-list">
|
||||
{group.contacts.map((c, ci) => (
|
||||
{group.contacts.map((c: StsContact, ci: number) => (
|
||||
<div key={ci} className="sts-detail-modal__contact-item">
|
||||
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
|
||||
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
|
||||
@ -376,7 +384,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 거리 통계 — 3열 그리드 */}
|
||||
{/* 거리 통계 -- 3열 그리드 */}
|
||||
<div className="sts-detail-modal__section">
|
||||
<h4 className="sts-detail-modal__section-title">거리 통계</h4>
|
||||
<div className="sts-detail-modal__stats-grid">
|
||||
@ -407,7 +415,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선박 상세 — 2열 그리드 */}
|
||||
{/* 선박 상세 -- 2열 그리드 */}
|
||||
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
|
||||
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
|
||||
</div>
|
||||
@ -423,7 +431,12 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VesselBadge({ vessel, track }) {
|
||||
interface VesselBadgeProps {
|
||||
vessel: StsVessel;
|
||||
track: ProcessedTrack;
|
||||
}
|
||||
|
||||
function VesselBadge({ vessel, track }: VesselBadgeProps) {
|
||||
const kindName = getShipKindName(track.shipKindCode);
|
||||
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
|
||||
return (
|
||||
@ -434,7 +447,7 @@ function VesselBadge({ vessel, track }) {
|
||||
className="sts-detail-modal__flag"
|
||||
src={flagUrl}
|
||||
alt=""
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="sts-detail-modal__name">
|
||||
@ -444,7 +457,13 @@ function VesselBadge({ vessel, track }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VesselDetailSection({ label, vessel, track }) {
|
||||
interface VesselDetailSectionProps {
|
||||
label: string;
|
||||
vessel: StsVessel;
|
||||
track: ProcessedTrack;
|
||||
}
|
||||
|
||||
function VesselDetailSection({ label, vessel, track }: VesselDetailSectionProps) {
|
||||
const kindName = getShipKindName(track.shipKindCode);
|
||||
const sourceName = getSignalSourceName(track.sigSrcCd);
|
||||
const color = getShipKindColor(track.shipKindCode);
|
||||
@ -456,7 +475,7 @@ function VesselDetailSection({ label, vessel, track }) {
|
||||
className="sts-detail-modal__track-dot"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
{label} — {vessel.vesselName || vessel.vesselId}
|
||||
{label} -- {vessel.vesselName || vessel.vesselId}
|
||||
</h4>
|
||||
<div className="sts-detail-modal__vessel-grid">
|
||||
<div className="sts-detail-modal__vessel-grid-item">
|
||||
@ -2,15 +2,16 @@
|
||||
* STS 접촉 쌍 결과 리스트 (그룹 기반)
|
||||
*
|
||||
* - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑
|
||||
* - 카드 클릭 → on/off 토글
|
||||
* - ▼/▲ 버튼 → 하단 정보 확장
|
||||
* - ▶ 버튼 → 모달 팝업
|
||||
* - 호버 → 지도 하이라이트
|
||||
* - 카드 클릭 -> on/off 토글
|
||||
* - 하단 정보 확장/접힘 토글
|
||||
* - 모달 팝업 버튼
|
||||
* - 호버 -> 지도 하이라이트
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import './StsContactList.scss';
|
||||
import { useStsStore } from '../stores/stsStore';
|
||||
import { getShipKindName } from '../../tracking/types/trackQuery.types';
|
||||
import type { StsGroupedContact, StsVessel, StsContact } from '../types/sts.types';
|
||||
import {
|
||||
getIndicatorDetail,
|
||||
formatDistance,
|
||||
@ -19,12 +20,18 @@ import {
|
||||
} from '../types/sts.types';
|
||||
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
||||
|
||||
function getNationalFlagUrl(nationalCode) {
|
||||
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
|
||||
if (!nationalCode) return null;
|
||||
return `/ship/image/small/${nationalCode}.svg`;
|
||||
}
|
||||
|
||||
function GroupCard({ group, index, onDetailClick }) {
|
||||
interface GroupCardProps {
|
||||
group: StsGroupedContact;
|
||||
index: number;
|
||||
onDetailClick?: (idx: number) => void;
|
||||
}
|
||||
|
||||
function GroupCard({ group, index, onDetailClick }: GroupCardProps) {
|
||||
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
|
||||
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
|
||||
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
|
||||
@ -42,19 +49,19 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
useStsStore.getState().setHighlightedGroupIndex(null);
|
||||
}, []);
|
||||
|
||||
// 카드 클릭 → on/off 토글
|
||||
// 카드 클릭 -> on/off 토글
|
||||
const handleClick = useCallback(() => {
|
||||
useStsStore.getState().toggleGroupEnabled(index);
|
||||
}, [index]);
|
||||
|
||||
// ▼/▲ 버튼 → 하단 정보 확장
|
||||
const handleExpand = useCallback((e) => {
|
||||
// 확장/접힘 토글
|
||||
const handleExpand = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
useStsStore.getState().setExpandedGroupIndex(index);
|
||||
}, [index]);
|
||||
|
||||
// ▶ 버튼 → 모달 열기
|
||||
const handleDetail = useCallback((e) => {
|
||||
// 모달 열기
|
||||
const handleDetail = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDetailClick?.(index);
|
||||
}, [index, onDetailClick]);
|
||||
@ -97,7 +104,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
className="sts-card__flag"
|
||||
src={v1Flag}
|
||||
alt=""
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="sts-card__name">{vessel1.vesselName || vessel1.vesselId}</span>
|
||||
@ -105,7 +112,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
|
||||
{/* 접촉 요약 (그룹 합산) */}
|
||||
<div className="sts-card__contact-summary">
|
||||
<span className="sts-card__arrow">↕</span>
|
||||
<span className="sts-card__arrow">{'\u2195'}</span>
|
||||
<span>{formatDuration(group.totalDurationMinutes)}</span>
|
||||
<span className="sts-card__sep">|</span>
|
||||
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
|
||||
@ -122,7 +129,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
className="sts-card__flag"
|
||||
src={v2Flag}
|
||||
alt=""
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="sts-card__name">{vessel2.vesselName || vessel2.vesselId}</span>
|
||||
@ -132,7 +139,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
onClick={handleExpand}
|
||||
title="상세 정보"
|
||||
>
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
{isExpanded ? '\u25B2' : '\u25BC'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -140,7 +147,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
onClick={handleDetail}
|
||||
title="상세 모달"
|
||||
>
|
||||
▶
|
||||
{'\u25B6'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -167,7 +174,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
||||
{group.contacts.length > 1 && (
|
||||
<div className="sts-card__sub-contacts">
|
||||
<span className="sts-card__sub-title">접촉 이력 ({group.contacts.length}회)</span>
|
||||
{group.contacts.map((c, ci) => (
|
||||
{group.contacts.map((c: StsContact, ci: number) => (
|
||||
<div key={ci} className="sts-card__sub-contact">
|
||||
<span className="sts-card__sub-num">#{ci + 1}</span>
|
||||
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
|
||||
@ -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 (
|
||||
<div className="sts-card__vessel-detail">
|
||||
<div className="sts-card__vessel-detail-header">
|
||||
@ -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<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedGroupIndex === null || !listRef.current) return;
|
||||
@ -253,7 +269,7 @@ export default function StsContactList({ onDetailClick }) {
|
||||
|
||||
return (
|
||||
<ul className="sts-contact-list" ref={listRef}>
|
||||
{groupedContacts.map((group, idx) => (
|
||||
{groupedContacts.map((group: StsGroupedContact, idx: number) => (
|
||||
<GroupCard key={group.pairKey} group={group} index={idx} onDetailClick={onDetailClick} />
|
||||
))}
|
||||
</ul>
|
||||
@ -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<number, MarkerItem[]> = {};
|
||||
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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<OlMap | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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<string | number, Zone> = {};
|
||||
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 }) {
|
||||
<span className="vessel-detail-modal__kind">{kindName}</span>
|
||||
{flagUrl && (
|
||||
<span className="vessel-detail-modal__flag">
|
||||
<img src={flagUrl} alt="국기" onError={(e) => { e.target.style.display = 'none'; }} />
|
||||
<img src={flagUrl} alt="국기" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
</span>
|
||||
)}
|
||||
<span className="vessel-detail-modal__name">
|
||||
@ -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<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e, index) => {
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLLIElement>, 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<HTMLLIElement>, 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<HTMLLIElement>, 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 && (
|
||||
<ul className="zone-list">
|
||||
{zones.map((zone, index) => {
|
||||
{zones.map((zone: Zone, index: number) => {
|
||||
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
|
||||
return (
|
||||
<li
|
||||
@ -3,7 +3,7 @@
|
||||
*
|
||||
* 성능 최적화:
|
||||
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
|
||||
* - 정적 레이어(PathLayer) 캐싱 — 필터 변경 시에만 재생성
|
||||
* - 정적 레이어(PathLayer) 캐싱 -- 필터 변경 시에만 재생성
|
||||
* - 동적 레이어(IconLayer, TextLayer, TripsLayer)만 매 프레임 갱신
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
@ -11,6 +11,8 @@ import { TripsLayer } from '@deck.gl/geo-layers';
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
|
||||
import type { ProcessedTrack } from '../stores/areaSearchStore';
|
||||
import type { VesselPosition } from '../types/areaSearch.types';
|
||||
import {
|
||||
registerAreaSearchLayers,
|
||||
unregisterAreaSearchLayers,
|
||||
@ -21,12 +23,27 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
||||
const TRAIL_LENGTH_MS = 3600000;
|
||||
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
|
||||
|
||||
export default function useAreaSearchLayer() {
|
||||
const tripsDataRef = useRef([]);
|
||||
interface TripsDataItem {
|
||||
vesselId: string;
|
||||
shipKindCode: string;
|
||||
path: number[][];
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
interface StaticLayerCacheDeps {
|
||||
tracks: ProcessedTrack[];
|
||||
disabledVesselIds: Set<string>;
|
||||
shipKindCodeFilter: Set<string>;
|
||||
highlightedVesselId: string | null;
|
||||
}
|
||||
|
||||
export default function useAreaSearchLayer(): void {
|
||||
const tripsDataRef = useRef<TripsDataItem[]>([]);
|
||||
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,
|
||||
@ -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<number>;
|
||||
highlightedGroupIndex: number | null;
|
||||
}
|
||||
|
||||
interface StaticLayerCacheDeps {
|
||||
tracks: ProcessedTrack[];
|
||||
disabledGroupIndices: Set<number>;
|
||||
highlightedGroupIndex: number | null;
|
||||
}
|
||||
|
||||
export default function useStsLayer(): void {
|
||||
const tripsDataRef = useRef<TripsDataItem[]>([]);
|
||||
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<string> | 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,
|
||||
@ -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<Draw | null>(null);
|
||||
const mapRef = useRef<OlMap | null>(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<Geometry>);
|
||||
});
|
||||
|
||||
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<Geometry>);
|
||||
});
|
||||
},
|
||||
);
|
||||
@ -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<Geometry>,
|
||||
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<Geometry>); } catch { /* already removed */ }
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -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<Geometry>): 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<Geometry>, 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<OlMap | null>(null);
|
||||
const modifyRef = useRef<Modify | null>(null);
|
||||
const translateRef = useRef<Translate | null>(null);
|
||||
const customResizeRef = useRef<BoxResizeInteraction | CircleResizeInteraction | null>(null);
|
||||
const selectedCollectionRef = useRef(new Collection<Feature<Geometry>>());
|
||||
const clickListenerRef = useRef<((evt: MapBrowserEvent<PointerEvent>) => void) | null>(null);
|
||||
const contextMenuRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||
const keydownRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||
const hoveredZoneIdRef = useRef<string | null>(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<Polygon>;
|
||||
const collection = selectedCollectionRef.current;
|
||||
collection.push(feature);
|
||||
collection.push(feature as Feature<Geometry>);
|
||||
|
||||
// 선택 스타일 적용
|
||||
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<Geometry>, 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<Geometry>, 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<Geometry>, 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<Polygon>) => {
|
||||
// 리사이즈 후 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<PointerEvent>) => {
|
||||
// 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<Geometry>, 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<PointerEvent>) => {
|
||||
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<Geometry>)) {
|
||||
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 = '';
|
||||
@ -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<Polygon>;
|
||||
onResize?: (feature: Feature<Polygon>) => 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<Polygon>;
|
||||
private onResize_: ((feature: Feature<Polygon>) => 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<PointerEvent>) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
|
||||
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => 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<PointerEvent>): 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<PointerEvent>): 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] };
|
||||
}
|
||||
}
|
||||
@ -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<Polygon>;
|
||||
center: [number, number]; // EPSG:3857 [x, y]
|
||||
onResize?: (feature: Feature<Polygon>) => void;
|
||||
}
|
||||
|
||||
interface HandleResult {
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export default class CircleResizeInteraction extends PointerInteraction {
|
||||
constructor(options) {
|
||||
private feature_: Feature<Polygon>;
|
||||
private center_: [number, number];
|
||||
private onResize_: ((feature: Feature<Polygon>) => 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<PointerEvent>) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
|
||||
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => 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<PointerEvent>): 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<PointerEvent>): 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' };
|
||||
}
|
||||
@ -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<string, HitDetail[]>;
|
||||
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<AreaSearchResult> {
|
||||
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<string, HitDetail[]> = {};
|
||||
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);
|
||||
@ -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<StsSearchResult> {
|
||||
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),
|
||||
@ -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<AreaSearchAnimationState>()(subscribeWithSelector((set, get) => {
|
||||
const animate = (): void => {
|
||||
const state = get();
|
||||
if (!state.isPlaying) return;
|
||||
|
||||
@ -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<string, number>();
|
||||
|
||||
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
||||
interface AreaSearchState {
|
||||
// 탭 상태
|
||||
activeTab: AnalysisTab;
|
||||
|
||||
// 검색 조건
|
||||
zones: Zone[];
|
||||
searchMode: SearchMode;
|
||||
|
||||
// 검색 결과
|
||||
tracks: ProcessedTrack[];
|
||||
hitDetails: Record<string, HitDetail[]>;
|
||||
summary: AreaSearchSummary | null;
|
||||
|
||||
// UI 상태
|
||||
isLoading: boolean;
|
||||
queryCompleted: boolean;
|
||||
disabledVesselIds: Set<string>;
|
||||
highlightedVesselId: string | null;
|
||||
showZones: boolean;
|
||||
activeDrawType: ZoneDrawType | null;
|
||||
areaSearchTooltip: AreaSearchTooltip | null;
|
||||
selectedZoneId: string | null;
|
||||
_lastZoneAddedAt: number;
|
||||
|
||||
// 필터 상태
|
||||
showPaths: boolean;
|
||||
showTrail: boolean;
|
||||
shipKindCodeFilter: Set<string>;
|
||||
|
||||
// 구역 관리
|
||||
addZone: (zone: Omit<Zone, 'id' | 'name' | 'colorIndex'> & { 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<string, HitDetail[]>) => 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<AreaSearchState>()(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<string>(),
|
||||
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<AreaSearchState> = { 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<string>(),
|
||||
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<string>(),
|
||||
highlightedVesselId: null,
|
||||
showZones: true,
|
||||
activeDrawType: null,
|
||||
@ -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<string, StsGroupedContact>();
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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<number>;
|
||||
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<string>;
|
||||
getCurrentPositions: (currentTime: number) => VesselPosition[];
|
||||
|
||||
// 초기화
|
||||
clearResults: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useStsStore = create<StsState>()(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<number>(),
|
||||
expandedGroupIndex: null,
|
||||
|
||||
// 필터 상태
|
||||
@ -129,7 +191,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
||||
tracks,
|
||||
summary,
|
||||
queryCompleted: true,
|
||||
disabledGroupIndices: new Set(),
|
||||
disabledGroupIndices: new Set<number>(),
|
||||
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<string>();
|
||||
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<number>(),
|
||||
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<number>(),
|
||||
highlightedGroupIndex: null,
|
||||
expandedGroupIndex: null,
|
||||
showPaths: true,
|
||||
@ -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<SearchMode, string> = {
|
||||
[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<Geometry>;
|
||||
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;
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
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];
|
||||
@ -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__ = [];
|
||||
}
|
||||
@ -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<string, HitDetail[]>,
|
||||
zones: Zone[],
|
||||
): void {
|
||||
// 구역별 최대 방문 횟수 계산
|
||||
const maxVisitsPerZone = {};
|
||||
const maxVisitsPerZone: Record<string, number> = {};
|
||||
zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; });
|
||||
Object.values(hitDetails).forEach((hits) => {
|
||||
const countByZone = {};
|
||||
const countByZone: Record<string, number> = {};
|
||||
(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');
|
||||
@ -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__ = [];
|
||||
}
|
||||
@ -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; }
|
||||
17
src/areaSearch/utils/zoneLayerRefs.ts
Normal file
17
src/areaSearch/utils/zoneLayerRefs.ts
Normal file
@ -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<Feature<Geometry>> | null = null;
|
||||
|
||||
export function setZoneSource(source: VectorSource | null): void { _source = source; }
|
||||
export function getZoneSource(): VectorSource | null { return _source; }
|
||||
export function setZoneLayer(layer: VectorLayer<Feature<Geometry>> | null): void { _layer = layer; }
|
||||
export function getZoneLayer(): VectorLayer<Feature<Geometry>> | null { return _layer; }
|
||||
@ -1,4 +1,4 @@
|
||||
export const shipTypeMap = new Map();
|
||||
export const shipTypeMap: Map<string, string> = new Map();
|
||||
|
||||
shipTypeMap.set('0', 'Not available');
|
||||
shipTypeMap.set('1', 'Reserved for future use');
|
||||
@ -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);
|
||||
@ -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);
|
||||
|
||||
@ -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<AlertState | null>(null);
|
||||
|
||||
useState(() => {
|
||||
showAlertFn = (message, errorCode) => {
|
||||
showAlertFn = (message: string, errorCode?: string) => {
|
||||
setAlert({ message, errorCode });
|
||||
};
|
||||
|
||||
@ -31,7 +36,7 @@ export function AlertModalContainer() {
|
||||
|
||||
return createPortal(
|
||||
<div className="alert-modal-overlay" onClick={handleClose}>
|
||||
<div className="alert-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="alert-modal" onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div className="alert-modal__message">{alert.message}</div>
|
||||
{alert.errorCode && (
|
||||
<div className="alert-modal__error-code">오류 코드: {alert.errorCode}</div>
|
||||
@ -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(
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
@ -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<ToastData[]>([]);
|
||||
|
||||
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<boolean>(false);
|
||||
const [isExiting, setIsExiting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 마운트 후 바로 표시
|
||||
@ -16,9 +16,9 @@ const SAMPLE_ALERTS = [
|
||||
*/
|
||||
export default function Header() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const alertIndexRef = useRef(0);
|
||||
const alertIndexRef = useRef<number>(0);
|
||||
|
||||
const handleAlarmClick = (e) => {
|
||||
const handleAlarmClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length];
|
||||
alertIndexRef.current++;
|
||||
@ -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 (
|
||||
<nav id="nav">
|
||||
<ul className="gnb">
|
||||
@ -33,7 +45,7 @@ export default function SideNav({ activeKey, onChange }) {
|
||||
}
|
||||
|
||||
// 키-경로 매핑 export (Sidebar에서 사용)
|
||||
export const keyToPath = {
|
||||
export const keyToPath: Record<string, string> = {
|
||||
gnb1: 'ship',
|
||||
gnb4: 'analysis',
|
||||
gnb5: 'timeline',
|
||||
@ -41,6 +53,6 @@ export const keyToPath = {
|
||||
gnb8: 'area-search',
|
||||
};
|
||||
|
||||
export const pathToKey = Object.fromEntries(
|
||||
export const pathToKey: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(keyToPath).map(([k, v]) => [v, k])
|
||||
);
|
||||
@ -20,10 +20,10 @@ export default function Sidebar() {
|
||||
const path = location.pathname.split('/')[1];
|
||||
return path ? pathToKey[path] : null;
|
||||
})();
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(initialActiveKey !== null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState<boolean>(initialActiveKey !== null);
|
||||
|
||||
// URL에서 활성 메뉴 키 추출 (루트 경로면 비활성)
|
||||
const getActiveKey = () => {
|
||||
const getActiveKey = (): string | null => {
|
||||
const path = location.pathname.split('/')[1];
|
||||
if (!path) return null; // 루트 경로면 비활성
|
||||
return pathToKey[path] || null;
|
||||
@ -31,7 +31,7 @@ export default function Sidebar() {
|
||||
|
||||
const activeKey = getActiveKey();
|
||||
|
||||
const handleMenuChange = (key) => {
|
||||
const handleMenuChange = (key: string) => {
|
||||
setIsPanelOpen(true);
|
||||
const path = keyToPath[key] || 'ship';
|
||||
navigate(`/${path}`);
|
||||
@ -49,14 +49,14 @@ export default function Sidebar() {
|
||||
|
||||
// 활성 키에 따른 패널 컴포넌트 렌더링
|
||||
const renderPanel = () => {
|
||||
const panelMap = {
|
||||
const panelMap: Record<string, React.ReactNode> = {
|
||||
gnb1: <ShipFilterPanel {...panelProps} />,
|
||||
gnb4: null, // TODO: 분석 패널
|
||||
gnb5: null, // TODO: 타임라인 패널
|
||||
gnb7: <ReplayPage {...panelProps} />,
|
||||
gnb8: <AreaSearchPage {...panelProps} />,
|
||||
};
|
||||
return panelMap[activeKey] || null;
|
||||
return activeKey ? panelMap[activeKey] || null : null;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useMapStore } from '../../stores/mapStore';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import type { LabelOptions } from '../../stores/shipStore';
|
||||
import { downloadShipCsv } from '../../utils/csvDownload';
|
||||
import { showLiveShips } from '../../utils/liveControl';
|
||||
import useReplayStore from '../../replay/stores/replayStore';
|
||||
@ -8,8 +9,15 @@ import useAnimationStore from '../../replay/stores/animationStore';
|
||||
import { unregisterReplayLayers } from '../../replay/utils/replayLayerRegistry';
|
||||
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
||||
|
||||
type AreaShapeKey = 'Box' | 'Polygon' | 'Circle';
|
||||
|
||||
interface AreaShapeOption {
|
||||
key: AreaShapeKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 면적 도형 옵션
|
||||
const AREA_SHAPES = [
|
||||
const AREA_SHAPES: AreaShapeOption[] = [
|
||||
{ key: 'Box', label: '사각형' },
|
||||
{ key: 'Polygon', label: '다각형' },
|
||||
{ key: 'Circle', label: '원형' },
|
||||
@ -23,8 +31,8 @@ const AREA_SHAPES = [
|
||||
* - 범례, 미니맵
|
||||
*/
|
||||
export default function ToolBar() {
|
||||
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState(false);
|
||||
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState(false);
|
||||
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState<boolean>(false);
|
||||
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState<boolean>(false);
|
||||
const { zoom, zoomIn, zoomOut, activeMeasureTool, setMeasureTool, setAreaShape } = useMapStore();
|
||||
const {
|
||||
isIntegrate,
|
||||
@ -38,7 +46,7 @@ export default function ToolBar() {
|
||||
} = useShipStore();
|
||||
|
||||
// 선명표시 옵션 목록
|
||||
const labelOptionList = [
|
||||
const labelOptionList: { key: keyof LabelOptions; label: string }[] = [
|
||||
{ key: 'showShipName', label: '선박명' },
|
||||
{ key: 'showSpeedVector', label: '속도벡터' },
|
||||
{ key: 'showShipSize', label: '선박크기' },
|
||||
@ -185,7 +193,9 @@ export default function ToolBar() {
|
||||
alert('다운로드할 선박 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
await downloadShipCsv(ships);
|
||||
// ShipFeature[]는 DownloadShip의 모든 필드를 포함 (getDownloadShips가 보장)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await downloadShipCsv(ships as any);
|
||||
}}
|
||||
>다운로드</button>
|
||||
</li>
|
||||
@ -9,23 +9,30 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { toLonLat } from 'ol/proj';
|
||||
import { useMapStore } from '../../stores/mapStore';
|
||||
import useTrackingModeStore from '../../stores/trackingModeStore';
|
||||
import useShipSearch from '../../hooks/useShipSearch';
|
||||
import useShipSearch, { type SearchResult } from '../../hooks/useShipSearch';
|
||||
import './TopBar.scss';
|
||||
|
||||
interface DmsResult {
|
||||
degrees: number;
|
||||
minutes: number;
|
||||
seconds: string;
|
||||
direction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 십진도를 도분초(DMS) 형식으로 변환
|
||||
* @param {number} decimal - 십진도
|
||||
* @param {boolean} isLongitude - 경도 여부 (false면 위도)
|
||||
* @returns {{ degrees: number, minutes: number, seconds: string, direction: string }}
|
||||
*/
|
||||
function decimalToDMS(decimal, isLongitude) {
|
||||
function decimalToDMS(decimal: number, isLongitude: boolean): DmsResult {
|
||||
const absolute = Math.abs(decimal);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutesFloat = (absolute - degrees) * 60;
|
||||
const minutes = Math.floor(minutesFloat);
|
||||
const seconds = ((minutesFloat - minutes) * 60).toFixed(3);
|
||||
|
||||
let direction;
|
||||
let direction: string;
|
||||
if (isLongitude) {
|
||||
direction = decimal >= 0 ? 'E' : 'W';
|
||||
} else {
|
||||
@ -41,11 +48,17 @@ function decimalToDMS(decimal, isLongitude) {
|
||||
* @param {boolean} isLongitude - 경도 여부
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDecimalDegrees(decimal, isLongitude) {
|
||||
function formatDecimalDegrees(decimal: number, isLongitude: boolean): string {
|
||||
const direction = isLongitude
|
||||
? (decimal >= 0 ? 'E' : 'W')
|
||||
: (decimal >= 0 ? 'N' : 'S');
|
||||
return `${Math.abs(decimal).toFixed(6)}° ${direction}`;
|
||||
return `${Math.abs(decimal).toFixed(6)}\u00B0 ${direction}`;
|
||||
}
|
||||
|
||||
interface TimeFormatResult {
|
||||
dateStr: string;
|
||||
timeStr: string;
|
||||
dayOfWeek: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -53,7 +66,7 @@ function formatDecimalDegrees(decimal, isLongitude) {
|
||||
* @param {Date} date
|
||||
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
||||
*/
|
||||
function formatKST(date) {
|
||||
function formatKST(date: Date): TimeFormatResult {
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
@ -75,7 +88,7 @@ function formatKST(date) {
|
||||
* @param {Date} date
|
||||
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
||||
*/
|
||||
function formatUTC(date) {
|
||||
function formatUTC(date: Date): TimeFormatResult {
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
@ -93,26 +106,26 @@ function formatUTC(date) {
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const map = useMapStore((s) => s.map);
|
||||
const map = useMapStore((s: { map: unknown }) => s.map);
|
||||
|
||||
// 추적 모드 상태
|
||||
const mode = useTrackingModeStore((s) => s.mode);
|
||||
const setMapMode = useTrackingModeStore((s) => s.setMapMode);
|
||||
const setShipMode = useTrackingModeStore((s) => s.setShipMode);
|
||||
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
|
||||
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
||||
const mode = useTrackingModeStore((s: { mode: string }) => s.mode);
|
||||
const setMapMode = useTrackingModeStore((s: { setMapMode: () => void }) => s.setMapMode);
|
||||
const setShipMode = useTrackingModeStore((s: { setShipMode: () => void }) => s.setShipMode);
|
||||
const trackedShip = useTrackingModeStore((s: { trackedShip: { shipName?: string; originalTargetId?: string } | null }) => s.trackedShip);
|
||||
const radiusNM = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM);
|
||||
|
||||
// 마우스 좌표 상태
|
||||
const [coordinates, setCoordinates] = useState({ lon: null, lat: null });
|
||||
const [coordinates, setCoordinates] = useState<{ lon: number | null; lat: number | null }>({ lon: null, lat: null });
|
||||
|
||||
// 현재 시간 상태
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||
|
||||
// 설정 상태
|
||||
const [coordFormat, setCoordFormat] = useState('dms'); // 'dms' | 'decimal'
|
||||
const [timeFormat, setTimeFormat] = useState('kst'); // 'kst' | 'utc'
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const settingsRef = useRef(null);
|
||||
const [coordFormat, setCoordFormat] = useState<'dms' | 'decimal'>('dms');
|
||||
const [timeFormat, setTimeFormat] = useState<'kst' | 'utc'>('kst');
|
||||
const [showSettings, setShowSettings] = useState<boolean>(false);
|
||||
const settingsRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
// 검색 훅
|
||||
const {
|
||||
@ -126,17 +139,18 @@ export default function TopBar() {
|
||||
} = useShipSearch();
|
||||
|
||||
// 검색창 포커스 상태
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const searchContainerRef = useRef(null);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 좌표 업데이트 쓰로틀 ref
|
||||
const throttleRef = useRef(null);
|
||||
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 마우스 이동 시 좌표 업데이트
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const handlePointerMove = (evt) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlePointerMove = (evt: any) => {
|
||||
// 쓰로틀: 100ms
|
||||
if (throttleRef.current) return;
|
||||
|
||||
@ -145,17 +159,20 @@ export default function TopBar() {
|
||||
}, 100);
|
||||
|
||||
const pixel = evt.pixel;
|
||||
const coord3857 = map.getCoordinateFromPixel(pixel);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const coord3857 = (map as any).getCoordinateFromPixel(pixel);
|
||||
if (coord3857) {
|
||||
const [lon, lat] = toLonLat(coord3857);
|
||||
setCoordinates({ lon, lat });
|
||||
}
|
||||
};
|
||||
|
||||
map.on('pointermove', handlePointerMove);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(map as any).on('pointermove', handlePointerMove);
|
||||
|
||||
return () => {
|
||||
map.un('pointermove', handlePointerMove);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(map as any).un('pointermove', handlePointerMove);
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
@ -174,11 +191,11 @@ export default function TopBar() {
|
||||
|
||||
// 검색창/설정 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target)) {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
|
||||
setIsSearchFocused(false);
|
||||
}
|
||||
if (settingsRef.current && !settingsRef.current.contains(e.target)) {
|
||||
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
|
||||
setShowSettings(false);
|
||||
}
|
||||
};
|
||||
@ -188,12 +205,12 @@ export default function TopBar() {
|
||||
}, []);
|
||||
|
||||
// 검색어 변경 핸들러
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
}, [setSearchValue]);
|
||||
|
||||
// 엔터키로 첫 번째 결과 선택
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSelectFirst();
|
||||
@ -204,7 +221,7 @@ export default function TopBar() {
|
||||
}, [handleSelectFirst, clearSearch]);
|
||||
|
||||
// 검색 결과 클릭
|
||||
const handleResultClick = useCallback((result) => {
|
||||
const handleResultClick = useCallback((result: SearchResult) => {
|
||||
handleClickResult(result);
|
||||
setIsSearchFocused(false);
|
||||
}, [handleClickResult]);
|
||||
@ -217,20 +234,20 @@ export default function TopBar() {
|
||||
/**
|
||||
* 문자열 길이 제한 (말줄임)
|
||||
*/
|
||||
const truncateString = (str, maxLength = 20) => {
|
||||
const truncateString = (str: string | null | undefined, maxLength: number = 20): string => {
|
||||
if (!str) return '-';
|
||||
return str.length > maxLength ? str.slice(0, maxLength) + '...' : str;
|
||||
};
|
||||
|
||||
// 좌표 포맷팅 (설정에 따라)
|
||||
const renderCoordinate = (value, isLongitude) => {
|
||||
const renderCoordinate = (value: number | null, isLongitude: boolean) => {
|
||||
if (value === null) return <span>---</span>;
|
||||
|
||||
if (coordFormat === 'dms') {
|
||||
const dms = decimalToDMS(value, isLongitude);
|
||||
return (
|
||||
<>
|
||||
<span>{dms.degrees}°</span>
|
||||
<span>{dms.degrees}°</span>
|
||||
<span>{dms.minutes}'</span>
|
||||
<span>{dms.seconds}"</span>
|
||||
<span>{dms.direction}</span>
|
||||
@ -375,7 +392,7 @@ export default function TopBar() {
|
||||
onClick={clearSearch}
|
||||
title="검색어 지우기"
|
||||
>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -383,7 +400,7 @@ export default function TopBar() {
|
||||
{/* 검색 결과 목록 */}
|
||||
{isSearchFocused && searchValue && results.length > 0 && (
|
||||
<ul className="search-results">
|
||||
{results.map((result) => (
|
||||
{results.map((result: SearchResult) => (
|
||||
<li
|
||||
key={result.featureId}
|
||||
className="search-result-item"
|
||||
@ -29,9 +29,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
|
||||
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
|
||||
|
||||
/**
|
||||
* 선박 종류 코드 → 아이콘 매핑
|
||||
* 선박 종류 코드 -> 아이콘 매핑
|
||||
*/
|
||||
const SHIP_KIND_ICONS = {
|
||||
const SHIP_KIND_ICONS: Record<string, string> = {
|
||||
[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<string, number>;
|
||||
replayTotalCount: number;
|
||||
shipKindCodeFilter: Set<string>;
|
||||
}) => ({
|
||||
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 (
|
||||
<article className="ship-legend">
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(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 }}
|
||||
>
|
||||
<div className="ship-context-menu__header">{title}</div>
|
||||
{visibleMenuItems.map((item, index) => (
|
||||
{visibleMenuItems.map((_item, _index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
|
||||
onClick={() => 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 && <span className="submenu-arrow">▶</span>}
|
||||
{_item.label}
|
||||
{_item.hasSubmenu && <span className="submenu-arrow">▶</span>}
|
||||
|
||||
{/* 반경설정 서브메뉴 */}
|
||||
{item.key === 'radius' && hoveredItem === 'radius' && (
|
||||
{_item.key === 'radius' && hoveredItem === 'radius' && (
|
||||
<div
|
||||
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
|
||||
style={{ top: 0 }}
|
||||
>
|
||||
{RADIUS_OPTIONS.map((radius) => (
|
||||
{RADIUS_OPTIONS.map((radius: number) => (
|
||||
<div
|
||||
key={radius}
|
||||
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
handleRadiusSelect(radius);
|
||||
}}
|
||||
@ -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<string, string> = {
|
||||
[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; }}
|
||||
/>
|
||||
</div>
|
||||
{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<TimeRange>(() => {
|
||||
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 && (
|
||||
<span className="countryFlag">
|
||||
<img
|
||||
src={getNationalFlagUrl(ship.nationalCode)}
|
||||
src={getNationalFlagUrl(ship.nationalCode) || ''}
|
||||
alt="국기"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@ -384,7 +401,7 @@ export default function ShipDetailModal({ modal }) {
|
||||
</div>
|
||||
|
||||
{/* gallery */}
|
||||
<ShipGallery imageUrlList={ship.imageUrlList} />
|
||||
<ShipGallery imageUrlList={ship.imageUrlList as string[] | undefined} />
|
||||
|
||||
{/* body */}
|
||||
<div className="pmBody">
|
||||
@ -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<boolean>(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="switchGroup">
|
||||
@ -31,10 +37,17 @@ 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 }) => (
|
||||
const ToggleSwitch = memo(function ToggleSwitch({ label, checked, onChange }: ToggleSwitchProps) {
|
||||
return (
|
||||
<li>
|
||||
<span>{label}</span>
|
||||
<label className="switch">
|
||||
@ -42,12 +55,20 @@ const ToggleSwitch = memo(({ label, checked, onChange }) => (
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</li>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
interface AllToggleProps {
|
||||
label: string;
|
||||
allChecked: boolean;
|
||||
onToggleAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 ON/OFF 토글
|
||||
*/
|
||||
const AllToggle = memo(({ label, allChecked, onToggleAll }) => (
|
||||
const AllToggle = memo(function AllToggle({ label, allChecked, onToggleAll }: AllToggleProps) {
|
||||
return (
|
||||
<li>
|
||||
<span style={{ fontWeight: 'bold' }}>{label}</span>
|
||||
<label className="switch">
|
||||
@ -55,21 +76,27 @@ const AllToggle = memo(({ label, allChecked, onToggleAll }) => (
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</li>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
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<string, boolean> }) => s.kindVisibility);
|
||||
const kindCounts = useShipStore((s: { kindCounts: Record<string, number> }) => 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 (
|
||||
<div className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
|
||||
@ -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 }) => (
|
||||
<ToggleSwitch
|
||||
key={code}
|
||||
label={`${label} (${(kindCounts[code] || 0).toLocaleString()})`}
|
||||
@ -32,9 +32,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
|
||||
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
|
||||
|
||||
/**
|
||||
* 선박 종류 코드 → 아이콘 매핑
|
||||
* 선박 종류 코드 -> 아이콘 매핑
|
||||
*/
|
||||
const SHIP_KIND_ICONS = {
|
||||
const SHIP_KIND_ICONS: Record<string, string> = {
|
||||
[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<string, number>;
|
||||
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<string, number>;
|
||||
kindVisibility: Record<string, boolean>;
|
||||
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<string> }) => s.disabledVesselIds);
|
||||
const areaSearchKindFilter = useAreaSearchStore((s: { shipKindCodeFilter: Set<string> }) => 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<string, number> = {};
|
||||
let total = 0;
|
||||
areaSearchTracks.forEach((track) => {
|
||||
if (areaSearchDisabledIds.has(track.vesselId)) return;
|
||||
@ -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;
|
||||
@ -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<HTMLInputElement>) => {
|
||||
setStartInput(e.target.value);
|
||||
}, []);
|
||||
|
||||
// 종료일 변경 핸들러
|
||||
const handleEndChange = useCallback((e) => {
|
||||
const handleEndChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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]);
|
||||
@ -5,6 +5,6 @@
|
||||
* 비활성화: 내부망 API(/api/gis) 접근 불가 (민간화)
|
||||
* TODO: 외부 API 연동 시 복원
|
||||
*/
|
||||
export default function useFavoriteData() {
|
||||
export default function useFavoriteData(): void {
|
||||
// noop — 내부망 /api/gis 의존 제거
|
||||
}
|
||||
@ -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: <T extends ShipWithCoords>(ships: T[]) => T[];
|
||||
filterFeaturesMapByRadius: <T extends ShipWithCoords>(featuresMap: Map<string, T>) => Map<string, T>;
|
||||
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(<T extends ShipWithCoords>(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(<T extends ShipWithCoords>(featuresMap: Map<string, T>): Map<string, T> => {
|
||||
if (!isRadiusFilterActive || !radiusCenter) {
|
||||
return featuresMap;
|
||||
}
|
||||
|
||||
const filteredMap = new Map();
|
||||
const filteredMap = new Map<string, T>();
|
||||
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);
|
||||
@ -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<VectorLayer<Feature<Geometry>> | null>(null);
|
||||
const sourceRef = useRef<VectorSource | null>(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;
|
||||
@ -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<void>;
|
||||
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<ReturnType<typeof setInterval> | null>(null);
|
||||
const initialLoadDoneRef = useRef<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(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<number> => {
|
||||
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<void> => {
|
||||
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]);
|
||||
@ -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<Deck | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선박 레이어 관리 훅
|
||||
* @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<Deck | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const batchRendererInitialized = useRef<boolean>(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<any[]>([]);
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@ -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<SearchResult[]>([]);
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user