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
|
#!/bin/bash
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
# pre-commit hook (React JavaScript)
|
# pre-commit hook (React TypeScript)
|
||||||
# ESLint 검증 — 실패 시 커밋 차단
|
# ESLint 검증 — 실패 시 커밋 차단
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ fi
|
|||||||
# ESLint 검증 (설정 파일이 있는 경우만)
|
# ESLint 검증 (설정 파일이 있는 경우만)
|
||||||
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
|
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
|
||||||
echo "pre-commit: ESLint 검증 중..."
|
echo "pre-commit: ESLint 검증 중..."
|
||||||
npx eslint src/ --ext .js,.jsx --quiet 2>&1
|
npx eslint src/ --ext .ts,.tsx --quiet 2>&1
|
||||||
LINT_RESULT=$?
|
LINT_RESULT=$?
|
||||||
|
|
||||||
if [ $LINT_RESULT -ne 0 ]; then
|
if [ $LINT_RESULT -ne 0 ]; then
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -31,8 +31,5 @@ Desktop.ini
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/scripts/
|
.claude/scripts/
|
||||||
|
|
||||||
# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외)
|
# TypeScript config (vite.config.ts 등은 추적)
|
||||||
**/*.ts
|
# tsconfig*.json은 추적
|
||||||
**/*.tsx
|
|
||||||
# tracking VesselListManager (참조용)
|
|
||||||
src/tracking/components/VesselListManager/
|
|
||||||
|
|||||||
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
17
package.json
17
package.json
@ -5,14 +5,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000",
|
"dev": "vite --port 3000",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:dev": "vite build --mode dev",
|
"build:dev": "tsc -b && vite build --mode dev",
|
||||||
"build:qa": "vite build --mode qa",
|
"build:qa": "tsc -b && vite build --mode qa",
|
||||||
"build:prod": "vite build",
|
"build:prod": "tsc -b && vite build",
|
||||||
"preview": "vite preview --port 3000",
|
"preview": "vite preview --port 3000",
|
||||||
"preview:dev": "vite preview --mode dev --port 3000",
|
"preview:dev": "vite preview --mode dev --port 3000",
|
||||||
"preview:qa": "vite preview --mode qa --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": {
|
"dependencies": {
|
||||||
"@deck.gl/core": "^9.2.6",
|
"@deck.gl/core": "^9.2.6",
|
||||||
@ -34,12 +35,18 @@
|
|||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@vitejs/plugin-react": "^4.0.1",
|
||||||
"eslint": "^8.44.0",
|
"eslint": "^8.44.0",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"eslint-plugin-react": "^7.34.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.1",
|
"eslint-plugin-react-refresh": "^0.4.1",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
"vite": "^5.2.10"
|
"vite": "^5.2.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,44 @@
|
|||||||
* SNP-Batch 서버의 AIS 데이터를 HTTP 폴링으로 조회
|
* SNP-Batch 서버의 AIS 데이터를 HTTP 폴링으로 조회
|
||||||
*/
|
*/
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import type { ShipFeature } from '../types/ship';
|
||||||
|
|
||||||
// dev: Vite 프록시 (/snp-api → 211.208.115.83:8041)
|
// dev: Vite 프록시 (/snp-api → 211.208.115.83:8041)
|
||||||
// prod: 환경변수로 직접 지정
|
// 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분 데이터)
|
* AIS 타겟 검색 (최근 N분 데이터)
|
||||||
* @param {number} minutes - 조회 기간 (분)
|
* @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`, {
|
const res = await axios.get(`${BASE_URL}/api/ais-target/search`, {
|
||||||
params: { minutes },
|
params: { minutes },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
@ -30,10 +57,10 @@ export async function searchAisTargets(minutes = 60) {
|
|||||||
* destination, eta, status, messageTimestamp, receivedDate,
|
* destination, eta, status, messageTimestamp, receivedDate,
|
||||||
* source, classType
|
* source, classType
|
||||||
*
|
*
|
||||||
* @param {Object} aisTarget - API 응답 단건
|
* @param {AisTargetResponse} aisTarget - API 응답 단건
|
||||||
* @returns {Object} shipStore 호환 feature 객체
|
* @returns {ShipFeature} shipStore 호환 feature 객체
|
||||||
*/
|
*/
|
||||||
export function aisTargetToFeature(aisTarget) {
|
export function aisTargetToFeature(aisTarget: AisTargetResponse): ShipFeature {
|
||||||
const mmsi = String(aisTarget.mmsi || '');
|
const mmsi = String(aisTarget.mmsi || '');
|
||||||
// 백엔드에서 signalKindCode를 직접 제공, 없으면 vesselType 기반 fallback
|
// 백엔드에서 signalKindCode를 직접 제공, 없으면 vesselType 기반 fallback
|
||||||
const signalKindCode = aisTarget.signalKindCode || mapVesselTypeToKindCode(aisTarget.vesselType);
|
const signalKindCode = aisTarget.signalKindCode || mapVesselTypeToKindCode(aisTarget.vesselType);
|
||||||
@ -109,7 +136,7 @@ export function aisTargetToFeature(aisTarget) {
|
|||||||
/**
|
/**
|
||||||
* vesselType 문자열 → 선종 코드 매핑
|
* vesselType 문자열 → 선종 코드 매핑
|
||||||
*/
|
*/
|
||||||
function mapVesselTypeToKindCode(vesselType) {
|
function mapVesselTypeToKindCode(vesselType: string | undefined): string {
|
||||||
if (!vesselType) return '000027'; // 일반
|
if (!vesselType) return '000027'; // 일반
|
||||||
|
|
||||||
const vt = vesselType.toLowerCase();
|
const vt = vesselType.toLowerCase();
|
||||||
@ -125,11 +152,11 @@ function mapVesselTypeToKindCode(vesselType) {
|
|||||||
/**
|
/**
|
||||||
* ISO 타임스탬프 → "YYYYMMDDHHmmss" 형식 변환
|
* ISO 타임스탬프 → "YYYYMMDDHHmmss" 형식 변환
|
||||||
*/
|
*/
|
||||||
function formatTimestamp(isoString) {
|
function formatTimestamp(isoString: string | undefined): string {
|
||||||
if (!isoString) return '';
|
if (!isoString) return '';
|
||||||
try {
|
try {
|
||||||
const d = new Date(isoString);
|
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())}`;
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
@ -5,13 +5,20 @@ import { fetchWithAuth } from './fetchWithAuth';
|
|||||||
|
|
||||||
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
|
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
|
||||||
|
|
||||||
|
/** 공통코드 아이템 인터페이스 */
|
||||||
|
export interface CommonCodeItem {
|
||||||
|
commonCodeTypeName: string;
|
||||||
|
commonCodeTypeNumber: string;
|
||||||
|
commonCodeEtc: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공통코드 목록 조회
|
* 공통코드 목록 조회
|
||||||
*
|
*
|
||||||
* @param {string} commonCodeTypeNumber - 공통코드 유형 번호
|
* @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 {
|
try {
|
||||||
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
|
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -1,10 +1,22 @@
|
|||||||
import { fetchWithAuth } from './fetchWithAuth';
|
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');
|
const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search');
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const result = await response.json();
|
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', {
|
const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -7,7 +7,7 @@ import { SESSION_TIMEOUT_MS } from '../types/constants';
|
|||||||
* - 사후 체크: 4011 응답 감지 (세션 만료)
|
* - 사후 체크: 4011 응답 감지 (세션 만료)
|
||||||
* - credentials: 'include' 자동 설정
|
* - 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') {
|
if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') {
|
||||||
const loginDate = localStorage.getItem('loginDate');
|
const loginDate = localStorage.getItem('loginDate');
|
||||||
@ -32,7 +32,7 @@ export async function fetchWithAuth(url, options = {}) {
|
|||||||
throw new Error('Session expired (4011)');
|
throw new Error('Session expired (4011)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 { parsePipeMessage, rowToShipObject } from '../common/stompClient';
|
||||||
|
import type { ShipFeature } from '../types/ship';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 12분 이내 전체 선박 신호 조회
|
* 12분 이내 전체 선박 신호 조회
|
||||||
* STOMP 구독 전에 호출하여 초기 선박 데이터 로드
|
* STOMP 구독 전에 호출하여 초기 선박 데이터 로드
|
||||||
*
|
*
|
||||||
* @returns {Promise<Array>} 선박 데이터 배열
|
* @returns {Promise<ShipFeature[]>} 선박 데이터 배열
|
||||||
*/
|
*/
|
||||||
export async function fetchAllSignals() {
|
export async function fetchAllSignals(): Promise<ShipFeature[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/signal-api/all/12');
|
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가 문자열이면 파이프로 파싱, 배열이면 그대로 사용
|
// row가 문자열이면 파이프로 파싱, 배열이면 그대로 사용
|
||||||
const parsed = typeof row === 'string' ? parsePipeMessage(row) : 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[]>} 파이프 구분 문자열 배열
|
* @returns {Promise<string[]>} 파이프 구분 문자열 배열
|
||||||
*/
|
*/
|
||||||
export async function fetchAllSignalsRaw() {
|
export async function fetchAllSignalsRaw(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/signal-api/all/12');
|
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') {
|
if (typeof row === 'string') {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@ -81,7 +82,7 @@ export async function fetchAllSignalsRaw() {
|
|||||||
return row.join('|');
|
return row.join('|');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}).filter(line => line.trim());
|
}).filter((line: string) => line.trim());
|
||||||
|
|
||||||
console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`);
|
console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`);
|
||||||
|
|
||||||
@ -9,21 +9,73 @@
|
|||||||
*/
|
*/
|
||||||
import useShipStore from '../stores/shipStore';
|
import useShipStore from '../stores/shipStore';
|
||||||
import { fetchWithAuth } from './fetchWithAuth';
|
import { fetchWithAuth } from './fetchWithAuth';
|
||||||
|
import type { ShipFeature } from '../types/ship';
|
||||||
|
|
||||||
/** API 엔드포인트 (메인 프로젝트와 동일) */
|
/** API 엔드포인트 (메인 프로젝트와 동일) */
|
||||||
const API_ENDPOINT = '/api/v2/tracks/vessels';
|
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 {TrackQueryParams} params
|
||||||
* @param {string} params.startTime - 조회 시작 시간 (ISO 8601, e.g. '2026-01-01T00:00:00')
|
* @returns {Promise<ProcessedTrack[]>} ProcessedTrack 배열
|
||||||
* @param {string} params.endTime - 조회 종료 시간 (ISO 8601)
|
|
||||||
* @param {Array<{ sigSrcCd: string, targetId: string }>} params.vessels - 조회 대상 선박
|
|
||||||
* @param {boolean} [params.isIntegration=false] - 통합 조회 여부
|
|
||||||
* @returns {Promise<Array>} ProcessedTrack 배열
|
|
||||||
*/
|
*/
|
||||||
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) {
|
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }: TrackQueryParams): Promise<ProcessedTrack[]> {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
startTime,
|
startTime,
|
||||||
@ -45,7 +97,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// v2 API는 배열을 직접 반환
|
// v2 API는 배열을 직접 반환
|
||||||
const rawTracks = Array.isArray(result) ? result : (result?.data || []);
|
const rawTracks: RawTrack[] = Array.isArray(result) ? result : (result?.data || []);
|
||||||
|
|
||||||
if (!Array.isArray(rawTracks)) {
|
if (!Array.isArray(rawTracks)) {
|
||||||
console.warn('[fetchTrackQuery] Invalid response format:', result);
|
console.warn('[fetchTrackQuery] Invalid response format:', result);
|
||||||
@ -55,7 +107,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
|
|||||||
// 가공: CompactVesselTrack → ProcessedTrack
|
// 가공: CompactVesselTrack → ProcessedTrack
|
||||||
const processed = rawTracks
|
const processed = rawTracks
|
||||||
.map((raw) => processTrack(raw))
|
.map((raw) => processTrack(raw))
|
||||||
.filter((t) => t !== null);
|
.filter((t): t is ProcessedTrack => t !== null);
|
||||||
|
|
||||||
console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`);
|
console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`);
|
||||||
return processed;
|
return processed;
|
||||||
@ -69,10 +121,10 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
|
|||||||
* API 응답 데이터를 ProcessedTrack으로 변환
|
* API 응답 데이터를 ProcessedTrack으로 변환
|
||||||
* 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks
|
* 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks
|
||||||
*
|
*
|
||||||
* @param {Object} raw - API 응답의 개별 항적 데이터
|
* @param {RawTrack} raw - API 응답의 개별 항적 데이터
|
||||||
* @returns {Object|null} ProcessedTrack
|
* @returns {ProcessedTrack|null} ProcessedTrack
|
||||||
*/
|
*/
|
||||||
function processTrack(raw) {
|
function processTrack(raw: RawTrack): ProcessedTrack | null {
|
||||||
if (!raw || !raw.geometry || raw.geometry.length === 0) return null;
|
if (!raw || !raw.geometry || raw.geometry.length === 0) return null;
|
||||||
|
|
||||||
const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`;
|
const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`;
|
||||||
@ -115,11 +167,11 @@ function processTrack(raw) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 실시간 선박 데이터에서 매칭되는 선박 찾기
|
* 실시간 선박 데이터에서 매칭되는 선박 찾기
|
||||||
* @param {string} targetId
|
* @param {string|undefined} targetId
|
||||||
* @param {string} sigSrcCd
|
* @param {string|undefined} sigSrcCd
|
||||||
* @returns {Object|null}
|
* @returns {ShipFeature|null}
|
||||||
*/
|
*/
|
||||||
function findLiveShipData(targetId, sigSrcCd) {
|
function findLiveShipData(targetId: string | undefined, sigSrcCd: string | undefined): ShipFeature | null {
|
||||||
if (!targetId) return null;
|
if (!targetId) return null;
|
||||||
|
|
||||||
const features = useShipStore.getState().features;
|
const features = useShipStore.getState().features;
|
||||||
@ -132,7 +184,7 @@ function findLiveShipData(targetId, sigSrcCd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// featureId로 못 찾으면 originalTargetId로 검색
|
// featureId로 못 찾으면 originalTargetId로 검색
|
||||||
let found = null;
|
let found: ShipFeature | null = null;
|
||||||
features.forEach((ship) => {
|
features.forEach((ship) => {
|
||||||
if (ship.originalTargetId === targetId) {
|
if (ship.originalTargetId === targetId) {
|
||||||
found = ship;
|
found = ship;
|
||||||
@ -144,10 +196,10 @@ function findLiveShipData(targetId, sigSrcCd) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 객체에서 항적 조회용 파라미터 추출
|
* 선박 객체에서 항적 조회용 파라미터 추출
|
||||||
* @param {Object} ship - shipStore의 선박 데이터
|
* @param {ShipFeature} ship - shipStore의 선박 데이터
|
||||||
* @returns {{ sigSrcCd: string, targetId: string }}
|
* @returns {VesselIdentifier}
|
||||||
*/
|
*/
|
||||||
export function extractVesselIdentifier(ship) {
|
export function extractVesselIdentifier(ship: ShipFeature): VesselIdentifier {
|
||||||
return {
|
return {
|
||||||
sigSrcCd: ship.signalSourceCode || '',
|
sigSrcCd: ship.signalSourceCode || '',
|
||||||
targetId: ship.originalTargetId || ship.targetId || '',
|
targetId: ship.originalTargetId || ship.targetId || '',
|
||||||
@ -159,8 +211,8 @@ export function extractVesselIdentifier(ship) {
|
|||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
* @returns {string} 'YYYY-MM-DDTHH:mm:ss'
|
* @returns {string} 'YYYY-MM-DDTHH:mm:ss'
|
||||||
*/
|
*/
|
||||||
export function toLocalISOString(date) {
|
export function toLocalISOString(date: Date): string {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
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())}`;
|
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이면 해당 장비 없음
|
* 각 위치에 00000이면 해당 장비 없음
|
||||||
*
|
*
|
||||||
* @param {string} targetId - 통합 TARGET_ID
|
* @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 [];
|
if (!targetId) return [];
|
||||||
|
|
||||||
const parts = targetId.split('_');
|
const parts = targetId.split('_');
|
||||||
// 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF
|
// 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF
|
||||||
const equipmentMap = [
|
const equipmentMap: { sigSrcCd: string; index: number }[] = [
|
||||||
{ sigSrcCd: '000001', index: 0 }, // AIS
|
{ sigSrcCd: '000001', index: 0 }, // AIS
|
||||||
{ sigSrcCd: '000003', index: 1 }, // VPASS
|
{ sigSrcCd: '000003', index: 1 }, // VPASS
|
||||||
{ sigSrcCd: '000002', index: 2 }, // ENAV
|
{ sigSrcCd: '000002', index: 2 }, // ENAV
|
||||||
@ -185,7 +237,7 @@ export function parseIntegratedTargetId(targetId) {
|
|||||||
{ sigSrcCd: '000016', index: 4 }, // D_MF_HF
|
{ sigSrcCd: '000016', index: 4 }, // D_MF_HF
|
||||||
];
|
];
|
||||||
|
|
||||||
const vessels = [];
|
const vessels: VesselIdentifier[] = [];
|
||||||
equipmentMap.forEach(({ sigSrcCd, index }) => {
|
equipmentMap.forEach(({ sigSrcCd, index }) => {
|
||||||
const id = parts[index];
|
const id = parts[index];
|
||||||
if (id && id !== '00000' && id !== '0' && id !== '') {
|
if (id && id !== '00000' && id !== '0' && id !== '') {
|
||||||
@ -201,10 +253,10 @@ export function parseIntegratedTargetId(targetId) {
|
|||||||
* 통합선박: TARGET_ID 파싱 → 모든 장비 (레이더 제외)
|
* 통합선박: TARGET_ID 파싱 → 모든 장비 (레이더 제외)
|
||||||
* 단일선박: 기본 identifier 반환
|
* 단일선박: 기본 identifier 반환
|
||||||
*
|
*
|
||||||
* @param {Object} ship - shipStore 선박 데이터
|
* @param {ShipFeature} ship - shipStore 선박 데이터
|
||||||
* @returns {Array<{ sigSrcCd: string, targetId: string }>}
|
* @returns {VesselIdentifier[]}
|
||||||
*/
|
*/
|
||||||
export function buildVesselListForQuery(ship) {
|
export function buildVesselListForQuery(ship: ShipFeature): VesselIdentifier[] {
|
||||||
if (ship.integrate && ship.targetId && ship.targetId.includes('_')) {
|
if (ship.integrate && ship.targetId && ship.targetId.includes('_')) {
|
||||||
return parseIntegratedTargetId(ship.targetId);
|
return parseIntegratedTargetId(ship.targetId);
|
||||||
}
|
}
|
||||||
@ -4,11 +4,24 @@ import { USER_SETTING_FILTER } from '../types/constants';
|
|||||||
const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search';
|
const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search';
|
||||||
const SAVE_ENDPOINT = '/api/cmn/personal/settings/save';
|
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 url = `${SEARCH_ENDPOINT}?type=${USER_SETTING_FILTER}`;
|
||||||
const response = await fetchWithAuth(url);
|
const response = await fetchWithAuth(url);
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
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, {
|
const response = await fetchWithAuth(SAVE_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -6,6 +6,8 @@ import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'
|
|||||||
import { fetchAreaSearch } from '../services/areaSearchApi';
|
import { fetchAreaSearch } from '../services/areaSearchApi';
|
||||||
import { fetchVesselContacts } from '../services/stsApi';
|
import { fetchVesselContacts } from '../services/stsApi';
|
||||||
import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types';
|
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 { showToast } from '../../components/common/Toast';
|
||||||
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
|
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
|
||||||
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
|
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
|
||||||
@ -16,12 +18,17 @@ import StsAnalysisTab from './StsAnalysisTab';
|
|||||||
|
|
||||||
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
|
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function toKstISOString(date) {
|
function toKstISOString(date: Date): string {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
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())}`;
|
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 [startDate, setStartDate] = useState('');
|
||||||
const [startTime, setStartTime] = useState('00:00');
|
const [startTime, setStartTime] = useState('00:00');
|
||||||
const [endDate, setEndDate] = useState('');
|
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;
|
if (newTab === activeTab) return;
|
||||||
|
|
||||||
const areaState = useAreaSearchStore.getState();
|
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);
|
setStartDate(newStartDate);
|
||||||
const start = new Date(`${newStartDate}T${startTime}:00`);
|
const start = new Date(`${newStartDate}T${startTime}:00`);
|
||||||
const end = new Date(`${endDate}T${endTime}:00`);
|
const end = new Date(`${endDate}T${endTime}:00`);
|
||||||
const diffDays = (end - start) / DAYS_TO_MS;
|
const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS;
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
if (diffDays < 0) {
|
if (diffDays < 0) {
|
||||||
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
|
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]);
|
}, [startTime, endDate, endTime]);
|
||||||
|
|
||||||
const handleEndDateChange = useCallback((newEndDate) => {
|
const handleEndDateChange = useCallback((newEndDate: string) => {
|
||||||
setEndDate(newEndDate);
|
setEndDate(newEndDate);
|
||||||
const start = new Date(`${startDate}T${startTime}:00`);
|
const start = new Date(`${startDate}T${startTime}:00`);
|
||||||
const end = new Date(`${newEndDate}T${endTime}:00`);
|
const end = new Date(`${newEndDate}T${endTime}:00`);
|
||||||
const diffDays = (end - start) / DAYS_TO_MS;
|
const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS;
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
if (diffDays < 0) {
|
if (diffDays < 0) {
|
||||||
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
|
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
|
||||||
@ -152,7 +159,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
|||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
useAreaSearchStore.getState().setLoading(true);
|
useAreaSearchStore.getState().setLoading(true);
|
||||||
|
|
||||||
const polygons = zones.map((z) => ({
|
const polygons = zones.map((z: Zone) => ({
|
||||||
id: z.id,
|
id: z.id,
|
||||||
name: z.name,
|
name: z.name,
|
||||||
coordinates: z.coordinates,
|
coordinates: z.coordinates,
|
||||||
@ -177,7 +184,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
|||||||
|
|
||||||
let minTime = Infinity;
|
let minTime = Infinity;
|
||||||
let maxTime = -Infinity;
|
let maxTime = -Infinity;
|
||||||
result.tracks.forEach((t) => {
|
result.tracks.forEach((t: ProcessedTrack) => {
|
||||||
if (t.timestampsMs.length > 0) {
|
if (t.timestampsMs.length > 0) {
|
||||||
minTime = Math.min(minTime, t.timestampsMs[0]);
|
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||||
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||||
@ -190,7 +197,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AreaSearch] 조회 실패:', error);
|
console.error('[AreaSearch] 조회 실패:', error);
|
||||||
useAreaSearchStore.getState().setLoading(false);
|
useAreaSearchStore.getState().setLoading(false);
|
||||||
setErrorMessage(`조회 실패: ${error.message}`);
|
setErrorMessage(`조회 실패: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
||||||
|
|
||||||
@ -228,7 +235,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
|||||||
|
|
||||||
let minTime = Infinity;
|
let minTime = Infinity;
|
||||||
let maxTime = -Infinity;
|
let maxTime = -Infinity;
|
||||||
result.tracks.forEach((t) => {
|
result.tracks.forEach((t: ProcessedTrack) => {
|
||||||
if (t.timestampsMs.length > 0) {
|
if (t.timestampsMs.length > 0) {
|
||||||
minTime = Math.min(minTime, t.timestampsMs[0]);
|
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||||
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||||
@ -241,7 +248,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[STS] 조회 실패:', error);
|
console.error('[STS] 조회 실패:', error);
|
||||||
useStsStore.getState().setLoading(false);
|
useStsStore.getState().setLoading(false);
|
||||||
setErrorMessage(`조회 실패: ${error.message}`);
|
setErrorMessage(`조회 실패: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
||||||
|
|
||||||
@ -12,13 +12,20 @@ import { useAreaSearchStore } from '../stores/areaSearchStore';
|
|||||||
import {
|
import {
|
||||||
SEARCH_MODE_LABELS,
|
SEARCH_MODE_LABELS,
|
||||||
} from '../types/areaSearch.types';
|
} 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 { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
import ZoneDrawPanel from './ZoneDrawPanel';
|
import ZoneDrawPanel from './ZoneDrawPanel';
|
||||||
import VesselDetailModal from './VesselDetailModal';
|
import VesselDetailModal from './VesselDetailModal';
|
||||||
import { exportSearchResultToCSV } from '../utils/csvExport';
|
import { exportSearchResultToCSV } from '../utils/csvExport';
|
||||||
|
|
||||||
export default function AreaSearchTab({ isLoading, errorMessage }) {
|
interface AreaSearchTabProps {
|
||||||
const [detailVesselId, setDetailVesselId] = useState(null);
|
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 zones = useAreaSearchStore((s) => s.zones);
|
||||||
const searchMode = useAreaSearchStore((s) => s.searchMode);
|
const searchMode = useAreaSearchStore((s) => s.searchMode);
|
||||||
@ -30,11 +37,11 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
|
|||||||
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
|
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
|
||||||
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
|
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
|
||||||
|
|
||||||
const handleToggleVessel = useCallback((vesselId) => {
|
const handleToggleVessel = useCallback((vesselId: string) => {
|
||||||
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
|
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHighlightVessel = useCallback((vesselId) => {
|
const handleHighlightVessel = useCallback((vesselId: string | null) => {
|
||||||
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -42,7 +49,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
|
|||||||
exportSearchResultToCSV(tracks, hitDetails, zones);
|
exportSearchResultToCSV(tracks, hitDetails, zones);
|
||||||
}, [tracks, hitDetails, zones]);
|
}, [tracks, hitDetails, zones]);
|
||||||
|
|
||||||
const listRef = useRef(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightedVesselId || !listRef.current) return;
|
if (!highlightedVesselId || !listRef.current) return;
|
||||||
@ -73,7 +80,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
|
|||||||
checked={searchMode === mode}
|
checked={searchMode === mode}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
|
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
|
||||||
setSearchMode(mode);
|
setSearchMode(mode as SearchMode);
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@ -105,7 +112,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="vessel-list" ref={listRef}>
|
<ul className="vessel-list" ref={listRef}>
|
||||||
{tracks.map((track) => {
|
{tracks.map((track: ProcessedTrack) => {
|
||||||
const isDisabled = disabledVesselIds.has(track.vesselId);
|
const isDisabled = disabledVesselIds.has(track.vesselId);
|
||||||
const isHighlighted = highlightedVesselId === track.vesselId;
|
const isHighlighted = highlightedVesselId === track.vesselId;
|
||||||
const color = getShipKindColor(track.shipKindCode);
|
const color = getShipKindColor(track.shipKindCode);
|
||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 항적분석 타임라인 재생 컨트롤
|
* 항적분석 타임라인 재생 컨트롤
|
||||||
* 참조: src/replay/components/ReplayTimeline.jsx (간소화)
|
* 참조: src/replay/components/ReplayTimeline.tsx (간소화)
|
||||||
*
|
*
|
||||||
* - 재생/일시정지/정지
|
* - 재생/일시정지/정지
|
||||||
* - 배속 조절 (1x ~ 1000x)
|
* - 배속 조절 (1x ~ 1000x)
|
||||||
* - 프로그레스 바 (range slider)
|
* - 프로그레스 바 (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 { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { useStsStore } from '../stores/stsStore';
|
import { useStsStore } from '../stores/stsStore';
|
||||||
@ -21,10 +21,10 @@ import './AreaSearchTimeline.scss';
|
|||||||
const PATH_LABEL = '항적';
|
const PATH_LABEL = '항적';
|
||||||
const TRAIL_LABEL = '궤적';
|
const TRAIL_LABEL = '궤적';
|
||||||
|
|
||||||
function formatDateTime(ms) {
|
function formatDateTime(ms: number): string {
|
||||||
if (!ms || ms <= 0) return '--:--:--';
|
if (!ms || ms <= 0) return '--:--:--';
|
||||||
const d = new Date(ms);
|
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())}`;
|
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]);
|
}, [currentTime, startTime, endTime]);
|
||||||
|
|
||||||
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||||
const speedMenuRef = useRef(null);
|
const speedMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 드래그
|
// 드래그
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [hasDragged, setHasDragged] = useState(false);
|
const [hasDragged, setHasDragged] = useState(false);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) {
|
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) {
|
||||||
setShowSpeedMenu(false);
|
setShowSpeedMenu(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -87,7 +87,7 @@ export default function AreaSearchTimeline() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [showSpeedMenu]);
|
}, [showSpeedMenu]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const parent = containerRef.current.parentElement;
|
const parent = containerRef.current.parentElement;
|
||||||
@ -103,7 +103,7 @@ export default function AreaSearchTimeline() {
|
|||||||
}, [hasDragged]);
|
}, [hasDragged]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isDragging || !containerRef.current) return;
|
if (!isDragging || !containerRef.current) return;
|
||||||
const parent = containerRef.current.parentElement;
|
const parent = containerRef.current.parentElement;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
@ -135,12 +135,12 @@ export default function AreaSearchTimeline() {
|
|||||||
|
|
||||||
const handleStop = useCallback(() => { stop(); }, [stop]);
|
const handleStop = useCallback(() => { stop(); }, [stop]);
|
||||||
|
|
||||||
const handleSpeedChange = useCallback((speed) => {
|
const handleSpeedChange = useCallback((speed: number) => {
|
||||||
setPlaybackSpeed(speed);
|
setPlaybackSpeed(speed);
|
||||||
setShowSpeedMenu(false);
|
setShowSpeedMenu(false);
|
||||||
}, [setPlaybackSpeed]);
|
}, [setPlaybackSpeed]);
|
||||||
|
|
||||||
const handleSliderChange = useCallback((e) => {
|
const handleSliderChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setCurrentTime(parseFloat(e.target.value));
|
setCurrentTime(parseFloat(e.target.value));
|
||||||
}, [setCurrentTime]);
|
}, [setCurrentTime]);
|
||||||
|
|
||||||
@ -232,7 +232,7 @@ export default function AreaSearchTimeline() {
|
|||||||
value={currentTime}
|
value={currentTime}
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
disabled={!hasData}
|
disabled={!hasData}
|
||||||
style={{ '--progress': `${progress}%` }}
|
style={{ '--progress': `${progress}%` } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -6,26 +6,28 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { ZONE_COLORS } from '../types/areaSearch.types';
|
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 { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
import './AreaSearchTooltip.scss';
|
import './AreaSearchTooltip.scss';
|
||||||
|
|
||||||
const OFFSET_X = 14;
|
const OFFSET_X = 14;
|
||||||
const OFFSET_Y = -20;
|
const OFFSET_Y = -20;
|
||||||
|
|
||||||
/** nationalCode → 국기 SVG URL */
|
/** nationalCode -> 국기 SVG URL */
|
||||||
function getNationalFlagUrl(nationalCode) {
|
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
|
||||||
if (!nationalCode) return null;
|
if (!nationalCode) return null;
|
||||||
return `/ship/image/small/${nationalCode}.svg`;
|
return `/ship/image/small/${nationalCode}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTimestamp(ms) {
|
export function formatTimestamp(ms: number | null | undefined): string {
|
||||||
if (!ms) return '-';
|
if (!ms) return '-';
|
||||||
const d = new Date(ms);
|
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())}`;
|
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;
|
if (!pos || pos.length < 2) return null;
|
||||||
const lon = pos[0];
|
const lon = pos[0];
|
||||||
const lat = pos[1];
|
const lat = pos[1];
|
||||||
@ -41,8 +43,8 @@ export default function AreaSearchTooltip() {
|
|||||||
const zones = useAreaSearchStore((s) => s.zones);
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
|
||||||
const zoneMap = useMemo(() => {
|
const zoneMap = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map<string | number, Zone>();
|
||||||
zones.forEach((z, idx) => {
|
zones.forEach((z: Zone, idx: number) => {
|
||||||
map.set(z.id, z);
|
map.set(z.id, z);
|
||||||
map.set(z.name, z);
|
map.set(z.name, z);
|
||||||
map.set(idx, z);
|
map.set(idx, z);
|
||||||
@ -54,16 +56,16 @@ export default function AreaSearchTooltip() {
|
|||||||
if (!tooltip) return null;
|
if (!tooltip) return null;
|
||||||
|
|
||||||
const { vesselId, x, y } = tooltip;
|
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;
|
if (!track) return null;
|
||||||
|
|
||||||
const hits = hitDetails[vesselId] || [];
|
const hits: HitDetail[] = hitDetails[vesselId] || [];
|
||||||
const kindName = getShipKindName(track.shipKindCode);
|
const kindName = getShipKindName(track.shipKindCode);
|
||||||
const sourceName = getSignalSourceName(track.sigSrcCd);
|
const sourceName = getSignalSourceName(track.sigSrcCd);
|
||||||
const flagUrl = getNationalFlagUrl(track.nationalCode);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -77,7 +79,7 @@ export default function AreaSearchTooltip() {
|
|||||||
<img
|
<img
|
||||||
src={flagUrl}
|
src={flagUrl}
|
||||||
alt="국기"
|
alt="국기"
|
||||||
onError={(e) => { e.target.style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -5,33 +5,37 @@
|
|||||||
* - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리)
|
* - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리)
|
||||||
* - 결과: StsContactList
|
* - 결과: StsContactList
|
||||||
*/
|
*/
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState, ChangeEvent } from 'react';
|
||||||
import './StsAnalysisTab.scss';
|
import './StsAnalysisTab.scss';
|
||||||
import { useStsStore } from '../stores/stsStore';
|
import { useStsStore } from '../stores/stsStore';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
|
||||||
import { STS_LIMITS } from '../types/sts.types';
|
import { STS_LIMITS } from '../types/sts.types';
|
||||||
import ZoneDrawPanel from './ZoneDrawPanel';
|
import ZoneDrawPanel from './ZoneDrawPanel';
|
||||||
import StsContactList from './StsContactList';
|
import StsContactList from './StsContactList';
|
||||||
import StsContactDetailModal from './StsContactDetailModal';
|
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 queryCompleted = useStsStore((s) => s.queryCompleted);
|
||||||
const groupedContacts = useStsStore((s) => s.groupedContacts);
|
const groupedContacts = useStsStore((s) => s.groupedContacts);
|
||||||
const summary = useStsStore((s) => s.summary);
|
const summary = useStsStore((s) => s.summary);
|
||||||
const minContactDuration = useStsStore((s) => s.minContactDurationMinutes);
|
const minContactDuration = useStsStore((s) => s.minContactDurationMinutes);
|
||||||
const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters);
|
const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters);
|
||||||
|
|
||||||
const handleDurationChange = useCallback((e) => {
|
const handleDurationChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
useStsStore.getState().setMinContactDuration(Number(e.target.value));
|
useStsStore.getState().setMinContactDuration(Number(e.target.value));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDistanceChange = useCallback((e) => {
|
const handleDistanceChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
useStsStore.getState().setMaxContactDistance(Number(e.target.value));
|
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);
|
setDetailGroupIndex(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* STS 접촉 쌍 상세 모달 — 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장
|
* STS 접촉 쌍 상세 모달 -- 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장
|
||||||
* 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시
|
* 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import Map from 'ol/Map';
|
import OlMap from 'ol/Map';
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import { XYZ } from 'ol/source';
|
import { XYZ } from 'ol/source';
|
||||||
import TileLayer from 'ol/layer/Tile';
|
import TileLayer from 'ol/layer/Tile';
|
||||||
@ -21,6 +21,9 @@ import html2canvas from 'html2canvas';
|
|||||||
import { useStsStore } from '../stores/stsStore';
|
import { useStsStore } from '../stores/stsStore';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { ZONE_COLORS } from '../types/areaSearch.types';
|
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 { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
||||||
import {
|
import {
|
||||||
@ -32,13 +35,13 @@ import {
|
|||||||
import { mapLayerConfig } from '../../map/layers/baseLayer';
|
import { mapLayerConfig } from '../../map/layers/baseLayer';
|
||||||
import './StsContactDetailModal.scss';
|
import './StsContactDetailModal.scss';
|
||||||
|
|
||||||
function getNationalFlagUrl(nationalCode) {
|
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
|
||||||
if (!nationalCode) return null;
|
if (!nationalCode) return null;
|
||||||
return `/ship/image/small/${nationalCode}.svg`;
|
return `/ship/image/small/${nationalCode}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createZoneFeatures(zones) {
|
function createZoneFeatures(zones: Zone[]): Feature[] {
|
||||||
const features = [];
|
const features: Feature[] = [];
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone) => {
|
||||||
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
|
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
|
||||||
const polygon = new Polygon([coords3857]);
|
const polygon = new Polygon([coords3857]);
|
||||||
@ -68,7 +71,7 @@ function createZoneFeatures(zones) {
|
|||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTrackFeature(track) {
|
function createTrackFeature(track: ProcessedTrack): Feature {
|
||||||
const coords3857 = track.geometry.map((c) => fromLonLat(c));
|
const coords3857 = track.geometry.map((c) => fromLonLat(c));
|
||||||
const line = new LineString(coords3857);
|
const line = new LineString(coords3857);
|
||||||
const feature = new Feature({ geometry: line });
|
const feature = new Feature({ geometry: line });
|
||||||
@ -82,14 +85,14 @@ function createTrackFeature(track) {
|
|||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContactMarkers(contacts) {
|
function createContactMarkers(contacts: StsContact[]): Feature[] {
|
||||||
const features = [];
|
const features: Feature[] = [];
|
||||||
|
|
||||||
contacts.forEach((contact, idx) => {
|
contacts.forEach((contact, idx) => {
|
||||||
if (!contact.contactCenterPoint) return;
|
if (!contact.contactCenterPoint) return;
|
||||||
|
|
||||||
const pos3857 = fromLonLat(contact.contactCenterPoint);
|
const pos3857 = fromLonLat(contact.contactCenterPoint);
|
||||||
const riskColor = getContactRiskColor(contact.indicators);
|
const riskColor = getContactRiskColor(contact.indicators ?? null);
|
||||||
|
|
||||||
const f = new Feature({ geometry: new Point(pos3857) });
|
const f = new Feature({ geometry: new Point(pos3857) });
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
@ -131,14 +134,19 @@ function createContactMarkers(contacts) {
|
|||||||
const MODAL_WIDTH = 680;
|
const MODAL_WIDTH = 680;
|
||||||
const MODAL_APPROX_HEIGHT = 780;
|
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 groupedContacts = useStsStore((s) => s.groupedContacts);
|
||||||
const tracks = useStsStore((s) => s.tracks);
|
const tracks = useStsStore((s) => s.tracks);
|
||||||
const zones = useAreaSearchStore((s) => s.zones);
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
|
||||||
const mapContainerRef = useRef(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef<OlMap | null>(null);
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [position, setPosition] = useState(() => ({
|
const [position, setPosition] = useState(() => ({
|
||||||
x: (window.innerWidth - MODAL_WIDTH) / 2,
|
x: (window.innerWidth - MODAL_WIDTH) / 2,
|
||||||
@ -148,7 +156,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const dragStart = useRef({ x: 0, y: 0 });
|
const dragStart = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
dragStart.current = {
|
dragStart.current = {
|
||||||
x: e.clientX - posRef.current.x,
|
x: e.clientX - posRef.current.x,
|
||||||
@ -158,7 +166,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!dragging.current) return;
|
if (!dragging.current) return;
|
||||||
const newPos = {
|
const newPos = {
|
||||||
x: e.clientX - dragStart.current.x,
|
x: e.clientX - dragStart.current.x,
|
||||||
@ -180,11 +188,11 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
|
|
||||||
const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]);
|
const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]);
|
||||||
const vessel1Track = useMemo(
|
const vessel1Track = useMemo(
|
||||||
() => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId),
|
() => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel1?.vesselId),
|
||||||
[tracks, group],
|
[tracks, group],
|
||||||
);
|
);
|
||||||
const vessel2Track = useMemo(
|
const vessel2Track = useMemo(
|
||||||
() => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId),
|
() => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel2?.vesselId),
|
||||||
[tracks, group],
|
[tracks, group],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -193,7 +201,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return;
|
if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return;
|
||||||
|
|
||||||
const tileSource = new XYZ({
|
const tileSource = new XYZ({
|
||||||
url: mapLayerConfig.darkLayer.source.getUrls()[0],
|
url: mapLayerConfig.darkLayer.source.getUrls()![0],
|
||||||
minZoom: 6,
|
minZoom: 6,
|
||||||
maxZoom: 11,
|
maxZoom: 11,
|
||||||
});
|
});
|
||||||
@ -211,7 +219,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
const markerSource = new VectorSource({ features: markerFeatures });
|
const markerSource = new VectorSource({ features: markerFeatures });
|
||||||
const markerLayer = new VectorLayer({ source: markerSource });
|
const markerLayer = new VectorLayer({ source: markerSource });
|
||||||
|
|
||||||
const map = new Map({
|
const map = new OlMap({
|
||||||
target: mapContainerRef.current,
|
target: mapContainerRef.current,
|
||||||
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
|
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
|
||||||
view: new View({ center: [0, 0], zoom: 7 }),
|
view: new View({ center: [0, 0], zoom: 7 }),
|
||||||
@ -230,7 +238,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.setTarget(null);
|
map.setTarget(undefined);
|
||||||
map.dispose();
|
map.dispose();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
@ -240,7 +248,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const modal = el.parentElement;
|
const modal = el.parentElement as HTMLElement;
|
||||||
const saved = {
|
const saved = {
|
||||||
elOverflow: el.style.overflow,
|
elOverflow: el.style.overflow,
|
||||||
modalMaxHeight: modal.style.maxHeight,
|
modalMaxHeight: modal.style.maxHeight,
|
||||||
@ -261,7 +269,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
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 now = new Date();
|
||||||
const v1Name = group?.vessel1?.vesselName || 'V1';
|
const v1Name = group?.vessel1?.vesselName || 'V1';
|
||||||
const v2Name = group?.vessel2?.vesselName || 'V2';
|
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__header" onMouseDown={handleMouseDown}>
|
||||||
<div className="sts-detail-modal__title">
|
<div className="sts-detail-modal__title">
|
||||||
<VesselBadge vessel={vessel1} track={vessel1Track} />
|
<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} />
|
<VesselBadge vessel={vessel2} track={vessel2Track} />
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="sts-detail-modal__close" onClick={onClose}>
|
<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(',')})` }}
|
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 접촉 요약 — 그리드 2열 */}
|
{/* 접촉 요약 -- 그리드 2열 */}
|
||||||
<div className="sts-detail-modal__section">
|
<div className="sts-detail-modal__section">
|
||||||
<h4 className="sts-detail-modal__section-title">접촉 요약</h4>
|
<h4 className="sts-detail-modal__section-title">접촉 요약</h4>
|
||||||
<div className="sts-detail-modal__summary-grid">
|
<div className="sts-detail-modal__summary-grid">
|
||||||
@ -362,7 +370,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
<div className="sts-detail-modal__section">
|
<div className="sts-detail-modal__section">
|
||||||
<h4 className="sts-detail-modal__section-title">접촉 이력 ({group.contacts.length}회)</h4>
|
<h4 className="sts-detail-modal__section-title">접촉 이력 ({group.contacts.length}회)</h4>
|
||||||
<div className="sts-detail-modal__contact-list">
|
<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">
|
<div key={ci} className="sts-detail-modal__contact-item">
|
||||||
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
|
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
|
||||||
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
|
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
|
||||||
@ -376,7 +384,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 거리 통계 — 3열 그리드 */}
|
{/* 거리 통계 -- 3열 그리드 */}
|
||||||
<div className="sts-detail-modal__section">
|
<div className="sts-detail-modal__section">
|
||||||
<h4 className="sts-detail-modal__section-title">거리 통계</h4>
|
<h4 className="sts-detail-modal__section-title">거리 통계</h4>
|
||||||
<div className="sts-detail-modal__stats-grid">
|
<div className="sts-detail-modal__stats-grid">
|
||||||
@ -407,7 +415,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선박 상세 — 2열 그리드 */}
|
{/* 선박 상세 -- 2열 그리드 */}
|
||||||
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
|
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
|
||||||
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
|
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
|
||||||
</div>
|
</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 kindName = getShipKindName(track.shipKindCode);
|
||||||
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
|
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
|
||||||
return (
|
return (
|
||||||
@ -434,7 +447,7 @@ function VesselBadge({ vessel, track }) {
|
|||||||
className="sts-detail-modal__flag"
|
className="sts-detail-modal__flag"
|
||||||
src={flagUrl}
|
src={flagUrl}
|
||||||
alt=""
|
alt=""
|
||||||
onError={(e) => { e.target.style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="sts-detail-modal__name">
|
<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 kindName = getShipKindName(track.shipKindCode);
|
||||||
const sourceName = getSignalSourceName(track.sigSrcCd);
|
const sourceName = getSignalSourceName(track.sigSrcCd);
|
||||||
const color = getShipKindColor(track.shipKindCode);
|
const color = getShipKindColor(track.shipKindCode);
|
||||||
@ -456,7 +475,7 @@ function VesselDetailSection({ label, vessel, track }) {
|
|||||||
className="sts-detail-modal__track-dot"
|
className="sts-detail-modal__track-dot"
|
||||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||||
/>
|
/>
|
||||||
{label} — {vessel.vesselName || vessel.vesselId}
|
{label} -- {vessel.vesselName || vessel.vesselId}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="sts-detail-modal__vessel-grid">
|
<div className="sts-detail-modal__vessel-grid">
|
||||||
<div className="sts-detail-modal__vessel-grid-item">
|
<div className="sts-detail-modal__vessel-grid-item">
|
||||||
@ -2,15 +2,16 @@
|
|||||||
* STS 접촉 쌍 결과 리스트 (그룹 기반)
|
* STS 접촉 쌍 결과 리스트 (그룹 기반)
|
||||||
*
|
*
|
||||||
* - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑
|
* - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑
|
||||||
* - 카드 클릭 → on/off 토글
|
* - 카드 클릭 -> on/off 토글
|
||||||
* - ▼/▲ 버튼 → 하단 정보 확장
|
* - 하단 정보 확장/접힘 토글
|
||||||
* - ▶ 버튼 → 모달 팝업
|
* - 모달 팝업 버튼
|
||||||
* - 호버 → 지도 하이라이트
|
* - 호버 -> 지도 하이라이트
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import './StsContactList.scss';
|
import './StsContactList.scss';
|
||||||
import { useStsStore } from '../stores/stsStore';
|
import { useStsStore } from '../stores/stsStore';
|
||||||
import { getShipKindName } from '../../tracking/types/trackQuery.types';
|
import { getShipKindName } from '../../tracking/types/trackQuery.types';
|
||||||
|
import type { StsGroupedContact, StsVessel, StsContact } from '../types/sts.types';
|
||||||
import {
|
import {
|
||||||
getIndicatorDetail,
|
getIndicatorDetail,
|
||||||
formatDistance,
|
formatDistance,
|
||||||
@ -19,12 +20,18 @@ import {
|
|||||||
} from '../types/sts.types';
|
} from '../types/sts.types';
|
||||||
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
||||||
|
|
||||||
function getNationalFlagUrl(nationalCode) {
|
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
|
||||||
if (!nationalCode) return null;
|
if (!nationalCode) return null;
|
||||||
return `/ship/image/small/${nationalCode}.svg`;
|
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 highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
|
||||||
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
|
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
|
||||||
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
|
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
|
||||||
@ -42,19 +49,19 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
useStsStore.getState().setHighlightedGroupIndex(null);
|
useStsStore.getState().setHighlightedGroupIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 카드 클릭 → on/off 토글
|
// 카드 클릭 -> on/off 토글
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
useStsStore.getState().toggleGroupEnabled(index);
|
useStsStore.getState().toggleGroupEnabled(index);
|
||||||
}, [index]);
|
}, [index]);
|
||||||
|
|
||||||
// ▼/▲ 버튼 → 하단 정보 확장
|
// 확장/접힘 토글
|
||||||
const handleExpand = useCallback((e) => {
|
const handleExpand = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
useStsStore.getState().setExpandedGroupIndex(index);
|
useStsStore.getState().setExpandedGroupIndex(index);
|
||||||
}, [index]);
|
}, [index]);
|
||||||
|
|
||||||
// ▶ 버튼 → 모달 열기
|
// 모달 열기
|
||||||
const handleDetail = useCallback((e) => {
|
const handleDetail = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDetailClick?.(index);
|
onDetailClick?.(index);
|
||||||
}, [index, onDetailClick]);
|
}, [index, onDetailClick]);
|
||||||
@ -97,7 +104,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
className="sts-card__flag"
|
className="sts-card__flag"
|
||||||
src={v1Flag}
|
src={v1Flag}
|
||||||
alt=""
|
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>
|
<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">
|
<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>{formatDuration(group.totalDurationMinutes)}</span>
|
||||||
<span className="sts-card__sep">|</span>
|
<span className="sts-card__sep">|</span>
|
||||||
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
|
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
|
||||||
@ -122,7 +129,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
className="sts-card__flag"
|
className="sts-card__flag"
|
||||||
src={v2Flag}
|
src={v2Flag}
|
||||||
alt=""
|
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>
|
<span className="sts-card__name">{vessel2.vesselName || vessel2.vesselId}</span>
|
||||||
@ -132,7 +139,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
onClick={handleExpand}
|
onClick={handleExpand}
|
||||||
title="상세 정보"
|
title="상세 정보"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? '\u25B2' : '\u25BC'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -140,7 +147,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
onClick={handleDetail}
|
onClick={handleDetail}
|
||||||
title="상세 모달"
|
title="상세 모달"
|
||||||
>
|
>
|
||||||
▶
|
{'\u25B6'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -167,7 +174,7 @@ function GroupCard({ group, index, onDetailClick }) {
|
|||||||
{group.contacts.length > 1 && (
|
{group.contacts.length > 1 && (
|
||||||
<div className="sts-card__sub-contacts">
|
<div className="sts-card__sub-contacts">
|
||||||
<span className="sts-card__sub-title">접촉 이력 ({group.contacts.length}회)</span>
|
<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">
|
<div key={ci} className="sts-card__sub-contact">
|
||||||
<span className="sts-card__sub-num">#{ci + 1}</span>
|
<span className="sts-card__sub-num">#{ci + 1}</span>
|
||||||
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</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 (
|
return (
|
||||||
<div className="sts-card__vessel-detail">
|
<div className="sts-card__vessel-detail">
|
||||||
<div className="sts-card__vessel-detail-header">
|
<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 groupedContacts = useStsStore((s) => s.groupedContacts);
|
||||||
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
|
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
|
||||||
const listRef = useRef(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlightedGroupIndex === null || !listRef.current) return;
|
if (highlightedGroupIndex === null || !listRef.current) return;
|
||||||
@ -253,7 +269,7 @@ export default function StsContactList({ onDetailClick }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="sts-contact-list" ref={listRef}>
|
<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} />
|
<GroupCard key={group.pairKey} group={group} index={idx} onDetailClick={onDetailClick} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 선박 상세 모달 — 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장
|
* 선박 상세 모달 -- 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import Map from 'ol/Map';
|
import OlMap from 'ol/Map';
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import { XYZ } from 'ol/source';
|
import { XYZ } from 'ol/source';
|
||||||
import TileLayer from 'ol/layer/Tile';
|
import TileLayer from 'ol/layer/Tile';
|
||||||
@ -19,18 +19,20 @@ import html2canvas from 'html2canvas';
|
|||||||
|
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { ZONE_COLORS } from '../types/areaSearch.types';
|
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 { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
||||||
import { mapLayerConfig } from '../../map/layers/baseLayer';
|
import { mapLayerConfig } from '../../map/layers/baseLayer';
|
||||||
import './VesselDetailModal.scss';
|
import './VesselDetailModal.scss';
|
||||||
|
|
||||||
function getNationalFlagUrl(nationalCode) {
|
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
|
||||||
if (!nationalCode) return null;
|
if (!nationalCode) return null;
|
||||||
return `/ship/image/small/${nationalCode}.svg`;
|
return `/ship/image/small/${nationalCode}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createZoneFeatures(zones) {
|
function createZoneFeatures(zones: Zone[]): Feature[] {
|
||||||
const features = [];
|
const features: Feature[] = [];
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone) => {
|
||||||
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
|
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
|
||||||
const polygon = new Polygon([coords3857]);
|
const polygon = new Polygon([coords3857]);
|
||||||
@ -60,7 +62,7 @@ function createZoneFeatures(zones) {
|
|||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTrackFeature(track) {
|
function createTrackFeature(track: ProcessedTrack): Feature {
|
||||||
const coords3857 = track.geometry.map((c) => fromLonLat(c));
|
const coords3857 = track.geometry.map((c) => fromLonLat(c));
|
||||||
const line = new LineString(coords3857);
|
const line = new LineString(coords3857);
|
||||||
const feature = new Feature({ geometry: line });
|
const feature = new Feature({ geometry: line });
|
||||||
@ -74,8 +76,8 @@ function createTrackFeature(track) {
|
|||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkerFeatures(sortedHits) {
|
function createMarkerFeatures(sortedHits: HitDetail[]): Feature[] {
|
||||||
const features = [];
|
const features: Feature[] = [];
|
||||||
sortedHits.forEach((hit, idx) => {
|
sortedHits.forEach((hit, idx) => {
|
||||||
const seqNum = idx + 1;
|
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;
|
if (!resolution || features.length < 2) return;
|
||||||
|
|
||||||
const PROXIMITY_PX = 40;
|
const PROXIMITY_PX = 40;
|
||||||
const proximityMap = resolution * PROXIMITY_PX;
|
const proximityMap = resolution * PROXIMITY_PX;
|
||||||
const LINE_HEIGHT_PX = 16;
|
const LINE_HEIGHT_PX = 16;
|
||||||
|
|
||||||
|
interface MarkerItem {
|
||||||
|
feature: Feature;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 피처별 좌표 추출
|
// 피처별 좌표 추출
|
||||||
const items = features.map((f) => {
|
const items: MarkerItem[] = features.map((f) => {
|
||||||
const coord = f.getGeometry().getCoordinates();
|
const coord = (f.getGeometry() as Point).getCoordinates();
|
||||||
return { feature: f, x: coord[0], y: coord[1] };
|
return { feature: f, x: coord[0], y: coord[1] };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 근접 그룹 찾기 (Union-Find 방식)
|
// 근접 그룹 찾기 (Union-Find 방식)
|
||||||
const parent = items.map((_, i) => i);
|
const parent = items.map((_, i) => i);
|
||||||
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
|
const find = (i: number): number => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
|
||||||
const union = (a, b) => { parent[find(a)] = find(b); };
|
const union = (a: number, b: number) => { parent[find(a)] = find(b); };
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
for (let j = i + 1; j < items.length; j++) {
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
@ -164,8 +172,8 @@ function adjustOverlappingLabels(features, resolution) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용)
|
// 그룹별 텍스트 offsetY 분산
|
||||||
const groups = {};
|
const groups: Record<number, MarkerItem[]> = {};
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const root = find(i);
|
const root = find(i);
|
||||||
if (!groups[root]) groups[root] = [];
|
if (!groups[root]) groups[root] = [];
|
||||||
@ -174,10 +182,10 @@ function adjustOverlappingLabels(features, resolution) {
|
|||||||
|
|
||||||
Object.values(groups).forEach((group) => {
|
Object.values(groups).forEach((group) => {
|
||||||
if (group.length < 2) return;
|
if (group.length < 2) return;
|
||||||
// 시퀀스 번호 순 정렬 후 IN→OUT 순서
|
// 시퀀스 번호 순 정렬 후 IN->OUT 순서
|
||||||
group.sort((a, b) => {
|
group.sort((a, b) => {
|
||||||
const seqA = a.feature.get('_seqNum');
|
const seqA = a.feature.get('_seqNum') as number;
|
||||||
const seqB = b.feature.get('_seqNum');
|
const seqB = b.feature.get('_seqNum') as number;
|
||||||
if (seqA !== seqB) return seqA - seqB;
|
if (seqA !== seqB) return seqA - seqB;
|
||||||
const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1;
|
const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1;
|
||||||
const typeB = b.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;
|
const startY = -totalHeight / 2 - 8;
|
||||||
|
|
||||||
group.forEach((item, idx) => {
|
group.forEach((item, idx) => {
|
||||||
const style = item.feature.getStyle();
|
const style = item.feature.getStyle() as Style;
|
||||||
const textStyle = style.getText();
|
const textStyle = style.getText();
|
||||||
if (textStyle) {
|
if (textStyle) {
|
||||||
textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX);
|
textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX);
|
||||||
@ -200,14 +208,19 @@ function adjustOverlappingLabels(features, resolution) {
|
|||||||
const MODAL_WIDTH = 680;
|
const MODAL_WIDTH = 680;
|
||||||
const MODAL_APPROX_HEIGHT = 780;
|
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 tracks = useAreaSearchStore((s) => s.tracks);
|
||||||
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
||||||
const zones = useAreaSearchStore((s) => s.zones);
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
|
||||||
const mapContainerRef = useRef(null);
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef<OlMap | null>(null);
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 드래그 위치 관리
|
// 드래그 위치 관리
|
||||||
const [position, setPosition] = useState(() => ({
|
const [position, setPosition] = useState(() => ({
|
||||||
@ -218,7 +231,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const dragStart = useRef({ x: 0, y: 0 });
|
const dragStart = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
dragStart.current = {
|
dragStart.current = {
|
||||||
x: e.clientX - posRef.current.x,
|
x: e.clientX - posRef.current.x,
|
||||||
@ -228,7 +241,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!dragging.current) return;
|
if (!dragging.current) return;
|
||||||
const newPos = {
|
const newPos = {
|
||||||
x: e.clientX - dragStart.current.x,
|
x: e.clientX - dragStart.current.x,
|
||||||
@ -249,14 +262,14 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const track = useMemo(
|
const track = useMemo(
|
||||||
() => tracks.find((t) => t.vesselId === vesselId),
|
() => tracks.find((t: ProcessedTrack) => t.vesselId === vesselId),
|
||||||
[tracks, vesselId],
|
[tracks, vesselId],
|
||||||
);
|
);
|
||||||
const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
|
const hits: HitDetail[] = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
|
||||||
|
|
||||||
const zoneMap = useMemo(() => {
|
const zoneMap = useMemo(() => {
|
||||||
const lookup = {};
|
const lookup: Record<string | number, Zone> = {};
|
||||||
zones.forEach((z, idx) => {
|
zones.forEach((z: Zone, idx: number) => {
|
||||||
lookup[z.id] = z;
|
lookup[z.id] = z;
|
||||||
lookup[z.name] = z;
|
lookup[z.name] = z;
|
||||||
lookup[idx] = z;
|
lookup[idx] = z;
|
||||||
@ -266,7 +279,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
}, [zones]);
|
}, [zones]);
|
||||||
|
|
||||||
const sortedHits = useMemo(
|
const sortedHits = useMemo(
|
||||||
() => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp),
|
() => [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)),
|
||||||
[hits],
|
[hits],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -275,7 +288,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
if (!mapContainerRef.current || !track) return;
|
if (!mapContainerRef.current || !track) return;
|
||||||
|
|
||||||
const tileSource = new XYZ({
|
const tileSource = new XYZ({
|
||||||
url: mapLayerConfig.darkLayer.source.getUrls()[0],
|
url: mapLayerConfig.darkLayer.source.getUrls()![0],
|
||||||
minZoom: 6,
|
minZoom: 6,
|
||||||
maxZoom: 11,
|
maxZoom: 11,
|
||||||
});
|
});
|
||||||
@ -291,7 +304,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
const markerSource = new VectorSource({ features: markerFeatures });
|
const markerSource = new VectorSource({ features: markerFeatures });
|
||||||
const markerLayer = new VectorLayer({ source: markerSource });
|
const markerLayer = new VectorLayer({ source: markerSource });
|
||||||
|
|
||||||
const map = new Map({
|
const map = new OlMap({
|
||||||
target: mapContainerRef.current,
|
target: mapContainerRef.current,
|
||||||
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
|
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
|
||||||
view: new View({ center: [0, 0], zoom: 7 }),
|
view: new View({ center: [0, 0], zoom: 7 }),
|
||||||
@ -315,7 +328,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.setTarget(null);
|
map.setTarget(undefined);
|
||||||
map.dispose();
|
map.dispose();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
@ -325,7 +338,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const modal = el.parentElement;
|
const modal = el.parentElement as HTMLElement;
|
||||||
const saved = {
|
const saved = {
|
||||||
elOverflow: el.style.overflow,
|
elOverflow: el.style.overflow,
|
||||||
modalMaxHeight: modal.style.maxHeight,
|
modalMaxHeight: modal.style.maxHeight,
|
||||||
@ -347,7 +360,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
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 now = new Date();
|
||||||
const name = track?.shipName || track?.targetId || 'vessel';
|
const name = track?.shipName || track?.targetId || 'vessel';
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@ -383,7 +396,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
|
|||||||
<span className="vessel-detail-modal__kind">{kindName}</span>
|
<span className="vessel-detail-modal__kind">{kindName}</span>
|
||||||
{flagUrl && (
|
{flagUrl && (
|
||||||
<span className="vessel-detail-modal__flag">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="vessel-detail-modal__name">
|
<span className="vessel-detail-modal__name">
|
||||||
@ -6,8 +6,14 @@ import {
|
|||||||
ZONE_DRAW_TYPES,
|
ZONE_DRAW_TYPES,
|
||||||
ZONE_COLORS,
|
ZONE_COLORS,
|
||||||
} from '../types/areaSearch.types';
|
} 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 effectiveMaxZones = maxZones ?? MAX_ZONES;
|
||||||
const zones = useAreaSearchStore((s) => s.zones);
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
|
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
|
||||||
@ -21,13 +27,13 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
|
|||||||
|
|
||||||
const canAddZone = zones.length < effectiveMaxZones;
|
const canAddZone = zones.length < effectiveMaxZones;
|
||||||
|
|
||||||
const handleDrawClick = useCallback((type) => {
|
const handleDrawClick = useCallback((type: ZoneDrawType) => {
|
||||||
if (!canAddZone || disabled) return;
|
if (!canAddZone || disabled) return;
|
||||||
if (!confirmAndClearResults()) return;
|
if (!confirmAndClearResults()) return;
|
||||||
setActiveDrawType(activeDrawType === type ? null : type);
|
setActiveDrawType(activeDrawType === type ? null : type);
|
||||||
}, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]);
|
}, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]);
|
||||||
|
|
||||||
const handleZoneClick = useCallback((zoneId) => {
|
const handleZoneClick = useCallback((zoneId: string) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (selectedZoneId === zoneId) {
|
if (selectedZoneId === zoneId) {
|
||||||
deselectZone();
|
deselectZone();
|
||||||
@ -37,26 +43,26 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
|
|||||||
}
|
}
|
||||||
}, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]);
|
}, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]);
|
||||||
|
|
||||||
const handleRemoveZone = useCallback((e, zoneId) => {
|
const handleRemoveZone = useCallback((e: React.MouseEvent, zoneId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirmAndClearResults()) return;
|
if (!confirmAndClearResults()) return;
|
||||||
removeZone(zoneId);
|
removeZone(zoneId);
|
||||||
}, [removeZone, confirmAndClearResults]);
|
}, [removeZone, confirmAndClearResults]);
|
||||||
|
|
||||||
// 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적)
|
// 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적)
|
||||||
const dragIndexRef = useRef(null);
|
const dragIndexRef = useRef<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState(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;
|
dragIndexRef.current = index;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', '');
|
e.dataTransfer.setData('text/plain', '');
|
||||||
requestAnimationFrame(() => {
|
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.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
|
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();
|
e.preventDefault();
|
||||||
const fromIndex = dragIndexRef.current;
|
const fromIndex = dragIndexRef.current;
|
||||||
if (fromIndex !== null && fromIndex !== toIndex) {
|
if (fromIndex !== null && fromIndex !== toIndex) {
|
||||||
@ -127,7 +133,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
|
|||||||
{/* 구역 목록 */}
|
{/* 구역 목록 */}
|
||||||
{zones.length > 0 && (
|
{zones.length > 0 && (
|
||||||
<ul className="zone-list">
|
<ul className="zone-list">
|
||||||
{zones.map((zone, index) => {
|
{zones.map((zone: Zone, index: number) => {
|
||||||
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
|
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 성능 최적화:
|
* 성능 최적화:
|
||||||
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
|
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
|
||||||
* - 정적 레이어(PathLayer) 캐싱 — 필터 변경 시에만 재생성
|
* - 정적 레이어(PathLayer) 캐싱 -- 필터 변경 시에만 재생성
|
||||||
* - 동적 레이어(IconLayer, TextLayer, TripsLayer)만 매 프레임 갱신
|
* - 동적 레이어(IconLayer, TextLayer, TripsLayer)만 매 프레임 갱신
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
@ -11,6 +11,8 @@ import { TripsLayer } from '@deck.gl/geo-layers';
|
|||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
|
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
|
||||||
|
import type { ProcessedTrack } from '../stores/areaSearchStore';
|
||||||
|
import type { VesselPosition } from '../types/areaSearch.types';
|
||||||
import {
|
import {
|
||||||
registerAreaSearchLayers,
|
registerAreaSearchLayers,
|
||||||
unregisterAreaSearchLayers,
|
unregisterAreaSearchLayers,
|
||||||
@ -21,12 +23,27 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
|||||||
const TRAIL_LENGTH_MS = 3600000;
|
const TRAIL_LENGTH_MS = 3600000;
|
||||||
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
|
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
|
||||||
|
|
||||||
export default function useAreaSearchLayer() {
|
interface TripsDataItem {
|
||||||
const tripsDataRef = useRef([]);
|
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 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 구독: 필터/상태 (비빈번 변경만)
|
// React 구독: 필터/상태 (비빈번 변경만)
|
||||||
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||||
@ -36,7 +53,7 @@ export default function useAreaSearchLayer() {
|
|||||||
const showPaths = useAreaSearchStore((s) => s.showPaths);
|
const showPaths = useAreaSearchStore((s) => s.showPaths);
|
||||||
const showTrail = useAreaSearchStore((s) => s.showTrail);
|
const showTrail = useAreaSearchStore((s) => s.showTrail);
|
||||||
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
|
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
|
||||||
// currentTime — React 구독 제거, zustand.subscribe로 대체
|
// currentTime -- React 구독 제거, zustand.subscribe로 대체
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음)
|
* 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음)
|
||||||
@ -48,14 +65,15 @@ export default function useAreaSearchLayer() {
|
|||||||
const ct = useAreaSearchAnimationStore.getState().currentTime;
|
const ct = useAreaSearchAnimationStore.getState().currentTime;
|
||||||
const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct);
|
const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct);
|
||||||
const filteredPositions = allPositions.filter(
|
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) {
|
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(
|
const filteredTripsData = tripsDataRef.current.filter(
|
||||||
(d) => iconVesselIds.has(d.vesselId),
|
(d) => iconVesselIds.has(d.vesselId),
|
||||||
);
|
);
|
||||||
@ -65,8 +83,9 @@ export default function useAreaSearchLayer() {
|
|||||||
new TripsLayer({
|
new TripsLayer({
|
||||||
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
|
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
|
||||||
data: filteredTripsData,
|
data: filteredTripsData,
|
||||||
getPath: (d) => d.path,
|
// @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry
|
||||||
getTimestamps: (d) => d.timestamps,
|
getPath: (d: TripsDataItem) => d.path,
|
||||||
|
getTimestamps: (d: TripsDataItem) => d.timestamps,
|
||||||
getColor: [120, 120, 120, 180],
|
getColor: [120, 120, 120, 180],
|
||||||
widthMinPixels: 2,
|
widthMinPixels: 2,
|
||||||
widthMaxPixels: 3,
|
widthMaxPixels: 3,
|
||||||
@ -80,7 +99,7 @@ export default function useAreaSearchLayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 정적 PathLayer (캐싱 — 필터/하이라이트 변경 시에만 재생성)
|
// 2. 정적 PathLayer (캐싱 -- 필터/하이라이트 변경 시에만 재생성)
|
||||||
if (showPaths) {
|
if (showPaths) {
|
||||||
const deps = staticLayerCacheRef.current.deps;
|
const deps = staticLayerCacheRef.current.deps;
|
||||||
const needsRebuild = !deps
|
const needsRebuild = !deps
|
||||||
@ -90,7 +109,7 @@ export default function useAreaSearchLayer() {
|
|||||||
|| deps.highlightedVesselId !== highlightedVesselId;
|
|| deps.highlightedVesselId !== highlightedVesselId;
|
||||||
|
|
||||||
if (needsRebuild) {
|
if (needsRebuild) {
|
||||||
const filteredTracks = tracks.filter((t) =>
|
const filteredTracks = tracks.filter((t: ProcessedTrack) =>
|
||||||
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
|
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
|
||||||
);
|
);
|
||||||
staticLayerCacheRef.current = {
|
staticLayerCacheRef.current = {
|
||||||
@ -98,7 +117,7 @@ export default function useAreaSearchLayer() {
|
|||||||
tracks: filteredTracks,
|
tracks: filteredTracks,
|
||||||
showPoints: false,
|
showPoints: false,
|
||||||
highlightedVesselId,
|
highlightedVesselId,
|
||||||
onPathHover: (vesselId) => {
|
onPathHover: (vesselId: string | null) => {
|
||||||
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
},
|
},
|
||||||
layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH },
|
layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH },
|
||||||
@ -114,14 +133,14 @@ export default function useAreaSearchLayer() {
|
|||||||
currentPositions: filteredPositions,
|
currentPositions: filteredPositions,
|
||||||
showVirtualShip: filteredPositions.length > 0,
|
showVirtualShip: filteredPositions.length > 0,
|
||||||
showLabels: filteredPositions.length > 0,
|
showLabels: filteredPositions.length > 0,
|
||||||
onIconHover: (shipData, x, y) => {
|
onIconHover: (shipData, _x, _y) => {
|
||||||
if (shipData) {
|
if (shipData) {
|
||||||
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
|
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
|
||||||
} else {
|
} else {
|
||||||
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPathHover: (vesselId) => {
|
onPathHover: (vesselId: string | null) => {
|
||||||
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
},
|
},
|
||||||
layerIds: {
|
layerIds: {
|
||||||
@ -156,17 +175,17 @@ export default function useAreaSearchLayer() {
|
|||||||
startTimeRef.current = sTime;
|
startTimeRef.current = sTime;
|
||||||
|
|
||||||
tripsDataRef.current = tracks
|
tripsDataRef.current = tracks
|
||||||
.filter((t) => t.geometry.length >= 2)
|
.filter((t: ProcessedTrack) => t.geometry.length >= 2)
|
||||||
.map((track) => ({
|
.map((track: ProcessedTrack) => ({
|
||||||
vesselId: track.vesselId,
|
vesselId: track.vesselId,
|
||||||
shipKindCode: track.shipKindCode,
|
shipKindCode: track.shipKindCode,
|
||||||
path: track.geometry,
|
path: track.geometry,
|
||||||
timestamps: track.timestampsMs.map((t) => t - sTime),
|
timestamps: track.timestampsMs.map((t: number) => t - sTime),
|
||||||
}));
|
}));
|
||||||
}, [queryCompleted, tracks]);
|
}, [queryCompleted, tracks]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* currentTime 구독 (zustand.subscribe — React 리렌더 바이패스)
|
* currentTime 구독 (zustand.subscribe -- React 리렌더 바이패스)
|
||||||
* 재생 중: ~10fps 쓰로틀 (RENDER_INTERVAL_MS)
|
* 재생 중: ~10fps 쓰로틀 (RENDER_INTERVAL_MS)
|
||||||
* seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지)
|
* seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지)
|
||||||
*/
|
*/
|
||||||
@ -176,7 +195,7 @@ export default function useAreaSearchLayer() {
|
|||||||
renderFrame();
|
renderFrame();
|
||||||
|
|
||||||
let lastRenderTime = 0;
|
let lastRenderTime = 0;
|
||||||
let pendingRafId = null;
|
let pendingRafId: number | null = null;
|
||||||
|
|
||||||
const unsub = useAreaSearchAnimationStore.subscribe(
|
const unsub = useAreaSearchAnimationStore.subscribe(
|
||||||
(s) => s.currentTime,
|
(s) => s.currentTime,
|
||||||
@ -15,6 +15,9 @@ import { useStsStore } from '../stores/stsStore';
|
|||||||
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
import { STS_LAYER_IDS } from '../types/sts.types';
|
import { STS_LAYER_IDS } from '../types/sts.types';
|
||||||
import { getContactRiskColor } 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 {
|
import {
|
||||||
registerStsLayers,
|
registerStsLayers,
|
||||||
unregisterStsLayers,
|
unregisterStsLayers,
|
||||||
@ -25,11 +28,39 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
|||||||
const TRAIL_LENGTH_MS = 3600000;
|
const TRAIL_LENGTH_MS = 3600000;
|
||||||
const RENDER_INTERVAL_MS = 100;
|
const RENDER_INTERVAL_MS = 100;
|
||||||
|
|
||||||
export default function useStsLayer() {
|
interface TripsDataItem {
|
||||||
const tripsDataRef = useRef([]);
|
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 startTimeRef = useRef(0);
|
||||||
const staticLayerCacheRef = useRef({ layers: [], deps: null });
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
|
||||||
const contactLayerCacheRef = useRef({ layers: [], deps: null });
|
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 구독: 그룹 기반
|
// React 구독: 그룹 기반
|
||||||
const queryCompleted = useStsStore((s) => s.queryCompleted);
|
const queryCompleted = useStsStore((s) => s.queryCompleted);
|
||||||
@ -52,11 +83,12 @@ export default function useStsLayer() {
|
|||||||
|
|
||||||
if (!needsRebuild) return contactLayerCacheRef.current.layers;
|
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
|
// disabled가 아닌 그룹의 모든 하위 contacts를 flat
|
||||||
const enabledContacts = [];
|
const enabledContacts: EnabledContact[] = [];
|
||||||
groupedContacts.forEach((group, gIdx) => {
|
groupedContacts.forEach((group: StsGroupedContact, gIdx: number) => {
|
||||||
if (disabledGroupIndices.has(gIdx)) return;
|
if (disabledGroupIndices.has(gIdx)) return;
|
||||||
group.contacts.forEach((c) => {
|
group.contacts.forEach((c) => {
|
||||||
enabledContacts.push({
|
enabledContacts.push({
|
||||||
@ -79,9 +111,10 @@ export default function useStsLayer() {
|
|||||||
new ScatterplotLayer({
|
new ScatterplotLayer({
|
||||||
id: STS_LAYER_IDS.CONTACT_POINT,
|
id: STS_LAYER_IDS.CONTACT_POINT,
|
||||||
data: enabledContacts.filter((c) => c.contactCenterPoint),
|
data: enabledContacts.filter((c) => c.contactCenterPoint),
|
||||||
getPosition: (d) => d.contactCenterPoint,
|
// @ts-expect-error Deck.gl runtime accepts number[] for Position
|
||||||
getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
|
getPosition: (d: EnabledContact) => d.contactCenterPoint as number[],
|
||||||
getFillColor: (d) => getContactRiskColor(d.indicators),
|
getRadius: (d: EnabledContact) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
|
||||||
|
getFillColor: (d: EnabledContact) => getContactRiskColor(d.indicators ?? null),
|
||||||
radiusMinPixels: 4,
|
radiusMinPixels: 4,
|
||||||
radiusMaxPixels: 12,
|
radiusMaxPixels: 12,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
@ -107,11 +140,12 @@ export default function useStsLayer() {
|
|||||||
const ct = useAreaSearchAnimationStore.getState().currentTime;
|
const ct = useAreaSearchAnimationStore.getState().currentTime;
|
||||||
const allPositions = useStsStore.getState().getCurrentPositions(ct);
|
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 궤적
|
// 1. TripsLayer 궤적
|
||||||
if (showTrail && tripsDataRef.current.length > 0) {
|
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(
|
const filteredTripsData = tripsDataRef.current.filter(
|
||||||
(d) => iconVesselIds.has(d.vesselId),
|
(d) => iconVesselIds.has(d.vesselId),
|
||||||
);
|
);
|
||||||
@ -121,8 +155,9 @@ export default function useStsLayer() {
|
|||||||
new TripsLayer({
|
new TripsLayer({
|
||||||
id: STS_LAYER_IDS.TRIPS_TRAIL,
|
id: STS_LAYER_IDS.TRIPS_TRAIL,
|
||||||
data: filteredTripsData,
|
data: filteredTripsData,
|
||||||
getPath: (d) => d.path,
|
// @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry
|
||||||
getTimestamps: (d) => d.timestamps,
|
getPath: (d: TripsDataItem) => d.path,
|
||||||
|
getTimestamps: (d: TripsDataItem) => d.timestamps,
|
||||||
getColor: [120, 120, 120, 180],
|
getColor: [120, 120, 120, 180],
|
||||||
widthMinPixels: 2,
|
widthMinPixels: 2,
|
||||||
widthMaxPixels: 3,
|
widthMaxPixels: 3,
|
||||||
@ -141,7 +176,7 @@ export default function useStsLayer() {
|
|||||||
const disabledVesselIds = useStsStore.getState().getDisabledVesselIds();
|
const disabledVesselIds = useStsStore.getState().getDisabledVesselIds();
|
||||||
|
|
||||||
// 접촉 쌍의 양쪽 선박 항적 하이라이트
|
// 접촉 쌍의 양쪽 선박 항적 하이라이트
|
||||||
let stsHighlightedVesselIds = null;
|
let stsHighlightedVesselIds: Set<string> | null = null;
|
||||||
if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) {
|
if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) {
|
||||||
const g = groupedContacts[highlightedGroupIndex];
|
const g = groupedContacts[highlightedGroupIndex];
|
||||||
stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]);
|
stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]);
|
||||||
@ -154,21 +189,21 @@ export default function useStsLayer() {
|
|||||||
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
|
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
|
||||||
|
|
||||||
if (needsRebuild) {
|
if (needsRebuild) {
|
||||||
const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId));
|
const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId));
|
||||||
staticLayerCacheRef.current = {
|
staticLayerCacheRef.current = {
|
||||||
layers: createStaticTrackLayers({
|
layers: createStaticTrackLayers({
|
||||||
tracks: filteredTracks,
|
tracks: filteredTracks,
|
||||||
showPoints: false,
|
showPoints: false,
|
||||||
highlightedVesselIds: stsHighlightedVesselIds,
|
highlightedVesselIds: stsHighlightedVesselIds,
|
||||||
layerIds: { path: STS_LAYER_IDS.TRACK_PATH },
|
layerIds: { path: STS_LAYER_IDS.TRACK_PATH },
|
||||||
onPathHover: (vesselId) => {
|
onPathHover: (vesselId: string | null) => {
|
||||||
if (!vesselId) {
|
if (!vesselId) {
|
||||||
useStsStore.getState().setHighlightedGroupIndex(null);
|
useStsStore.getState().setHighlightedGroupIndex(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const groups = useStsStore.getState().groupedContacts;
|
const groups = useStsStore.getState().groupedContacts;
|
||||||
const idx = groups.findIndex(
|
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);
|
useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null);
|
||||||
},
|
},
|
||||||
@ -220,12 +255,12 @@ export default function useStsLayer() {
|
|||||||
startTimeRef.current = sTime;
|
startTimeRef.current = sTime;
|
||||||
|
|
||||||
tripsDataRef.current = tracks
|
tripsDataRef.current = tracks
|
||||||
.filter((t) => t.geometry.length >= 2)
|
.filter((t: ProcessedTrack) => t.geometry.length >= 2)
|
||||||
.map((track) => ({
|
.map((track: ProcessedTrack) => ({
|
||||||
vesselId: track.vesselId,
|
vesselId: track.vesselId,
|
||||||
shipKindCode: track.shipKindCode,
|
shipKindCode: track.shipKindCode,
|
||||||
path: track.geometry,
|
path: track.geometry,
|
||||||
timestamps: track.timestampsMs.map((t) => t - sTime),
|
timestamps: track.timestampsMs.map((t: number) => t - sTime),
|
||||||
}));
|
}));
|
||||||
}, [queryCompleted, tracks]);
|
}, [queryCompleted, tracks]);
|
||||||
|
|
||||||
@ -238,7 +273,7 @@ export default function useStsLayer() {
|
|||||||
renderFrame();
|
renderFrame();
|
||||||
|
|
||||||
let lastRenderTime = 0;
|
let lastRenderTime = 0;
|
||||||
let pendingRafId = null;
|
let pendingRafId: number | null = null;
|
||||||
|
|
||||||
const unsub = useAreaSearchAnimationStore.subscribe(
|
const unsub = useAreaSearchAnimationStore.subscribe(
|
||||||
(s) => s.currentTime,
|
(s) => s.currentTime,
|
||||||
@ -15,15 +15,21 @@ import { createBox } from 'ol/interaction/Draw';
|
|||||||
import { Style, Fill, Stroke } from 'ol/style';
|
import { Style, Fill, Stroke } from 'ol/style';
|
||||||
import { transform } from 'ol/proj';
|
import { transform } from 'ol/proj';
|
||||||
import { fromCircle } from 'ol/geom/Polygon';
|
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 { useMapStore } from '../../stores/mapStore';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
|
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';
|
import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
|
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
|
||||||
*/
|
*/
|
||||||
function toWgs84Polygon(coords3857) {
|
function toWgs84Polygon(coords3857: Coordinate[]): number[][] {
|
||||||
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
|
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
|
||||||
// 폐곡선 보장 (첫점 == 끝점)
|
// 폐곡선 보장 (첫점 == 끝점)
|
||||||
if (coords4326.length > 0) {
|
if (coords4326.length > 0) {
|
||||||
@ -39,7 +45,7 @@ function toWgs84Polygon(coords3857) {
|
|||||||
/**
|
/**
|
||||||
* 구역 인덱스에 맞는 OL 스타일 생성
|
* 구역 인덱스에 맞는 OL 스타일 생성
|
||||||
*/
|
*/
|
||||||
function createZoneStyle(index) {
|
function createZoneStyle(index: number): Style {
|
||||||
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
|
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
|
||||||
return new Style({
|
return new Style({
|
||||||
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
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 map = useMapStore((s) => s.map);
|
||||||
const drawRef = useRef(null);
|
const drawRef = useRef<Draw | null>(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef<OlMap | null>(null);
|
||||||
|
|
||||||
// map ref 동기화 (클린업에서 사용)
|
// map ref 동기화 (클린업에서 사용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -72,10 +78,10 @@ export default function useZoneDraw() {
|
|||||||
|
|
||||||
// 기존 zones가 있으면 동기화
|
// 기존 zones가 있으면 동기화
|
||||||
const { zones } = useAreaSearchStore.getState();
|
const { zones } = useAreaSearchStore.getState();
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone: Zone) => {
|
||||||
if (!zone.olFeature) return;
|
if (!zone.olFeature) return;
|
||||||
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
|
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
|
||||||
source.addFeature(zone.olFeature);
|
source.addFeature(zone.olFeature as Feature<Geometry>);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -93,15 +99,15 @@ export default function useZoneDraw() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useAreaSearchStore.subscribe(
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
(s) => s.zones,
|
(s) => s.zones,
|
||||||
(zones) => {
|
(zones: Zone[]) => {
|
||||||
const source = getZoneSource();
|
const source = getZoneSource();
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
source.clear();
|
source.clear();
|
||||||
|
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone: Zone) => {
|
||||||
if (!zone.olFeature) return;
|
if (!zone.olFeature) return;
|
||||||
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
|
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(() => {
|
useEffect(() => {
|
||||||
const unsub = useAreaSearchStore.subscribe(
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
(s) => s.showZones,
|
(s) => s.showZones,
|
||||||
(show) => {
|
(show: boolean) => {
|
||||||
const layer = getZoneLayer();
|
const layer = getZoneLayer();
|
||||||
if (layer) layer.setVisible(show);
|
if (layer) layer.setVisible(show);
|
||||||
},
|
},
|
||||||
@ -121,7 +127,7 @@ export default function useZoneDraw() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Draw 인터랙션 생성 함수
|
// Draw 인터랙션 생성 함수
|
||||||
const setupDraw = useCallback((currentMap, drawType) => {
|
const setupDraw = useCallback((currentMap: OlMap, drawType: ZoneDrawType | null) => {
|
||||||
// 기존 인터랙션 제거
|
// 기존 인터랙션 제거
|
||||||
if (drawRef.current) {
|
if (drawRef.current) {
|
||||||
currentMap.removeInteraction(drawRef.current);
|
currentMap.removeInteraction(drawRef.current);
|
||||||
@ -137,7 +143,7 @@ export default function useZoneDraw() {
|
|||||||
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
|
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
|
||||||
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
|
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
|
||||||
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
|
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
|
||||||
let draw;
|
let draw: Draw;
|
||||||
if (drawType === ZONE_DRAW_TYPES.BOX) {
|
if (drawType === ZONE_DRAW_TYPES.BOX) {
|
||||||
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
|
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
|
||||||
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
||||||
@ -146,22 +152,23 @@ export default function useZoneDraw() {
|
|||||||
draw = new Draw({ type: 'Polygon' });
|
draw = new Draw({ type: 'Polygon' });
|
||||||
}
|
}
|
||||||
|
|
||||||
draw.on('drawend', (evt) => {
|
draw.on('drawend', (evt: DrawEvent) => {
|
||||||
const feature = evt.feature;
|
const feature = evt.feature;
|
||||||
let geom = feature.getGeometry();
|
let geom = feature.getGeometry()!;
|
||||||
const typeName = drawType;
|
const typeName = drawType;
|
||||||
|
|
||||||
// Circle → Polygon 변환 (center/radius 보존)
|
// Circle → Polygon 변환 (center/radius 보존)
|
||||||
let circleMeta = null;
|
let circleMeta: CircleMeta | null = null;
|
||||||
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
||||||
circleMeta = { center: geom.getCenter(), radius: geom.getRadius() };
|
const circleGeom = geom as OlCircleGeom;
|
||||||
const polyGeom = fromCircle(geom, 64);
|
circleMeta = { center: circleGeom.getCenter() as [number, number], radius: circleGeom.getRadius() };
|
||||||
|
const polyGeom = fromCircle(circleGeom, 64);
|
||||||
feature.setGeometry(polyGeom);
|
feature.setGeometry(polyGeom);
|
||||||
geom = polyGeom;
|
geom = polyGeom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EPSG:3857 → 4326 좌표 추출
|
// EPSG:3857 → 4326 좌표 추출
|
||||||
const coords3857 = geom.getCoordinates()[0];
|
const coords3857 = (geom as Polygon).getCoordinates()[0];
|
||||||
const coordinates = toWgs84Polygon(coords3857);
|
const coordinates = toWgs84Polygon(coords3857);
|
||||||
|
|
||||||
// 최소 4점 확인
|
// 최소 4점 확인
|
||||||
@ -187,7 +194,7 @@ export default function useZoneDraw() {
|
|||||||
type: typeName,
|
type: typeName,
|
||||||
source: 'draw',
|
source: 'draw',
|
||||||
coordinates,
|
coordinates,
|
||||||
olFeature: feature,
|
olFeature: feature as Feature<Geometry>,
|
||||||
circleMeta,
|
circleMeta,
|
||||||
});
|
});
|
||||||
// addZone → activeDrawType: null → subscription → removeInteraction
|
// addZone → activeDrawType: null → subscription → removeInteraction
|
||||||
@ -204,7 +211,7 @@ export default function useZoneDraw() {
|
|||||||
|
|
||||||
const unsub = useAreaSearchStore.subscribe(
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
(s) => s.activeDrawType,
|
(s) => s.activeDrawType,
|
||||||
(drawType) => {
|
(drawType: ZoneDrawType | null) => {
|
||||||
setupDraw(map, drawType);
|
setupDraw(map, drawType);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -227,7 +234,7 @@ export default function useZoneDraw() {
|
|||||||
|
|
||||||
// ESC 키로 그리기 취소
|
// ESC 키로 그리기 취소
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const { activeDrawType } = useAreaSearchStore.getState();
|
const { activeDrawType } = useAreaSearchStore.getState();
|
||||||
if (activeDrawType) {
|
if (activeDrawType) {
|
||||||
@ -243,15 +250,15 @@ export default function useZoneDraw() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useAreaSearchStore.subscribe(
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
(s) => s.zones,
|
(s) => s.zones,
|
||||||
(zones, prevZones) => {
|
(zones: Zone[], prevZones: Zone[]) => {
|
||||||
if (!prevZones || zones.length >= prevZones.length) return;
|
if (!prevZones || zones.length >= prevZones.length) return;
|
||||||
const source = getZoneSource();
|
const source = getZoneSource();
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
|
||||||
const currentIds = new Set(zones.map((z) => z.id));
|
const currentIds = new Set(zones.map((z: Zone) => z.id));
|
||||||
prevZones.forEach((z) => {
|
prevZones.forEach((z: Zone) => {
|
||||||
if (!currentIds.has(z.id) && z.olFeature) {
|
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 Collection from 'ol/Collection';
|
||||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
|
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
|
||||||
import { transform } from 'ol/proj';
|
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 { useMapStore } from '../../stores/mapStore';
|
||||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
|
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
|
||||||
|
import type { Zone, CircleMeta } from '../types/areaSearch.types';
|
||||||
import { getZoneSource } from '../utils/zoneLayerRefs';
|
import { getZoneSource } from '../utils/zoneLayerRefs';
|
||||||
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
|
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
|
||||||
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
|
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
|
||||||
|
|
||||||
/** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */
|
/** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */
|
||||||
function toWgs84Polygon(coords3857) {
|
function toWgs84Polygon(coords3857: Coordinate[]): number[][] {
|
||||||
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
|
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
|
||||||
if (coords4326.length > 0) {
|
if (coords4326.length > 0) {
|
||||||
const first = coords4326[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];
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
||||||
return new Style({
|
return new Style({
|
||||||
fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }),
|
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];
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
||||||
return new Style({
|
return new Style({
|
||||||
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
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];
|
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
|
||||||
return new Style({
|
return new Style({
|
||||||
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
||||||
@ -78,7 +84,7 @@ function createHoverStyle(colorIndex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
|
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
|
||||||
function pointToSegmentDist(p, a, b) {
|
function pointToSegmentDist(p: number[], a: number[], b: number[]): number {
|
||||||
const dx = b[0] - a[0];
|
const dx = b[0] - a[0];
|
||||||
const dy = b[1] - a[1];
|
const dy = b[1] - a[1];
|
||||||
const lenSq = dx * dx + dy * dy;
|
const lenSq = dx * dx + dy * dy;
|
||||||
@ -91,8 +97,8 @@ function pointToSegmentDist(p, a, b) {
|
|||||||
const HANDLE_TOLERANCE = 12;
|
const HANDLE_TOLERANCE = 12;
|
||||||
|
|
||||||
/** Polygon 꼭짓점/변 근접 검사 */
|
/** Polygon 꼭짓점/변 근접 검사 */
|
||||||
function isNearPolygonHandle(map, pixel, feature) {
|
function isNearPolygonHandle(map: OlMap, pixel: number[], feature: Feature<Geometry>): boolean {
|
||||||
const coords = feature.getGeometry().getCoordinates()[0];
|
const coords = (feature.getGeometry() as Polygon).getCoordinates()[0];
|
||||||
const n = coords.length - 1;
|
const n = coords.length - 1;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const vp = map.getPixelFromCoordinate(coords[i]);
|
const vp = map.getPixelFromCoordinate(coords[i]);
|
||||||
@ -103,7 +109,7 @@ function isNearPolygonHandle(map, pixel, feature) {
|
|||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const p1 = map.getPixelFromCoordinate(coords[i]);
|
const p1 = map.getPixelFromCoordinate(coords[i]);
|
||||||
const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,12 +117,12 @@ function isNearPolygonHandle(map, pixel, feature) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Feature에서 좌표를 추출하여 store에 동기화 */
|
/** Feature에서 좌표를 추출하여 store에 동기화 */
|
||||||
function syncZoneToStore(zoneId, feature, zone) {
|
function syncZoneToStore(zoneId: string, feature: Feature<Geometry>, zone: Zone): void {
|
||||||
const geom = feature.getGeometry();
|
const geom = feature.getGeometry() as Polygon;
|
||||||
const coords3857 = geom.getCoordinates()[0];
|
const coords3857 = geom.getCoordinates()[0];
|
||||||
const coords4326 = toWgs84Polygon(coords3857);
|
const coords4326 = toWgs84Polygon(coords3857);
|
||||||
|
|
||||||
let circleMeta;
|
let circleMeta: CircleMeta | undefined;
|
||||||
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
|
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
|
||||||
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
|
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
|
||||||
const center = computeCentroid(coords3857);
|
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;
|
let sumX = 0, sumY = 0;
|
||||||
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
|
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@ -139,17 +145,17 @@ function computeCentroid(coords) {
|
|||||||
return [sumX / n, sumY / n];
|
return [sumX / n, sumY / n];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useZoneEdit() {
|
export default function useZoneEdit(): void {
|
||||||
const map = useMapStore((s) => s.map);
|
const map = useMapStore((s) => s.map);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef<OlMap | null>(null);
|
||||||
const modifyRef = useRef(null);
|
const modifyRef = useRef<Modify | null>(null);
|
||||||
const translateRef = useRef(null);
|
const translateRef = useRef<Translate | null>(null);
|
||||||
const customResizeRef = useRef(null);
|
const customResizeRef = useRef<BoxResizeInteraction | CircleResizeInteraction | null>(null);
|
||||||
const selectedCollectionRef = useRef(new Collection());
|
const selectedCollectionRef = useRef(new Collection<Feature<Geometry>>());
|
||||||
const clickListenerRef = useRef(null);
|
const clickListenerRef = useRef<((evt: MapBrowserEvent<PointerEvent>) => void) | null>(null);
|
||||||
const contextMenuRef = useRef(null);
|
const contextMenuRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||||
const keydownRef = useRef(null);
|
const keydownRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||||
const hoveredZoneIdRef = useRef(null);
|
const hoveredZoneIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => { mapRef.current = map; }, [map]);
|
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();
|
removeInteractions();
|
||||||
if (!zone || !zone.olFeature) return;
|
if (!zone || !zone.olFeature) return;
|
||||||
|
|
||||||
const feature = zone.olFeature;
|
const feature = zone.olFeature as Feature<Polygon>;
|
||||||
const collection = selectedCollectionRef.current;
|
const collection = selectedCollectionRef.current;
|
||||||
collection.push(feature);
|
collection.push(feature as Feature<Geometry>);
|
||||||
|
|
||||||
// 선택 스타일 적용
|
// 선택 스타일 적용
|
||||||
feature.setStyle(createSelectedStyle(zone.colorIndex));
|
feature.setStyle(createSelectedStyle(zone.colorIndex));
|
||||||
@ -180,11 +186,11 @@ export default function useZoneEdit() {
|
|||||||
translate.on('translateend', () => {
|
translate.on('translateend', () => {
|
||||||
// Circle의 경우 center 업데이트
|
// Circle의 경우 center 업데이트
|
||||||
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) {
|
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);
|
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);
|
currentMap.addInteraction(translate);
|
||||||
translateRef.current = translate;
|
translateRef.current = translate;
|
||||||
@ -197,7 +203,7 @@ export default function useZoneEdit() {
|
|||||||
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
|
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
|
||||||
});
|
});
|
||||||
modify.on('modifyend', () => {
|
modify.on('modifyend', () => {
|
||||||
syncZoneToStore(zone.id, feature, zone);
|
syncZoneToStore(zone.id, feature as Feature<Geometry>, zone);
|
||||||
});
|
});
|
||||||
currentMap.addInteraction(modify);
|
currentMap.addInteraction(modify);
|
||||||
modifyRef.current = modify;
|
modifyRef.current = modify;
|
||||||
@ -205,19 +211,19 @@ export default function useZoneEdit() {
|
|||||||
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
|
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
|
||||||
const boxResize = new BoxResizeInteraction({
|
const boxResize = new BoxResizeInteraction({
|
||||||
feature,
|
feature,
|
||||||
onResize: () => syncZoneToStore(zone.id, feature, zone),
|
onResize: () => syncZoneToStore(zone.id, feature as Feature<Geometry>, zone),
|
||||||
});
|
});
|
||||||
currentMap.addInteraction(boxResize);
|
currentMap.addInteraction(boxResize);
|
||||||
customResizeRef.current = boxResize;
|
customResizeRef.current = boxResize;
|
||||||
|
|
||||||
} else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) {
|
} 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({
|
const circleResize = new CircleResizeInteraction({
|
||||||
feature,
|
feature,
|
||||||
center,
|
center,
|
||||||
onResize: (f) => {
|
onResize: (f: Feature<Polygon>) => {
|
||||||
// 리사이즈 후 circleMeta 업데이트
|
// 리사이즈 후 circleMeta 업데이트
|
||||||
const coords = f.getGeometry().getCoordinates()[0];
|
const coords = f.getGeometry()!.getCoordinates()[0];
|
||||||
const newCenter = computeCentroid(coords);
|
const newCenter = computeCentroid(coords);
|
||||||
const dx = coords[0][0] - newCenter[0];
|
const dx = coords[0][0] - newCenter[0];
|
||||||
const dy = coords[0][1] - newCenter[1];
|
const dy = coords[0][1] - newCenter[1];
|
||||||
@ -232,9 +238,9 @@ export default function useZoneEdit() {
|
|||||||
}, [removeInteractions]);
|
}, [removeInteractions]);
|
||||||
|
|
||||||
/** 구역 선택 해제 시 스타일 복원 */
|
/** 구역 선택 해제 시 스타일 복원 */
|
||||||
const restoreStyle = useCallback((zoneId) => {
|
const restoreStyle = useCallback((zoneId: string) => {
|
||||||
const { zones } = useAreaSearchStore.getState();
|
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) {
|
if (zone && zone.olFeature) {
|
||||||
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
|
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
|
||||||
}
|
}
|
||||||
@ -244,11 +250,11 @@ export default function useZoneEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
let prevSelectedId = null;
|
let prevSelectedId: string | null = null;
|
||||||
|
|
||||||
const unsub = useAreaSearchStore.subscribe(
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
(s) => s.selectedZoneId,
|
(s) => s.selectedZoneId,
|
||||||
(zoneId) => {
|
(zoneId: string | null) => {
|
||||||
// 이전 선택 스타일 복원
|
// 이전 선택 스타일 복원
|
||||||
if (prevSelectedId) restoreStyle(prevSelectedId);
|
if (prevSelectedId) restoreStyle(prevSelectedId);
|
||||||
prevSelectedId = zoneId;
|
prevSelectedId = zoneId;
|
||||||
@ -259,7 +265,7 @@ export default function useZoneEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { zones } = useAreaSearchStore.getState();
|
const { zones } = useAreaSearchStore.getState();
|
||||||
const zone = zones.find(z => z.id === zoneId);
|
const zone = zones.find((z: Zone) => z.id === zoneId);
|
||||||
if (zone) {
|
if (zone) {
|
||||||
setupInteractions(map, zone);
|
setupInteractions(map, zone);
|
||||||
}
|
}
|
||||||
@ -290,7 +296,7 @@ export default function useZoneEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const handleClick = (evt) => {
|
const handleClick = (evt: MapBrowserEvent<PointerEvent>) => {
|
||||||
// Drawing 중이면 무시
|
// Drawing 중이면 무시
|
||||||
if (useAreaSearchStore.getState().activeDrawType) return;
|
if (useAreaSearchStore.getState().activeDrawType) return;
|
||||||
|
|
||||||
@ -301,24 +307,24 @@ export default function useZoneEdit() {
|
|||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
|
||||||
// 클릭 지점의 feature 탐색
|
// 클릭 지점의 feature 탐색
|
||||||
let clickedZone = null;
|
let clickedZone: Zone | undefined;
|
||||||
const { zones } = useAreaSearchStore.getState();
|
const { zones } = useAreaSearchStore.getState();
|
||||||
|
|
||||||
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
||||||
if (clickedZone) return; // 이미 찾았으면 무시
|
if (clickedZone) return; // 이미 찾았으면 무시
|
||||||
const zone = zones.find(z => z.olFeature === feature);
|
const zone = zones.find((z: Zone) => z.olFeature === feature);
|
||||||
if (zone) clickedZone = zone;
|
if (zone) clickedZone = zone;
|
||||||
}, { layerFilter: (layer) => layer.getSource() === source });
|
}, { layerFilter: (layer) => layer.getSource() === source });
|
||||||
|
|
||||||
const { selectedZoneId } = useAreaSearchStore.getState();
|
const { selectedZoneId } = useAreaSearchStore.getState();
|
||||||
|
|
||||||
if (clickedZone) {
|
if (clickedZone) {
|
||||||
if (clickedZone.id === selectedZoneId) return; // 이미 선택됨
|
if ((clickedZone as Zone).id === selectedZoneId) return; // 이미 선택됨
|
||||||
|
|
||||||
// 결과 표시 중이면 confirmAndClearResults
|
// 결과 표시 중이면 confirmAndClearResults
|
||||||
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
|
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
|
||||||
|
|
||||||
useAreaSearchStore.getState().selectZone(clickedZone.id);
|
useAreaSearchStore.getState().selectZone((clickedZone as Zone).id);
|
||||||
} else {
|
} else {
|
||||||
// 빈 영역 클릭 → 선택 해제
|
// 빈 영역 클릭 → 선택 해제
|
||||||
if (selectedZoneId) {
|
if (selectedZoneId) {
|
||||||
@ -340,17 +346,17 @@ export default function useZoneEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const handleContextMenu = (e) => {
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
const { selectedZoneId, zones } = useAreaSearchStore.getState();
|
const { selectedZoneId, zones } = useAreaSearchStore.getState();
|
||||||
if (!selectedZoneId) return;
|
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;
|
if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return;
|
||||||
|
|
||||||
const feature = zone.olFeature;
|
const feature = zone.olFeature;
|
||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
|
|
||||||
const geom = feature.getGeometry();
|
const geom = feature.getGeometry() as Polygon;
|
||||||
const coords = geom.getCoordinates()[0];
|
const coords = geom.getCoordinates()[0];
|
||||||
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
|
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
|
||||||
if (vertexCount <= 3) return; // 최소 삼각형 유지
|
if (vertexCount <= 3) return; // 최소 삼각형 유지
|
||||||
@ -380,7 +386,7 @@ export default function useZoneEdit() {
|
|||||||
newCoords[newCoords.length - 1] = [...newCoords[0]];
|
newCoords[newCoords.length - 1] = [...newCoords[0]];
|
||||||
}
|
}
|
||||||
geom.setCoordinates([newCoords]);
|
geom.setCoordinates([newCoords]);
|
||||||
syncZoneToStore(zone.id, feature, zone);
|
syncZoneToStore(zone.id, feature as Feature<Geometry>, zone);
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewport = map.getViewport();
|
const viewport = map.getViewport();
|
||||||
@ -395,7 +401,7 @@ export default function useZoneEdit() {
|
|||||||
|
|
||||||
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
|
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
|
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
|
||||||
|
|
||||||
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
|
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
|
||||||
@ -423,7 +429,7 @@ export default function useZoneEdit() {
|
|||||||
|
|
||||||
const viewport = map.getViewport();
|
const viewport = map.getViewport();
|
||||||
|
|
||||||
const handlePointerMove = (evt) => {
|
const handlePointerMove = (evt: MapBrowserEvent<PointerEvent>) => {
|
||||||
if (evt.dragging) return;
|
if (evt.dragging) return;
|
||||||
|
|
||||||
// Drawing 중이면 호버 해제
|
// Drawing 중이면 호버 해제
|
||||||
@ -443,11 +449,11 @@ export default function useZoneEdit() {
|
|||||||
|
|
||||||
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
|
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
|
||||||
if (selectedZoneId) {
|
if (selectedZoneId) {
|
||||||
const zone = zones.find(z => z.id === selectedZoneId);
|
const zone = zones.find((z: Zone) => z.id === selectedZoneId);
|
||||||
if (zone && zone.olFeature) {
|
if (zone && zone.olFeature) {
|
||||||
// Box/Circle: isOverHandle
|
// Box/Circle: isOverHandle
|
||||||
if (customResizeRef.current && customResizeRef.current.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) {
|
if (handle) {
|
||||||
viewport.style.cursor = handle.cursor;
|
viewport.style.cursor = handle.cursor;
|
||||||
return;
|
return;
|
||||||
@ -456,7 +462,7 @@ export default function useZoneEdit() {
|
|||||||
|
|
||||||
// Polygon: 꼭짓점/변 근접
|
// Polygon: 꼭짓점/변 근접
|
||||||
if (zone.type === ZONE_DRAW_TYPES.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';
|
viewport.style.cursor = 'crosshair';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -476,19 +482,20 @@ export default function useZoneEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 비선택 구역 호버
|
// 2. 비선택 구역 호버
|
||||||
let hoveredZone = null;
|
let hoveredZone: Zone | undefined;
|
||||||
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
|
||||||
if (hoveredZone) return;
|
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;
|
if (zone) hoveredZone = zone;
|
||||||
}, { layerFilter: (l) => l.getSource() === source });
|
}, { layerFilter: (l) => l.getSource() === source });
|
||||||
|
|
||||||
if (hoveredZone) {
|
if (hoveredZone) {
|
||||||
|
const hz = hoveredZone as Zone;
|
||||||
viewport.style.cursor = 'pointer';
|
viewport.style.cursor = 'pointer';
|
||||||
if (hoveredZoneIdRef.current !== hoveredZone.id) {
|
if (hoveredZoneIdRef.current !== hz.id) {
|
||||||
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
|
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
|
||||||
hoveredZoneIdRef.current = hoveredZone.id;
|
hoveredZoneIdRef.current = hz.id;
|
||||||
hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex));
|
hz.olFeature!.setStyle(createHoverStyle(hz.colorIndex));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewport.style.cursor = '';
|
viewport.style.cursor = '';
|
||||||
@ -6,12 +6,17 @@
|
|||||||
* - 변 드래그: 반대쪽 변 고정, 1축 리사이즈
|
* - 변 드래그: 반대쪽 변 고정, 1축 리사이즈
|
||||||
*/
|
*/
|
||||||
import PointerInteraction from 'ol/interaction/Pointer';
|
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 CORNER_TOLERANCE = 16;
|
||||||
const EDGE_TOLERANCE = 12;
|
const EDGE_TOLERANCE = 12;
|
||||||
|
|
||||||
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
|
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
|
||||||
function pointToSegmentDist(p, a, b) {
|
function pointToSegmentDist(p: number[], a: number[], b: number[]): number {
|
||||||
const dx = b[0] - a[0];
|
const dx = b[0] - a[0];
|
||||||
const dy = b[1] - a[1];
|
const dy = b[1] - a[1];
|
||||||
const lenSq = dx * dx + dy * dy;
|
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));
|
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 {
|
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({
|
super({
|
||||||
handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
|
handleDownEvent: (evt: MapBrowserEvent<PointerEvent>) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
|
||||||
handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
|
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
|
||||||
handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt),
|
handleUpEvent: () => BoxResizeInteraction.prototype._handleUp.call(this),
|
||||||
});
|
});
|
||||||
this.feature_ = options.feature;
|
this.feature_ = options.feature;
|
||||||
this.onResize_ = options.onResize || null;
|
this.onResize_ = options.onResize || null;
|
||||||
// corner mode
|
// corner mode
|
||||||
this.mode_ = null; // 'corner' | 'edge'
|
this.mode_ = null; // 'corner' | 'edge'
|
||||||
this.draggedIndex_ = null;
|
|
||||||
this.anchorCoord_ = null;
|
this.anchorCoord_ = null;
|
||||||
// edge mode
|
// edge mode
|
||||||
this.edgeIndex_ = null;
|
this.edgeIndex_ = null;
|
||||||
this.bbox_ = null;
|
this.bbox_ = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleDown(evt) {
|
private _handleDown(evt: MapBrowserEvent<PointerEvent>): boolean {
|
||||||
const pixel = evt.pixel;
|
const pixel = evt.pixel as unknown as number[];
|
||||||
const coords = this.feature_.getGeometry().getCoordinates()[0];
|
const coords = this.feature_.getGeometry()!.getCoordinates()[0];
|
||||||
|
|
||||||
// 1. 모서리 감지 (우선)
|
// 1. 모서리 감지 (우선)
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const vp = evt.map.getPixelFromCoordinate(coords[i]);
|
const vp = evt.map.getPixelFromCoordinate(coords[i]);
|
||||||
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
|
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
|
||||||
this.mode_ = 'corner';
|
this.mode_ = 'corner';
|
||||||
this.draggedIndex_ = i;
|
|
||||||
this.anchorCoord_ = coords[(i + 2) % 4];
|
this.anchorCoord_ = coords[(i + 2) % 4];
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -59,11 +87,11 @@ export default class BoxResizeInteraction extends PointerInteraction {
|
|||||||
const j = (i + 1) % 4;
|
const j = (i + 1) % 4;
|
||||||
const p1 = evt.map.getPixelFromCoordinate(coords[i]);
|
const p1 = evt.map.getPixelFromCoordinate(coords[i]);
|
||||||
const p2 = evt.map.getPixelFromCoordinate(coords[j]);
|
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.mode_ = 'edge';
|
||||||
this.edgeIndex_ = i;
|
this.edgeIndex_ = i;
|
||||||
const xs = coords.slice(0, 4).map(c => c[0]);
|
const xs = coords.slice(0, 4).map((c: Coordinate) => c[0]);
|
||||||
const ys = coords.slice(0, 4).map(c => c[1]);
|
const ys = coords.slice(0, 4).map((c: Coordinate) => c[1]);
|
||||||
this.bbox_ = {
|
this.bbox_ = {
|
||||||
minX: Math.min(...xs), maxX: Math.max(...xs),
|
minX: Math.min(...xs), maxX: Math.max(...xs),
|
||||||
minY: Math.min(...ys), maxY: Math.max(...ys),
|
minY: Math.min(...ys), maxY: Math.max(...ys),
|
||||||
@ -75,21 +103,21 @@ export default class BoxResizeInteraction extends PointerInteraction {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleDrag(evt) {
|
private _handleDrag(evt: MapBrowserEvent<PointerEvent>): void {
|
||||||
const coord = evt.coordinate;
|
const coord = evt.coordinate;
|
||||||
|
|
||||||
if (this.mode_ === 'corner') {
|
if (this.mode_ === 'corner') {
|
||||||
const anchor = this.anchorCoord_;
|
const anchor = this.anchorCoord_!;
|
||||||
const minX = Math.min(coord[0], anchor[0]);
|
const minX = Math.min(coord[0], anchor[0]);
|
||||||
const maxX = Math.max(coord[0], anchor[0]);
|
const maxX = Math.max(coord[0], anchor[0]);
|
||||||
const minY = Math.min(coord[1], anchor[1]);
|
const minY = Math.min(coord[1], anchor[1]);
|
||||||
const maxY = Math.max(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],
|
[minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY],
|
||||||
]]);
|
]]);
|
||||||
} else if (this.mode_ === 'edge') {
|
} else if (this.mode_ === 'edge') {
|
||||||
let { minX, maxX, minY, maxY } = this.bbox_;
|
let { minX, maxX, minY, maxY } = this.bbox_!;
|
||||||
// Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL)
|
// Edge 0: top(TL->TR), 1: right(TR->BR), 2: bottom(BR->BL), 3: left(BL->TL)
|
||||||
switch (this.edgeIndex_) {
|
switch (this.edgeIndex_) {
|
||||||
case 0: maxY = coord[1]; break;
|
case 0: maxY = coord[1]; break;
|
||||||
case 1: maxX = coord[0]; 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 x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX);
|
||||||
const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY);
|
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],
|
[x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2],
|
||||||
]]);
|
]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleUp() {
|
private _handleUp(): boolean {
|
||||||
if (this.mode_) {
|
if (this.mode_) {
|
||||||
this.mode_ = null;
|
this.mode_ = null;
|
||||||
this.draggedIndex_ = null;
|
|
||||||
this.anchorCoord_ = null;
|
this.anchorCoord_ = null;
|
||||||
this.edgeIndex_ = null;
|
this.edgeIndex_ = null;
|
||||||
this.bbox_ = null;
|
this.bbox_ = null;
|
||||||
@ -119,10 +146,9 @@ export default class BoxResizeInteraction extends PointerInteraction {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인
|
* 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인
|
||||||
* @returns {{ cursor: string }} | null
|
|
||||||
*/
|
*/
|
||||||
isOverHandle(map, pixel) {
|
isOverHandle(map: OlMap, pixel: number[]): HandleResult | null {
|
||||||
const coords = this.feature_.getGeometry().getCoordinates()[0];
|
const coords = this.feature_.getGeometry()!.getCoordinates()[0];
|
||||||
|
|
||||||
// 모서리 감지
|
// 모서리 감지
|
||||||
const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize'];
|
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 j = (i + 1) % 4;
|
||||||
const p1 = map.getPixelFromCoordinate(coords[i]);
|
const p1 = map.getPixelFromCoordinate(coords[i]);
|
||||||
const p2 = map.getPixelFromCoordinate(coords[j]);
|
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] };
|
return { cursor: edgeCursors[i] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10,16 +10,35 @@
|
|||||||
import PointerInteraction from 'ol/interaction/Pointer';
|
import PointerInteraction from 'ol/interaction/Pointer';
|
||||||
import { fromCircle } from 'ol/geom/Polygon';
|
import { fromCircle } from 'ol/geom/Polygon';
|
||||||
import OlCircle from 'ol/geom/Circle';
|
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 PIXEL_TOLERANCE = 16;
|
||||||
const MIN_RADIUS = 100; // 최소 반지름 (미터)
|
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 {
|
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({
|
super({
|
||||||
handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
|
handleDownEvent: (evt: MapBrowserEvent<PointerEvent>) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
|
||||||
handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
|
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
|
||||||
handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt),
|
handleUpEvent: () => CircleResizeInteraction.prototype._handleUp.call(this),
|
||||||
});
|
});
|
||||||
this.feature_ = options.feature;
|
this.feature_ = options.feature;
|
||||||
this.center_ = options.center; // EPSG:3857 [x, y]
|
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 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 edgePixel = map.getPixelFromCoordinate(coords[0]);
|
||||||
const radiusPixels = Math.hypot(
|
const radiusPixels = Math.hypot(
|
||||||
edgePixel[0] - centerPixel[0],
|
edgePixel[0] - centerPixel[0],
|
||||||
@ -43,15 +62,15 @@ export default class CircleResizeInteraction extends PointerInteraction {
|
|||||||
return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE;
|
return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleDown(evt) {
|
private _handleDown(evt: MapBrowserEvent<PointerEvent>): boolean {
|
||||||
if (this._isNearEdge(evt.map, evt.pixel)) {
|
if (this._isNearEdge(evt.map, evt.pixel as unknown as number[])) {
|
||||||
this.dragging_ = true;
|
this.dragging_ = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleDrag(evt) {
|
private _handleDrag(evt: MapBrowserEvent<PointerEvent>): void {
|
||||||
if (!this.dragging_) return;
|
if (!this.dragging_) return;
|
||||||
const coord = evt.coordinate;
|
const coord = evt.coordinate;
|
||||||
const dx = coord[0] - this.center_[0];
|
const dx = coord[0] - this.center_[0];
|
||||||
@ -63,7 +82,7 @@ export default class CircleResizeInteraction extends PointerInteraction {
|
|||||||
this.feature_.setGeometry(polyGeom);
|
this.feature_.setGeometry(polyGeom);
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleUp() {
|
private _handleUp(): boolean {
|
||||||
if (this.dragging_) {
|
if (this.dragging_) {
|
||||||
this.dragging_ = false;
|
this.dragging_ = false;
|
||||||
if (this.onResize_) this.onResize_(this.feature_);
|
if (this.onResize_) this.onResize_(this.feature_);
|
||||||
@ -73,15 +92,14 @@ export default class CircleResizeInteraction extends PointerInteraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 외부에서 center 업데이트 (Translate 후) */
|
/** 외부에서 center 업데이트 (Translate 후) */
|
||||||
setCenter(center) {
|
setCenter(center: [number, number]): void {
|
||||||
this.center_ = center;
|
this.center_ = center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인
|
* 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인
|
||||||
* @returns {{ cursor: string }} | null
|
|
||||||
*/
|
*/
|
||||||
isOverHandle(map, pixel) {
|
isOverHandle(map: OlMap, pixel: number[]): HandleResult | null {
|
||||||
if (this._isNearEdge(map, pixel)) {
|
if (this._isNearEdge(map, pixel)) {
|
||||||
return { cursor: 'nesw-resize' };
|
return { cursor: 'nesw-resize' };
|
||||||
}
|
}
|
||||||
@ -6,14 +6,35 @@
|
|||||||
*/
|
*/
|
||||||
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
||||||
import { fetchWithAuth } from '../../api/fetchWithAuth';
|
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';
|
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]을 계산
|
* track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat]을 계산
|
||||||
*/
|
*/
|
||||||
function interpolatePositionAtTime(track, targetTime) {
|
function interpolatePositionAtTime(track: ProcessedTrack, targetTime: number | null): number[] | null {
|
||||||
const { timestampsMs, geometry } = track;
|
const { timestampsMs, geometry } = track;
|
||||||
if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null;
|
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 = {
|
const request = {
|
||||||
startTime: params.startTime,
|
startTime: params.startTime,
|
||||||
endTime: params.endTime,
|
endTime: params.endTime,
|
||||||
@ -76,20 +90,21 @@ export async function fetchAreaSearch(params) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
|
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
|
||||||
const tracks = convertToProcessedTracks(rawTracks);
|
const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[];
|
||||||
|
|
||||||
// vesselId → track 빠른 조회용
|
// vesselId → track 빠른 조회용
|
||||||
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
|
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
|
||||||
|
|
||||||
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
|
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
|
||||||
const rawHitDetails = result.hitDetails || {};
|
const rawHitDetails = result.hitDetails || {};
|
||||||
const hitDetails = {};
|
const hitDetails: Record<string, HitDetail[]> = {};
|
||||||
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
|
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
|
||||||
const track = trackMap.get(vesselId);
|
const track = trackMap.get(vesselId);
|
||||||
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const toMs = (ts) => {
|
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit: any) => {
|
||||||
|
const toMs = (ts: string | number | null | undefined): number | null => {
|
||||||
if (!ts) return 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;
|
return num < 10000000000 ? num * 1000 : num;
|
||||||
};
|
};
|
||||||
const entryMs = toMs(hit.entryTimestamp);
|
const entryMs = toMs(hit.entryTimestamp);
|
||||||
@ -6,30 +6,45 @@
|
|||||||
*/
|
*/
|
||||||
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
||||||
import { fetchWithAuth } from '../../api/fetchWithAuth';
|
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';
|
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 초/밀리초 → 밀리초 변환
|
* Unix 초/밀리초 → 밀리초 변환
|
||||||
*/
|
*/
|
||||||
function toMs(ts) {
|
function toMs(ts: string | number | null | undefined): number | null {
|
||||||
if (!ts) return 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;
|
return num < 10000000000 ? num * 1000 : num;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* STS 접촉 검출 API 호출
|
* 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 = {
|
const request = {
|
||||||
startTime: params.startTime,
|
startTime: params.startTime,
|
||||||
endTime: params.endTime,
|
endTime: params.endTime,
|
||||||
@ -52,11 +67,13 @@ export async function fetchVesselContacts(params) {
|
|||||||
|
|
||||||
// tracks 변환
|
// tracks 변환
|
||||||
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
|
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
|
||||||
const tracks = convertToProcessedTracks(rawTracks);
|
const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[];
|
||||||
|
|
||||||
// contacts: timestamp 초→밀리초 변환
|
// contacts: timestamp 초→밀리초 변환
|
||||||
const rawContacts = Array.isArray(result.contacts) ? result.contacts : [];
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const contacts = rawContacts.map((c) => ({
|
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,
|
...c,
|
||||||
contactStartTimestamp: toMs(c.contactStartTimestamp),
|
contactStartTimestamp: toMs(c.contactStartTimestamp),
|
||||||
contactEndTimestamp: toMs(c.contactEndTimestamp),
|
contactEndTimestamp: toMs(c.contactEndTimestamp),
|
||||||
@ -9,11 +9,28 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
|
||||||
let animationFrameId = null;
|
let animationFrameId: number | null = null;
|
||||||
let lastFrameTime = null;
|
let lastFrameTime: number | null = null;
|
||||||
|
|
||||||
export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => {
|
interface AreaSearchAnimationState {
|
||||||
const animate = () => {
|
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();
|
const state = get();
|
||||||
if (!state.isPlaying) return;
|
if (!state.isPlaying) return;
|
||||||
|
|
||||||
@ -9,12 +9,50 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types';
|
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';
|
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 (t1 === t2) return p1;
|
||||||
if (currentTime <= t1) return p1;
|
if (currentTime <= t1) return p1;
|
||||||
if (currentTime >= t2) return p2;
|
if (currentTime >= t2) return p2;
|
||||||
@ -25,7 +63,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) {
|
|||||||
/**
|
/**
|
||||||
* 두 지점 간 방향(heading) 계산
|
* 두 지점 간 방향(heading) 계산
|
||||||
*/
|
*/
|
||||||
function calculateHeading(p1, p2) {
|
function calculateHeading(p1: number[], p2: number[]): number {
|
||||||
const [lon1, lat1] = p1;
|
const [lon1, lat1] = p1;
|
||||||
const [lon2, lat2] = p2;
|
const [lon2, lat2] = p2;
|
||||||
const dx = lon2 - lon1;
|
const dx = lon2 - lon1;
|
||||||
@ -39,9 +77,81 @@ let zoneIdCounter = 0;
|
|||||||
|
|
||||||
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
|
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
|
||||||
// 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback
|
// 재생 중 시간은 단조 증가 → 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,
|
activeTab: ANALYSIS_TABS.AREA,
|
||||||
|
|
||||||
@ -57,7 +167,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
// UI 상태
|
// UI 상태
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
disabledVesselIds: new Set(),
|
disabledVesselIds: new Set<string>(),
|
||||||
highlightedVesselId: null,
|
highlightedVesselId: null,
|
||||||
showZones: true,
|
showZones: true,
|
||||||
activeDrawType: null,
|
activeDrawType: null,
|
||||||
@ -81,7 +191,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
let colorIndex = 0;
|
let colorIndex = 0;
|
||||||
while (usedColors.has(colorIndex)) colorIndex++;
|
while (usedColors.has(colorIndex)) colorIndex++;
|
||||||
|
|
||||||
const newZone = {
|
const newZone: Zone = {
|
||||||
...zone,
|
...zone,
|
||||||
id: `zone-${++zoneIdCounter}`,
|
id: `zone-${++zoneIdCounter}`,
|
||||||
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
|
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
|
||||||
@ -94,7 +204,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
removeZone: (zoneId) => {
|
removeZone: (zoneId) => {
|
||||||
const { zones, selectedZoneId } = get();
|
const { zones, selectedZoneId } = get();
|
||||||
const filtered = zones.filter(z => z.id !== zoneId);
|
const filtered = zones.filter(z => z.id !== zoneId);
|
||||||
const updates = { zones: filtered };
|
const updates: Partial<AreaSearchState> = { zones: filtered };
|
||||||
if (selectedZoneId === zoneId) updates.selectedZoneId = null;
|
if (selectedZoneId === zoneId) updates.selectedZoneId = null;
|
||||||
set(updates);
|
set(updates);
|
||||||
},
|
},
|
||||||
@ -130,7 +240,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
const { zones } = get();
|
const { zones } = get();
|
||||||
const updated = zones.map(z => {
|
const updated = zones.map(z => {
|
||||||
if (z.id !== zoneId) return z;
|
if (z.id !== zoneId) return z;
|
||||||
const patch = { ...z, coordinates: coordinates4326 };
|
const patch: Zone = { ...z, coordinates: coordinates4326 };
|
||||||
if (circleMeta !== undefined) patch.circleMeta = circleMeta;
|
if (circleMeta !== undefined) patch.circleMeta = circleMeta;
|
||||||
return patch;
|
return patch;
|
||||||
});
|
});
|
||||||
@ -140,7 +250,6 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
/**
|
/**
|
||||||
* 조회 조건 변경 시 결과 초기화 확인
|
* 조회 조건 변경 시 결과 초기화 확인
|
||||||
* 결과가 없으면 true 반환, 있으면 confirm 후 초기화
|
* 결과가 없으면 true 반환, 있으면 confirm 후 초기화
|
||||||
* @returns {boolean} 진행 허용 여부
|
|
||||||
*/
|
*/
|
||||||
confirmAndClearResults: () => {
|
confirmAndClearResults: () => {
|
||||||
const { queryCompleted } = get();
|
const { queryCompleted } = get();
|
||||||
@ -209,7 +318,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
*/
|
*/
|
||||||
getCurrentPositions: (currentTime) => {
|
getCurrentPositions: (currentTime) => {
|
||||||
const { tracks, disabledVesselIds } = get();
|
const { tracks, disabledVesselIds } = get();
|
||||||
const positions = [];
|
const positions: VesselPosition[] = [];
|
||||||
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
if (disabledVesselIds.has(track.vesselId)) return;
|
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 idx1 = Math.max(0, cursor - 1);
|
||||||
const idx2 = Math.min(timestampsMs.length - 1, cursor);
|
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]) {
|
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
|
||||||
position = geometry[idx1];
|
position = geometry[idx1];
|
||||||
@ -281,7 +390,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
hitDetails: {},
|
hitDetails: {},
|
||||||
summary: null,
|
summary: null,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
disabledVesselIds: new Set(),
|
disabledVesselIds: new Set<string>(),
|
||||||
highlightedVesselId: null,
|
highlightedVesselId: null,
|
||||||
areaSearchTooltip: null,
|
areaSearchTooltip: null,
|
||||||
showPaths: true,
|
showPaths: true,
|
||||||
@ -301,7 +410,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
summary: null,
|
summary: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
disabledVesselIds: new Set(),
|
disabledVesselIds: new Set<string>(),
|
||||||
highlightedVesselId: null,
|
highlightedVesselId: null,
|
||||||
showZones: true,
|
showZones: true,
|
||||||
activeDrawType: null,
|
activeDrawType: null,
|
||||||
@ -9,8 +9,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
import { STS_DEFAULTS } from '../types/sts.types';
|
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 (t1 === t2) return p1;
|
||||||
if (currentTime <= t1) return p1;
|
if (currentTime <= t1) return p1;
|
||||||
if (currentTime >= t2) return p2;
|
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];
|
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 [lon1, lat1] = p1;
|
||||||
const [lon2, lat2] = p2;
|
const [lon2, lat2] = p2;
|
||||||
const dx = lon2 - lon1;
|
const dx = lon2 - lon1;
|
||||||
@ -28,11 +31,18 @@ function calculateHeading(p1, p2) {
|
|||||||
return angle;
|
return angle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StsSummary {
|
||||||
|
totalContactPairs: number;
|
||||||
|
totalVesselsInvolved: number;
|
||||||
|
totalVesselsInPolygon: number;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contacts 배열을 선박 쌍 기준으로 그룹핑
|
* contacts 배열을 선박 쌍 기준으로 그룹핑
|
||||||
*/
|
*/
|
||||||
function groupContactsByPair(contacts) {
|
function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] {
|
||||||
const groupMap = new Map();
|
const groupMap = new Map<string, StsGroupedContact>();
|
||||||
|
|
||||||
contacts.forEach((contact) => {
|
contacts.forEach((contact) => {
|
||||||
const v1Id = contact.vessel1.vesselId;
|
const v1Id = contact.vessel1.vesselId;
|
||||||
@ -45,13 +55,19 @@ function groupContactsByPair(contacts) {
|
|||||||
vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2,
|
vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2,
|
||||||
vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1,
|
vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1,
|
||||||
contacts: [],
|
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) => {
|
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(
|
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 파라미터
|
// STS 파라미터
|
||||||
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
|
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
|
||||||
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
|
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
|
||||||
@ -106,7 +168,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
highlightedGroupIndex: null,
|
highlightedGroupIndex: null,
|
||||||
disabledGroupIndices: new Set(),
|
disabledGroupIndices: new Set<number>(),
|
||||||
expandedGroupIndex: null,
|
expandedGroupIndex: null,
|
||||||
|
|
||||||
// 필터 상태
|
// 필터 상태
|
||||||
@ -129,7 +191,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
tracks,
|
tracks,
|
||||||
summary,
|
summary,
|
||||||
queryCompleted: true,
|
queryCompleted: true,
|
||||||
disabledGroupIndices: new Set(),
|
disabledGroupIndices: new Set<number>(),
|
||||||
highlightedGroupIndex: null,
|
highlightedGroupIndex: null,
|
||||||
expandedGroupIndex: null,
|
expandedGroupIndex: null,
|
||||||
});
|
});
|
||||||
@ -160,7 +222,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
|
|
||||||
getDisabledVesselIds: () => {
|
getDisabledVesselIds: () => {
|
||||||
const { groupedContacts, disabledGroupIndices } = get();
|
const { groupedContacts, disabledGroupIndices } = get();
|
||||||
const ids = new Set();
|
const ids = new Set<string>();
|
||||||
disabledGroupIndices.forEach((idx) => {
|
disabledGroupIndices.forEach((idx) => {
|
||||||
const g = groupedContacts[idx];
|
const g = groupedContacts[idx];
|
||||||
if (g) {
|
if (g) {
|
||||||
@ -174,7 +236,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
getCurrentPositions: (currentTime) => {
|
getCurrentPositions: (currentTime) => {
|
||||||
const { tracks } = get();
|
const { tracks } = get();
|
||||||
const disabledVesselIds = get().getDisabledVesselIds();
|
const disabledVesselIds = get().getDisabledVesselIds();
|
||||||
const positions = [];
|
const positions: VesselPosition[] = [];
|
||||||
|
|
||||||
tracks.forEach((track) => {
|
tracks.forEach((track) => {
|
||||||
if (disabledVesselIds.has(track.vesselId)) return;
|
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 idx1 = Math.max(0, cursor - 1);
|
||||||
const idx2 = Math.min(timestampsMs.length - 1, cursor);
|
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]) {
|
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
|
||||||
position = geometry[idx1];
|
position = geometry[idx1];
|
||||||
@ -244,7 +306,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
tracks: [],
|
tracks: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
disabledGroupIndices: new Set(),
|
disabledGroupIndices: new Set<number>(),
|
||||||
highlightedGroupIndex: null,
|
highlightedGroupIndex: null,
|
||||||
expandedGroupIndex: null,
|
expandedGroupIndex: null,
|
||||||
showPaths: true,
|
showPaths: true,
|
||||||
@ -263,7 +325,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
|
|||||||
summary: null,
|
summary: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
queryCompleted: false,
|
queryCompleted: false,
|
||||||
disabledGroupIndices: new Set(),
|
disabledGroupIndices: new Set<number>(),
|
||||||
highlightedGroupIndex: null,
|
highlightedGroupIndex: null,
|
||||||
expandedGroupIndex: null,
|
expandedGroupIndex: null,
|
||||||
showPaths: true,
|
showPaths: true,
|
||||||
@ -2,12 +2,17 @@
|
|||||||
* 항적분석(구역 검색) 상수 및 타입 정의
|
* 항적분석(구역 검색) 상수 및 타입 정의
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type Feature from 'ol/Feature';
|
||||||
|
import type { Geometry } from 'ol/geom';
|
||||||
|
|
||||||
// ========== 분석 탭 ==========
|
// ========== 분석 탭 ==========
|
||||||
|
|
||||||
export const ANALYSIS_TABS = {
|
export const ANALYSIS_TABS = {
|
||||||
AREA: 'area',
|
AREA: 'area',
|
||||||
STS: 'sts',
|
STS: 'sts',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type AnalysisTab = typeof ANALYSIS_TABS[keyof typeof ANALYSIS_TABS];
|
||||||
|
|
||||||
// ========== 검색 모드 ==========
|
// ========== 검색 모드 ==========
|
||||||
|
|
||||||
@ -15,9 +20,11 @@ export const SEARCH_MODES = {
|
|||||||
ANY: 'ANY',
|
ANY: 'ANY',
|
||||||
ALL: 'ALL',
|
ALL: 'ALL',
|
||||||
SEQUENTIAL: 'SEQUENTIAL',
|
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.ANY]: 'ANY (합집합)',
|
||||||
[SEARCH_MODES.ALL]: 'ALL (교집합)',
|
[SEARCH_MODES.ALL]: 'ALL (교집합)',
|
||||||
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
|
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
|
||||||
@ -31,11 +38,19 @@ export const ZONE_DRAW_TYPES = {
|
|||||||
POLYGON: 'Polygon',
|
POLYGON: 'Polygon',
|
||||||
BOX: 'Box',
|
BOX: 'Box',
|
||||||
CIRCLE: 'Circle',
|
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_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: [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: [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' },
|
{ 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)
|
* 조회 가능 기간 계산 (D-7 ~ D-1)
|
||||||
* 인메모리 캐시 기반, 오늘 데이터 없음
|
* 인메모리 캐시 기반, 오늘 데이터 없음
|
||||||
*/
|
*/
|
||||||
export function getQueryDateRange() {
|
export function getQueryDateRange(): { startDate: Date; endDate: Date } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const endDate = new Date(now);
|
const endDate = new Date(now);
|
||||||
@ -80,7 +95,7 @@ import {
|
|||||||
SIGNAL_KIND_CODE_BUOY,
|
SIGNAL_KIND_CODE_BUOY,
|
||||||
} from '../../types/constants';
|
} from '../../types/constants';
|
||||||
|
|
||||||
export const ALL_SHIP_KIND_CODES = [
|
export const ALL_SHIP_KIND_CODES: string[] = [
|
||||||
SIGNAL_KIND_CODE_FISHING,
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
SIGNAL_KIND_CODE_KCGV,
|
SIGNAL_KIND_CODE_KCGV,
|
||||||
SIGNAL_KIND_CODE_PASSENGER,
|
SIGNAL_KIND_CODE_PASSENGER,
|
||||||
@ -98,4 +113,58 @@ export const AREA_SEARCH_LAYER_IDS = {
|
|||||||
TRIPS_TRAIL: 'area-search-trips-trail',
|
TRIPS_TRAIL: 'area-search-trips-trail',
|
||||||
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
|
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
|
||||||
VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-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 = {
|
export const STS_DEFAULTS = {
|
||||||
MIN_CONTACT_DURATION: 60,
|
MIN_CONTACT_DURATION: 60,
|
||||||
MAX_CONTACT_DISTANCE: 500,
|
MAX_CONTACT_DISTANCE: 500,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export const STS_LIMITS = {
|
export const STS_LIMITS = {
|
||||||
DURATION_MIN: 30,
|
DURATION_MIN: 30,
|
||||||
DURATION_MAX: 360,
|
DURATION_MAX: 360,
|
||||||
DISTANCE_MIN: 50,
|
DISTANCE_MIN: 50,
|
||||||
DISTANCE_MAX: 5000,
|
DISTANCE_MAX: 5000,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
// ========== 레이어 ID ==========
|
// ========== 레이어 ID ==========
|
||||||
|
|
||||||
@ -26,22 +26,71 @@ export const STS_LAYER_IDS = {
|
|||||||
TRIPS_TRAIL: 'sts-trips-trail-layer',
|
TRIPS_TRAIL: 'sts-trips-trail-layer',
|
||||||
VIRTUAL_SHIP: 'sts-virtual-ship-layer',
|
VIRTUAL_SHIP: 'sts-virtual-ship-layer',
|
||||||
VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer',
|
VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
// ========== Indicator 라벨 ==========
|
// ========== Indicator 라벨 ==========
|
||||||
|
|
||||||
export const INDICATOR_LABELS = {
|
export const INDICATOR_LABELS: Record<string, string> = {
|
||||||
lowSpeedContact: '저속',
|
lowSpeedContact: '저속',
|
||||||
differentVesselTypes: '이종',
|
differentVesselTypes: '이종',
|
||||||
differentNationalities: '외국적',
|
differentNationalities: '외국적',
|
||||||
nightTimeContact: '야간',
|
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 뱃지에 맥락 정보를 포함한 텍스트 생성
|
* indicator 뱃지에 맥락 정보를 포함한 텍스트 생성
|
||||||
* 예: "저속 1.2/0.8kn", "이종 어선↔화물선"
|
* 예: "저속 1.2/0.8kn", "이종 어선↔화물선"
|
||||||
*/
|
*/
|
||||||
export function getIndicatorDetail(key, contact) {
|
export function getIndicatorDetail(key: string, contact: StsContact): string {
|
||||||
const { vessel1, vessel2 } = contact;
|
const { vessel1, vessel2 } = contact;
|
||||||
|
|
||||||
switch (key) {
|
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 == null) return '-';
|
||||||
if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`;
|
if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`;
|
||||||
return `${Math.round(meters)}m`;
|
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 == null) return '-';
|
||||||
if (minutes < 60) return `${minutes}분`;
|
if (minutes < 60) return `${minutes}분`;
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@ -96,7 +145,7 @@ export function formatDuration(minutes) {
|
|||||||
* contact의 indicators 활성 개수에 따라 위험도 색상 반환
|
* contact의 indicators 활성 개수에 따라 위험도 색상 반환
|
||||||
* 3+: 빨강, 2: 주황, 1: 노랑, 0: 회색
|
* 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];
|
if (!indicators) return [150, 150, 150, 200];
|
||||||
const count = Object.values(indicators).filter(Boolean).length;
|
const count = Object.values(indicators).filter(Boolean).length;
|
||||||
if (count >= 3) return [231, 76, 60, 220];
|
if (count >= 3) return [231, 76, 60, 220];
|
||||||
@ -1,19 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* 항적분석 레이어 전역 레지스트리
|
* 항적분석 레이어 전역 레지스트리
|
||||||
* 참조: src/replay/utils/replayLayerRegistry.js
|
* 참조: src/replay/utils/replayLayerRegistry.ts
|
||||||
*
|
*
|
||||||
* useAreaSearchLayer 훅이 레이어를 등록하면
|
* useAreaSearchLayer 훅이 레이어를 등록하면
|
||||||
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
|
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function registerAreaSearchLayers(layers) {
|
import type { Layer } from '@deck.gl/core';
|
||||||
|
|
||||||
|
export function registerAreaSearchLayers(layers: Layer[]): void {
|
||||||
window.__areaSearchLayers__ = layers;
|
window.__areaSearchLayers__ = layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAreaSearchLayers() {
|
export function getAreaSearchLayers(): Layer[] {
|
||||||
return window.__areaSearchLayers__ || [];
|
return window.__areaSearchLayers__ || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterAreaSearchLayers() {
|
export function unregisterAreaSearchLayers(): void {
|
||||||
window.__areaSearchLayers__ = [];
|
window.__areaSearchLayers__ = [];
|
||||||
}
|
}
|
||||||
@ -4,15 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils';
|
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 '';
|
if (!ms) return '';
|
||||||
const d = new Date(ms);
|
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())}`;
|
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 '';
|
if (!pos || pos.length < 2) return '';
|
||||||
const lon = pos[0];
|
const lon = pos[0];
|
||||||
const lat = pos[1];
|
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}`;
|
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 ?? '');
|
const str = String(value ?? '');
|
||||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
return `"${str.replace(/"/g, '""')}"`;
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
@ -32,16 +34,20 @@ function escapeCsvField(value) {
|
|||||||
/**
|
/**
|
||||||
* 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원)
|
* 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원)
|
||||||
*
|
*
|
||||||
* @param {Array} tracks ProcessedTrack 배열
|
* @param tracks ProcessedTrack 배열
|
||||||
* @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
|
* @param hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
|
||||||
* @param {Array} zones 구역 배열
|
* @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; });
|
zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; });
|
||||||
Object.values(hitDetails).forEach((hits) => {
|
Object.values(hitDetails).forEach((hits) => {
|
||||||
const countByZone = {};
|
const countByZone: Record<string, number> = {};
|
||||||
(Array.isArray(hits) ? hits : []).forEach((h) => {
|
(Array.isArray(hits) ? hits : []).forEach((h) => {
|
||||||
countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1;
|
countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1;
|
||||||
});
|
});
|
||||||
@ -56,7 +62,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
|
|||||||
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
|
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
|
||||||
];
|
];
|
||||||
|
|
||||||
const zoneHeaders = [];
|
const zoneHeaders: string[] = [];
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone) => {
|
||||||
const max = maxVisitsPerZone[zone.id] || 1;
|
const max = maxVisitsPerZone[zone.id] || 1;
|
||||||
if (max === 1) {
|
if (max === 1) {
|
||||||
@ -78,7 +84,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
|
|||||||
|
|
||||||
// 데이터 행 생성
|
// 데이터 행 생성
|
||||||
const rows = tracks.map((track) => {
|
const rows = tracks.map((track) => {
|
||||||
const baseRow = [
|
const baseRow: (string | number)[] = [
|
||||||
getSignalSourceName(track.sigSrcCd),
|
getSignalSourceName(track.sigSrcCd),
|
||||||
track.targetId || '',
|
track.targetId || '',
|
||||||
track.shipName || '',
|
track.shipName || '',
|
||||||
@ -91,7 +97,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const hits = hitDetails[track.vesselId] || [];
|
const hits = hitDetails[track.vesselId] || [];
|
||||||
const zoneData = [];
|
const zoneData: string[] = [];
|
||||||
zones.forEach((zone) => {
|
zones.forEach((zone) => {
|
||||||
const max = maxVisitsPerZone[zone.id] || 1;
|
const max = maxVisitsPerZone[zone.id] || 1;
|
||||||
const zoneHits = hits
|
const zoneHits = hits
|
||||||
@ -129,7 +135,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const now = new Date();
|
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 filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@ -1,19 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* STS 분석 레이어 전역 레지스트리
|
* STS 분석 레이어 전역 레지스트리
|
||||||
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.js
|
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.ts
|
||||||
*
|
*
|
||||||
* useStsLayer 훅이 레이어를 등록하면
|
* useStsLayer 훅이 레이어를 등록하면
|
||||||
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
|
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function registerStsLayers(layers) {
|
import type { Layer } from '@deck.gl/core';
|
||||||
|
|
||||||
|
export function registerStsLayers(layers: Layer[]): void {
|
||||||
window.__stsLayers__ = layers;
|
window.__stsLayers__ = layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStsLayers() {
|
export function getStsLayers(): Layer[] {
|
||||||
return window.__stsLayers__ || [];
|
return window.__stsLayers__ || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterStsLayers() {
|
export function unregisterStsLayers(): void {
|
||||||
window.__stsLayers__ = [];
|
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('0', 'Not available');
|
||||||
shipTypeMap.set('1', 'Reserved for future use');
|
shipTypeMap.set('1', 'Reserved for future use');
|
||||||
@ -3,9 +3,54 @@
|
|||||||
* 참조: mda-react-front/src/common/stompClient.ts
|
* 참조: mda-react-front/src/common/stompClient.ts
|
||||||
* 참조: mda-react-front/src/map/MapUpdater.tsx
|
* 참조: 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';
|
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 클라이언트 인스턴스
|
* STOMP 클라이언트 인스턴스
|
||||||
* 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect)
|
* 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect)
|
||||||
@ -17,7 +62,7 @@ export const signalStompClient = new Client({
|
|||||||
brokerURL,
|
brokerURL,
|
||||||
reconnectDelay: 10000,
|
reconnectDelay: 10000,
|
||||||
connectionTimeout: 5000,
|
connectionTimeout: 5000,
|
||||||
debug: (str) => {
|
debug: (str: string) => {
|
||||||
// STOMP 디버그 로그 (연결 관련 메시지만 출력)
|
// STOMP 디버그 로그 (연결 관련 메시지만 출력)
|
||||||
if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) {
|
if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) {
|
||||||
console.log('[STOMP Debug]', str);
|
console.log('[STOMP Debug]', str);
|
||||||
@ -31,7 +76,7 @@ export const signalStompClient = new Client({
|
|||||||
* @param {string} msgString - 파이프 구분 문자열
|
* @param {string} msgString - 파이프 구분 문자열
|
||||||
* @returns {Array} 파싱된 배열
|
* @returns {Array} 파싱된 배열
|
||||||
*/
|
*/
|
||||||
export function parsePipeMessage(msgString) {
|
export function parsePipeMessage(msgString: string): string[] {
|
||||||
return msgString.split('|');
|
return msgString.split('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,9 +85,9 @@ export function parsePipeMessage(msgString) {
|
|||||||
* 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget()
|
* 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget()
|
||||||
*
|
*
|
||||||
* @param {Array} row - 파싱된 메시지 배열 (38개 요소)
|
* @param {Array} row - 파싱된 메시지 배열 (38개 요소)
|
||||||
* @returns {Object} 선박 데이터 객체
|
* @returns {ShipObject} 선박 데이터 객체
|
||||||
*/
|
*/
|
||||||
export function rowToShipObject(row) {
|
export function rowToShipObject(row: string[]): ShipObject {
|
||||||
const idx = SHIP_MSG_INDEX;
|
const idx = SHIP_MSG_INDEX;
|
||||||
|
|
||||||
const targetId = row[idx.TARGET_ID] || '';
|
const targetId = row[idx.TARGET_ID] || '';
|
||||||
@ -122,10 +167,10 @@ export function rowToShipObject(row) {
|
|||||||
* @param {Function} callbacks.onDisconnect - 연결 해제 시
|
* @param {Function} callbacks.onDisconnect - 연결 해제 시
|
||||||
* @param {Function} callbacks.onError - 에러 발생 시
|
* @param {Function} callbacks.onError - 에러 발생 시
|
||||||
*/
|
*/
|
||||||
export function connectStomp(callbacks = {}) {
|
export function connectStomp(callbacks: StompCallbacks = {}): void {
|
||||||
const { onConnect, onDisconnect, onError } = callbacks;
|
const { onConnect, onDisconnect, onError } = callbacks;
|
||||||
|
|
||||||
signalStompClient.onConnect = (frame) => {
|
signalStompClient.onConnect = (frame: IFrame) => {
|
||||||
console.log('[STOMP] Connected');
|
console.log('[STOMP] Connected');
|
||||||
onConnect?.(frame);
|
onConnect?.(frame);
|
||||||
};
|
};
|
||||||
@ -135,7 +180,7 @@ export function connectStomp(callbacks = {}) {
|
|||||||
onDisconnect?.();
|
onDisconnect?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
signalStompClient.onStompError = (frame) => {
|
signalStompClient.onStompError = (frame: IFrame) => {
|
||||||
console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error');
|
console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error');
|
||||||
onError?.(frame);
|
onError?.(frame);
|
||||||
};
|
};
|
||||||
@ -146,7 +191,7 @@ export function connectStomp(callbacks = {}) {
|
|||||||
/**
|
/**
|
||||||
* STOMP 연결 해제
|
* STOMP 연결 해제
|
||||||
*/
|
*/
|
||||||
export function disconnectStomp() {
|
export function disconnectStomp(): void {
|
||||||
if (signalStompClient.connected) {
|
if (signalStompClient.connected) {
|
||||||
signalStompClient.deactivate();
|
signalStompClient.deactivate();
|
||||||
}
|
}
|
||||||
@ -157,9 +202,9 @@ export function disconnectStomp() {
|
|||||||
* - 개발: /topic/ship (실시간)
|
* - 개발: /topic/ship (실시간)
|
||||||
* - 프로덕션: /topic/ship-throttled-60s (위성망 대응)
|
* - 프로덕션: /topic/ship-throttled-60s (위성망 대응)
|
||||||
* @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열)
|
* @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=쓰로틀)
|
// 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀)
|
||||||
const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
|
const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
|
||||||
|
|
||||||
@ -172,9 +217,9 @@ export function subscribeShips(onMessage) {
|
|||||||
return signalStompClient.subscribe(topic, (message) => {
|
return signalStompClient.subscribe(topic, (message) => {
|
||||||
try {
|
try {
|
||||||
const body = message.body;
|
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);
|
const row = parsePipeMessage(line);
|
||||||
return rowToShipObject(row);
|
return rowToShipObject(row);
|
||||||
});
|
});
|
||||||
@ -190,9 +235,9 @@ export function subscribeShips(onMessage) {
|
|||||||
* 선박 토픽 구독 (Raw 문자열 반환, Worker용)
|
* 선박 토픽 구독 (Raw 문자열 반환, Worker용)
|
||||||
* - Web Worker에서 파싱을 수행할 때 사용
|
* - Web Worker에서 파싱을 수행할 때 사용
|
||||||
* @param {Function} onMessage - 메시지 수신 콜백 (파이프 구분 문자열 배열)
|
* @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 throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
|
||||||
|
|
||||||
const topic = throttleSeconds > 0
|
const topic = throttleSeconds > 0
|
||||||
@ -205,7 +250,7 @@ export function subscribeShipsRaw(onMessage) {
|
|||||||
try {
|
try {
|
||||||
const body = message.body;
|
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);
|
onMessage(lines);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[STOMP] Ship message parse error:', error);
|
console.error('[STOMP] Ship message parse error:', error);
|
||||||
@ -216,9 +261,9 @@ export function subscribeShipsRaw(onMessage) {
|
|||||||
/**
|
/**
|
||||||
* 선박 삭제 토픽 구독
|
* 선박 삭제 토픽 구독
|
||||||
* @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId)
|
* @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}`);
|
console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`);
|
||||||
|
|
||||||
return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => {
|
return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => {
|
||||||
@ -239,9 +284,9 @@ export function subscribeShipDelete(onDelete) {
|
|||||||
/**
|
/**
|
||||||
* 선박 카운트 토픽 구독
|
* 선박 카운트 토픽 구독
|
||||||
* @param {Function} onCount - 카운트 메시지 수신 콜백
|
* @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) => {
|
return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => {
|
||||||
try {
|
try {
|
||||||
const counts = JSON.parse(message.body);
|
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 { useAuthStore } from '../../stores/authStore';
|
||||||
import './SessionGuard.scss';
|
import './SessionGuard.scss';
|
||||||
|
|
||||||
const SKIP_AUTH = import.meta.env.VITE_DEV_SKIP_AUTH === 'true';
|
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 isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const isChecking = useAuthStore((s) => s.isChecking);
|
const isChecking = useAuthStore((s) => s.isChecking);
|
||||||
|
|
||||||
@ -2,19 +2,24 @@ import { useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import './AlertModal.scss';
|
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) {
|
if (showAlertFn) {
|
||||||
showAlertFn(message, errorCode);
|
showAlertFn(message, errorCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertModalContainer() {
|
export function AlertModalContainer() {
|
||||||
const [alert, setAlert] = useState(null);
|
const [alert, setAlert] = useState<AlertState | null>(null);
|
||||||
|
|
||||||
useState(() => {
|
useState(() => {
|
||||||
showAlertFn = (message, errorCode) => {
|
showAlertFn = (message: string, errorCode?: string) => {
|
||||||
setAlert({ message, errorCode });
|
setAlert({ message, errorCode });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,7 +36,7 @@ export function AlertModalContainer() {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="alert-modal-overlay" onClick={handleClose}>
|
<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>
|
<div className="alert-modal__message">{alert.message}</div>
|
||||||
{alert.errorCode && (
|
{alert.errorCode && (
|
||||||
<div className="alert-modal__error-code">오류 코드: {alert.errorCode}</div>
|
<div className="alert-modal__error-code">오류 코드: {alert.errorCode}</div>
|
||||||
@ -7,7 +7,11 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import './LoadingOverlay.scss';
|
import './LoadingOverlay.scss';
|
||||||
|
|
||||||
export default function LoadingOverlay({ message = '조회중...' }) {
|
interface LoadingOverlayProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingOverlay({ message = '조회중...' }: LoadingOverlayProps) {
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="loading-overlay">
|
<div className="loading-overlay">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
@ -6,10 +6,16 @@ import { useEffect, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import './Toast.scss';
|
import './Toast.scss';
|
||||||
|
|
||||||
// 토스트 메시지 표시 함수 (외부에서 호출용)
|
interface ToastData {
|
||||||
let showToastFn = null;
|
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) {
|
if (showToastFn) {
|
||||||
showToastFn(message, duration);
|
showToastFn(message, duration);
|
||||||
}
|
}
|
||||||
@ -20,10 +26,10 @@ export function showToast(message, duration = 3000) {
|
|||||||
* App 최상위에 한 번만 마운트
|
* App 최상위에 한 번만 마운트
|
||||||
*/
|
*/
|
||||||
export function ToastContainer() {
|
export function ToastContainer() {
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState<ToastData[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showToastFn = (message, duration) => {
|
showToastFn = (message: string, duration: number) => {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
setToasts((prev) => [...prev, { id, message, duration }]);
|
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));
|
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 }) {
|
function ToastItem({ message, duration, onClose }: ToastItemProps) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
const [isExiting, setIsExiting] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 마운트 후 바로 표시
|
// 마운트 후 바로 표시
|
||||||
@ -16,9 +16,9 @@ const SAMPLE_ALERTS = [
|
|||||||
*/
|
*/
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const user = useAuthStore((s) => s.user);
|
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();
|
e.preventDefault();
|
||||||
const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length];
|
const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length];
|
||||||
alertIndexRef.current++;
|
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: 'gnb1', className: 'gnb1', label: '선박', path: 'ship' },
|
||||||
{ key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' },
|
{ key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' },
|
||||||
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
|
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
|
||||||
@ -10,7 +17,12 @@ const gnbList = [
|
|||||||
{ key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
|
{ 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 (
|
return (
|
||||||
<nav id="nav">
|
<nav id="nav">
|
||||||
<ul className="gnb">
|
<ul className="gnb">
|
||||||
@ -33,7 +45,7 @@ export default function SideNav({ activeKey, onChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 키-경로 매핑 export (Sidebar에서 사용)
|
// 키-경로 매핑 export (Sidebar에서 사용)
|
||||||
export const keyToPath = {
|
export const keyToPath: Record<string, string> = {
|
||||||
gnb1: 'ship',
|
gnb1: 'ship',
|
||||||
gnb4: 'analysis',
|
gnb4: 'analysis',
|
||||||
gnb5: 'timeline',
|
gnb5: 'timeline',
|
||||||
@ -41,6 +53,6 @@ export const keyToPath = {
|
|||||||
gnb8: 'area-search',
|
gnb8: 'area-search',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pathToKey = Object.fromEntries(
|
export const pathToKey: Record<string, string> = Object.fromEntries(
|
||||||
Object.entries(keyToPath).map(([k, v]) => [v, k])
|
Object.entries(keyToPath).map(([k, v]) => [v, k])
|
||||||
);
|
);
|
||||||
@ -20,10 +20,10 @@ export default function Sidebar() {
|
|||||||
const path = location.pathname.split('/')[1];
|
const path = location.pathname.split('/')[1];
|
||||||
return path ? pathToKey[path] : null;
|
return path ? pathToKey[path] : null;
|
||||||
})();
|
})();
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(initialActiveKey !== null);
|
const [isPanelOpen, setIsPanelOpen] = useState<boolean>(initialActiveKey !== null);
|
||||||
|
|
||||||
// URL에서 활성 메뉴 키 추출 (루트 경로면 비활성)
|
// URL에서 활성 메뉴 키 추출 (루트 경로면 비활성)
|
||||||
const getActiveKey = () => {
|
const getActiveKey = (): string | null => {
|
||||||
const path = location.pathname.split('/')[1];
|
const path = location.pathname.split('/')[1];
|
||||||
if (!path) return null; // 루트 경로면 비활성
|
if (!path) return null; // 루트 경로면 비활성
|
||||||
return pathToKey[path] || null;
|
return pathToKey[path] || null;
|
||||||
@ -31,7 +31,7 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
const activeKey = getActiveKey();
|
const activeKey = getActiveKey();
|
||||||
|
|
||||||
const handleMenuChange = (key) => {
|
const handleMenuChange = (key: string) => {
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
const path = keyToPath[key] || 'ship';
|
const path = keyToPath[key] || 'ship';
|
||||||
navigate(`/${path}`);
|
navigate(`/${path}`);
|
||||||
@ -49,14 +49,14 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
// 활성 키에 따른 패널 컴포넌트 렌더링
|
// 활성 키에 따른 패널 컴포넌트 렌더링
|
||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
const panelMap = {
|
const panelMap: Record<string, React.ReactNode> = {
|
||||||
gnb1: <ShipFilterPanel {...panelProps} />,
|
gnb1: <ShipFilterPanel {...panelProps} />,
|
||||||
gnb4: null, // TODO: 분석 패널
|
gnb4: null, // TODO: 분석 패널
|
||||||
gnb5: null, // TODO: 타임라인 패널
|
gnb5: null, // TODO: 타임라인 패널
|
||||||
gnb7: <ReplayPage {...panelProps} />,
|
gnb7: <ReplayPage {...panelProps} />,
|
||||||
gnb8: <AreaSearchPage {...panelProps} />,
|
gnb8: <AreaSearchPage {...panelProps} />,
|
||||||
};
|
};
|
||||||
return panelMap[activeKey] || null;
|
return activeKey ? panelMap[activeKey] || null : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMapStore } from '../../stores/mapStore';
|
import { useMapStore } from '../../stores/mapStore';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
|
import type { LabelOptions } from '../../stores/shipStore';
|
||||||
import { downloadShipCsv } from '../../utils/csvDownload';
|
import { downloadShipCsv } from '../../utils/csvDownload';
|
||||||
import { showLiveShips } from '../../utils/liveControl';
|
import { showLiveShips } from '../../utils/liveControl';
|
||||||
import useReplayStore from '../../replay/stores/replayStore';
|
import useReplayStore from '../../replay/stores/replayStore';
|
||||||
@ -8,8 +9,15 @@ import useAnimationStore from '../../replay/stores/animationStore';
|
|||||||
import { unregisterReplayLayers } from '../../replay/utils/replayLayerRegistry';
|
import { unregisterReplayLayers } from '../../replay/utils/replayLayerRegistry';
|
||||||
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
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: 'Box', label: '사각형' },
|
||||||
{ key: 'Polygon', label: '다각형' },
|
{ key: 'Polygon', label: '다각형' },
|
||||||
{ key: 'Circle', label: '원형' },
|
{ key: 'Circle', label: '원형' },
|
||||||
@ -23,8 +31,8 @@ const AREA_SHAPES = [
|
|||||||
* - 범례, 미니맵
|
* - 범례, 미니맵
|
||||||
*/
|
*/
|
||||||
export default function ToolBar() {
|
export default function ToolBar() {
|
||||||
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState(false);
|
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState<boolean>(false);
|
||||||
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState(false);
|
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState<boolean>(false);
|
||||||
const { zoom, zoomIn, zoomOut, activeMeasureTool, setMeasureTool, setAreaShape } = useMapStore();
|
const { zoom, zoomIn, zoomOut, activeMeasureTool, setMeasureTool, setAreaShape } = useMapStore();
|
||||||
const {
|
const {
|
||||||
isIntegrate,
|
isIntegrate,
|
||||||
@ -38,7 +46,7 @@ export default function ToolBar() {
|
|||||||
} = useShipStore();
|
} = useShipStore();
|
||||||
|
|
||||||
// 선명표시 옵션 목록
|
// 선명표시 옵션 목록
|
||||||
const labelOptionList = [
|
const labelOptionList: { key: keyof LabelOptions; label: string }[] = [
|
||||||
{ key: 'showShipName', label: '선박명' },
|
{ key: 'showShipName', label: '선박명' },
|
||||||
{ key: 'showSpeedVector', label: '속도벡터' },
|
{ key: 'showSpeedVector', label: '속도벡터' },
|
||||||
{ key: 'showShipSize', label: '선박크기' },
|
{ key: 'showShipSize', label: '선박크기' },
|
||||||
@ -185,7 +193,9 @@ export default function ToolBar() {
|
|||||||
alert('다운로드할 선박 데이터가 없습니다.');
|
alert('다운로드할 선박 데이터가 없습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await downloadShipCsv(ships);
|
// ShipFeature[]는 DownloadShip의 모든 필드를 포함 (getDownloadShips가 보장)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await downloadShipCsv(ships as any);
|
||||||
}}
|
}}
|
||||||
>다운로드</button>
|
>다운로드</button>
|
||||||
</li>
|
</li>
|
||||||
@ -9,23 +9,30 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { toLonLat } from 'ol/proj';
|
import { toLonLat } from 'ol/proj';
|
||||||
import { useMapStore } from '../../stores/mapStore';
|
import { useMapStore } from '../../stores/mapStore';
|
||||||
import useTrackingModeStore from '../../stores/trackingModeStore';
|
import useTrackingModeStore from '../../stores/trackingModeStore';
|
||||||
import useShipSearch from '../../hooks/useShipSearch';
|
import useShipSearch, { type SearchResult } from '../../hooks/useShipSearch';
|
||||||
import './TopBar.scss';
|
import './TopBar.scss';
|
||||||
|
|
||||||
|
interface DmsResult {
|
||||||
|
degrees: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: string;
|
||||||
|
direction: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 십진도를 도분초(DMS) 형식으로 변환
|
* 십진도를 도분초(DMS) 형식으로 변환
|
||||||
* @param {number} decimal - 십진도
|
* @param {number} decimal - 십진도
|
||||||
* @param {boolean} isLongitude - 경도 여부 (false면 위도)
|
* @param {boolean} isLongitude - 경도 여부 (false면 위도)
|
||||||
* @returns {{ degrees: number, minutes: number, seconds: string, direction: string }}
|
* @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 absolute = Math.abs(decimal);
|
||||||
const degrees = Math.floor(absolute);
|
const degrees = Math.floor(absolute);
|
||||||
const minutesFloat = (absolute - degrees) * 60;
|
const minutesFloat = (absolute - degrees) * 60;
|
||||||
const minutes = Math.floor(minutesFloat);
|
const minutes = Math.floor(minutesFloat);
|
||||||
const seconds = ((minutesFloat - minutes) * 60).toFixed(3);
|
const seconds = ((minutesFloat - minutes) * 60).toFixed(3);
|
||||||
|
|
||||||
let direction;
|
let direction: string;
|
||||||
if (isLongitude) {
|
if (isLongitude) {
|
||||||
direction = decimal >= 0 ? 'E' : 'W';
|
direction = decimal >= 0 ? 'E' : 'W';
|
||||||
} else {
|
} else {
|
||||||
@ -41,11 +48,17 @@ function decimalToDMS(decimal, isLongitude) {
|
|||||||
* @param {boolean} isLongitude - 경도 여부
|
* @param {boolean} isLongitude - 경도 여부
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function formatDecimalDegrees(decimal, isLongitude) {
|
function formatDecimalDegrees(decimal: number, isLongitude: boolean): string {
|
||||||
const direction = isLongitude
|
const direction = isLongitude
|
||||||
? (decimal >= 0 ? 'E' : 'W')
|
? (decimal >= 0 ? 'E' : 'W')
|
||||||
: (decimal >= 0 ? 'N' : 'S');
|
: (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
|
* @param {Date} date
|
||||||
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
||||||
*/
|
*/
|
||||||
function formatKST(date) {
|
function formatKST(date: Date): TimeFormatResult {
|
||||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
@ -75,7 +88,7 @@ function formatKST(date) {
|
|||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
|
||||||
*/
|
*/
|
||||||
function formatUTC(date) {
|
function formatUTC(date: Date): TimeFormatResult {
|
||||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
@ -93,26 +106,26 @@ function formatUTC(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar() {
|
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 mode = useTrackingModeStore((s: { mode: string }) => s.mode);
|
||||||
const setMapMode = useTrackingModeStore((s) => s.setMapMode);
|
const setMapMode = useTrackingModeStore((s: { setMapMode: () => void }) => s.setMapMode);
|
||||||
const setShipMode = useTrackingModeStore((s) => s.setShipMode);
|
const setShipMode = useTrackingModeStore((s: { setShipMode: () => void }) => s.setShipMode);
|
||||||
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
|
const trackedShip = useTrackingModeStore((s: { trackedShip: { shipName?: string; originalTargetId?: string } | null }) => s.trackedShip);
|
||||||
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
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 [coordFormat, setCoordFormat] = useState<'dms' | 'decimal'>('dms');
|
||||||
const [timeFormat, setTimeFormat] = useState('kst'); // 'kst' | 'utc'
|
const [timeFormat, setTimeFormat] = useState<'kst' | 'utc'>('kst');
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState<boolean>(false);
|
||||||
const settingsRef = useRef(null);
|
const settingsRef = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
// 검색 훅
|
// 검색 훅
|
||||||
const {
|
const {
|
||||||
@ -126,17 +139,18 @@ export default function TopBar() {
|
|||||||
} = useShipSearch();
|
} = useShipSearch();
|
||||||
|
|
||||||
// 검색창 포커스 상태
|
// 검색창 포커스 상태
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||||
const searchContainerRef = useRef(null);
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 좌표 업데이트 쓰로틀 ref
|
// 좌표 업데이트 쓰로틀 ref
|
||||||
const throttleRef = useRef(null);
|
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 마우스 이동 시 좌표 업데이트
|
// 마우스 이동 시 좌표 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const handlePointerMove = (evt) => {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const handlePointerMove = (evt: any) => {
|
||||||
// 쓰로틀: 100ms
|
// 쓰로틀: 100ms
|
||||||
if (throttleRef.current) return;
|
if (throttleRef.current) return;
|
||||||
|
|
||||||
@ -145,17 +159,20 @@ export default function TopBar() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const pixel = evt.pixel;
|
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) {
|
if (coord3857) {
|
||||||
const [lon, lat] = toLonLat(coord3857);
|
const [lon, lat] = toLonLat(coord3857);
|
||||||
setCoordinates({ lon, lat });
|
setCoordinates({ lon, lat });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
map.on('pointermove', handlePointerMove);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(map as any).on('pointermove', handlePointerMove);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.un('pointermove', handlePointerMove);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(map as any).un('pointermove', handlePointerMove);
|
||||||
if (throttleRef.current) {
|
if (throttleRef.current) {
|
||||||
clearTimeout(throttleRef.current);
|
clearTimeout(throttleRef.current);
|
||||||
throttleRef.current = null;
|
throttleRef.current = null;
|
||||||
@ -174,11 +191,11 @@ export default function TopBar() {
|
|||||||
|
|
||||||
// 검색창/설정 외부 클릭 시 닫기
|
// 검색창/설정 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target)) {
|
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
|
||||||
setIsSearchFocused(false);
|
setIsSearchFocused(false);
|
||||||
}
|
}
|
||||||
if (settingsRef.current && !settingsRef.current.contains(e.target)) {
|
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
|
||||||
setShowSettings(false);
|
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(e.target.value);
|
||||||
}, [setSearchValue]);
|
}, [setSearchValue]);
|
||||||
|
|
||||||
// 엔터키로 첫 번째 결과 선택
|
// 엔터키로 첫 번째 결과 선택
|
||||||
const handleKeyDown = useCallback((e) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelectFirst();
|
handleSelectFirst();
|
||||||
@ -204,7 +221,7 @@ export default function TopBar() {
|
|||||||
}, [handleSelectFirst, clearSearch]);
|
}, [handleSelectFirst, clearSearch]);
|
||||||
|
|
||||||
// 검색 결과 클릭
|
// 검색 결과 클릭
|
||||||
const handleResultClick = useCallback((result) => {
|
const handleResultClick = useCallback((result: SearchResult) => {
|
||||||
handleClickResult(result);
|
handleClickResult(result);
|
||||||
setIsSearchFocused(false);
|
setIsSearchFocused(false);
|
||||||
}, [handleClickResult]);
|
}, [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 '-';
|
if (!str) return '-';
|
||||||
return str.length > maxLength ? str.slice(0, maxLength) + '...' : str;
|
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 (value === null) return <span>---</span>;
|
||||||
|
|
||||||
if (coordFormat === 'dms') {
|
if (coordFormat === 'dms') {
|
||||||
const dms = decimalToDMS(value, isLongitude);
|
const dms = decimalToDMS(value, isLongitude);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span>{dms.degrees}°</span>
|
<span>{dms.degrees}°</span>
|
||||||
<span>{dms.minutes}'</span>
|
<span>{dms.minutes}'</span>
|
||||||
<span>{dms.seconds}"</span>
|
<span>{dms.seconds}"</span>
|
||||||
<span>{dms.direction}</span>
|
<span>{dms.direction}</span>
|
||||||
@ -375,7 +392,7 @@ export default function TopBar() {
|
|||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
title="검색어 지우기"
|
title="검색어 지우기"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -383,7 +400,7 @@ export default function TopBar() {
|
|||||||
{/* 검색 결과 목록 */}
|
{/* 검색 결과 목록 */}
|
||||||
{isSearchFocused && searchValue && results.length > 0 && (
|
{isSearchFocused && searchValue && results.length > 0 && (
|
||||||
<ul className="search-results">
|
<ul className="search-results">
|
||||||
{results.map((result) => (
|
{results.map((result: SearchResult) => (
|
||||||
<li
|
<li
|
||||||
key={result.featureId}
|
key={result.featureId}
|
||||||
className="search-result-item"
|
className="search-result-item"
|
||||||
@ -29,9 +29,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
|
|||||||
import etcIcon from '../../assets/img/shipKindIcons/etc.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_FISHING]: fishingIcon,
|
||||||
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
||||||
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
|
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
|
||||||
@ -42,10 +42,15 @@ const SHIP_KIND_ICONS = {
|
|||||||
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
|
[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_FISHING, label: '어선' },
|
||||||
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
|
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
|
||||||
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
|
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
|
||||||
@ -56,10 +61,19 @@ const LEGEND_ITEMS = [
|
|||||||
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
|
{ 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;
|
const isBuoy = code === SIGNAL_KIND_CODE_BUOY;
|
||||||
|
|
||||||
return (
|
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 } =
|
const { replayShipCounts, replayTotalCount, shipKindCodeFilter } =
|
||||||
useReplayStore(
|
useReplayStore(
|
||||||
(state) => ({
|
(state: {
|
||||||
|
replayShipCounts: Record<string, number>;
|
||||||
|
replayTotalCount: number;
|
||||||
|
shipKindCodeFilter: Set<string>;
|
||||||
|
}) => ({
|
||||||
replayShipCounts: state.replayShipCounts,
|
replayShipCounts: state.replayShipCounts,
|
||||||
replayTotalCount: state.replayTotalCount,
|
replayTotalCount: state.replayTotalCount,
|
||||||
shipKindCodeFilter: state.shipKindCodeFilter,
|
shipKindCodeFilter: state.shipKindCodeFilter,
|
||||||
}),
|
}),
|
||||||
shallow
|
shallow
|
||||||
);
|
);
|
||||||
const toggleShipKindCode = useReplayStore((state) => state.toggleShipKindCode);
|
const toggleShipKindCode = useReplayStore((state: { toggleShipKindCode: (code: string) => void }) => state.toggleShipKindCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="ship-legend">
|
<article className="ship-legend">
|
||||||
@ -13,15 +13,29 @@ import {
|
|||||||
buildVesselListForQuery,
|
buildVesselListForQuery,
|
||||||
deduplicateVessels,
|
deduplicateVessels,
|
||||||
} from '../../tracking/services/trackQueryApi';
|
} from '../../tracking/services/trackQueryApi';
|
||||||
|
import type { VesselQueryTarget } from '../../tracking/services/trackQueryApi';
|
||||||
|
import type { ShipFeature } from '../../types/ship';
|
||||||
import './ShipContextMenu.scss';
|
import './ShipContextMenu.scss';
|
||||||
|
|
||||||
|
interface ContextMenuData {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ships: ShipFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hasSubmenu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
|
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
|
||||||
function toKstISOString(date) {
|
function toKstISOString(date: Date): string {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
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())}`;
|
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: '항적조회' },
|
{ key: 'track', label: '항적조회' },
|
||||||
// TODO: 임시 배포용 - 미구현 기능 숨김
|
// TODO: 임시 배포용 - 미구현 기능 숨김
|
||||||
// { key: 'analysis', label: '항적분석' },
|
// { key: 'analysis', label: '항적분석' },
|
||||||
@ -30,20 +44,20 @@ const MENU_ITEMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function ShipContextMenu() {
|
export default function ShipContextMenu() {
|
||||||
const contextMenu = useShipStore((s) => s.contextMenu);
|
const contextMenu = useShipStore((s: { contextMenu: ContextMenuData | null }) => s.contextMenu);
|
||||||
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
|
const closeContextMenu = useShipStore((s: { closeContextMenu: () => void }) => s.closeContextMenu);
|
||||||
const setRadius = useTrackingModeStore((s) => s.setRadius);
|
const setRadius = useTrackingModeStore((s: { setRadius: (r: number) => void }) => s.setRadius);
|
||||||
const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
|
const selectTrackedShip = useTrackingModeStore((s: { selectTrackedShip: (id: string, ship: ShipFeature) => void }) => s.selectTrackedShip);
|
||||||
const currentRadius = useTrackingModeStore((s) => s.radiusNM);
|
const currentRadius = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [hoveredItem, setHoveredItem] = useState(null);
|
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||||
|
|
||||||
// 외부 클릭 시 닫기
|
// 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -53,7 +67,7 @@ export default function ShipContextMenu() {
|
|||||||
}, [contextMenu, closeContextMenu]);
|
}, [contextMenu, closeContextMenu]);
|
||||||
|
|
||||||
// 반경 선택 핸들러
|
// 반경 선택 핸들러
|
||||||
const handleRadiusSelect = useCallback((radius) => {
|
const handleRadiusSelect = useCallback((radius: number) => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
const { ships } = contextMenu;
|
const { ships } = contextMenu;
|
||||||
|
|
||||||
@ -67,7 +81,7 @@ export default function ShipContextMenu() {
|
|||||||
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
|
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
|
||||||
|
|
||||||
// 메뉴 항목 클릭
|
// 메뉴 항목 클릭
|
||||||
const handleAction = useCallback(async (key) => {
|
const handleAction = useCallback(async (key: string) => {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
const { ships } = contextMenu;
|
const { ships } = contextMenu;
|
||||||
|
|
||||||
@ -86,8 +100,8 @@ export default function ShipContextMenu() {
|
|||||||
|
|
||||||
const { isIntegrate, features } = useShipStore.getState();
|
const { isIntegrate, features } = useShipStore.getState();
|
||||||
|
|
||||||
const allVessels = [];
|
const allVessels: VesselQueryTarget[] = [];
|
||||||
const errors = [];
|
const errors: string[] = [];
|
||||||
ships.forEach(ship => {
|
ships.forEach(ship => {
|
||||||
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
||||||
if (result.canQuery) allVessels.push(...result.vessels);
|
if (result.canQuery) allVessels.push(...result.vessels);
|
||||||
@ -134,7 +148,7 @@ export default function ShipContextMenu() {
|
|||||||
|
|
||||||
const { isIntegrate, features } = useShipStore.getState();
|
const { isIntegrate, features } = useShipStore.getState();
|
||||||
|
|
||||||
const allVessels = [];
|
const allVessels: VesselQueryTarget[] = [];
|
||||||
ships.forEach(ship => {
|
ships.forEach(ship => {
|
||||||
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
||||||
if (result.canQuery) allVessels.push(...result.vessels);
|
if (result.canQuery) allVessels.push(...result.vessels);
|
||||||
@ -213,28 +227,28 @@ export default function ShipContextMenu() {
|
|||||||
style={{ left: adjustedX, top: adjustedY }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
>
|
>
|
||||||
<div className="ship-context-menu__header">{title}</div>
|
<div className="ship-context-menu__header">{title}</div>
|
||||||
{visibleMenuItems.map((item, index) => (
|
{visibleMenuItems.map((_item, _index) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={_item.key}
|
||||||
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
|
className={`ship-context-menu__item ${_item.hasSubmenu ? 'has-submenu' : ''}`}
|
||||||
onClick={() => handleAction(item.key)}
|
onClick={() => handleAction(_item.key)}
|
||||||
onMouseEnter={() => setHoveredItem(item.key)}
|
onMouseEnter={() => setHoveredItem(_item.key)}
|
||||||
onMouseLeave={() => setHoveredItem(null)}
|
onMouseLeave={() => setHoveredItem(null)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{_item.label}
|
||||||
{item.hasSubmenu && <span className="submenu-arrow">▶</span>}
|
{_item.hasSubmenu && <span className="submenu-arrow">▶</span>}
|
||||||
|
|
||||||
{/* 반경설정 서브메뉴 */}
|
{/* 반경설정 서브메뉴 */}
|
||||||
{item.key === 'radius' && hoveredItem === 'radius' && (
|
{_item.key === 'radius' && hoveredItem === 'radius' && (
|
||||||
<div
|
<div
|
||||||
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
|
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
|
||||||
style={{ top: 0 }}
|
style={{ top: 0 }}
|
||||||
>
|
>
|
||||||
{RADIUS_OPTIONS.map((radius) => (
|
{RADIUS_OPTIONS.map((radius: number) => (
|
||||||
<div
|
<div
|
||||||
key={radius}
|
key={radius}
|
||||||
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
|
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
|
||||||
onClick={(e) => {
|
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleRadiusSelect(radius);
|
handleRadiusSelect(radius);
|
||||||
}}
|
}}
|
||||||
@ -7,8 +7,10 @@
|
|||||||
* - 선박 사진 갤러리 (없으면 기본 이미지)
|
* - 선박 사진 갤러리 (없으면 기본 이미지)
|
||||||
* - 새 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성
|
* - 새 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성
|
||||||
*/
|
*/
|
||||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
|
import type { DetailModal } from '../../stores/shipStore';
|
||||||
|
import type { ShipFeature } from '../../types/ship';
|
||||||
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
|
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
|
||||||
import {
|
import {
|
||||||
fetchVesselTracksV2,
|
fetchVesselTracksV2,
|
||||||
@ -39,7 +41,7 @@ import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg';
|
|||||||
import './ShipDetailModal.scss';
|
import './ShipDetailModal.scss';
|
||||||
|
|
||||||
/** 선종코드 → 아이콘 매핑 */
|
/** 선종코드 → 아이콘 매핑 */
|
||||||
const SHIP_KIND_ICONS = {
|
const SHIP_KIND_ICONS: Record<string, string> = {
|
||||||
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
|
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
|
||||||
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
||||||
[SIGNAL_KIND_CODE_PASSENGER]: passengerIcon,
|
[SIGNAL_KIND_CODE_PASSENGER]: passengerIcon,
|
||||||
@ -49,7 +51,7 @@ const SHIP_KIND_ICONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 선종 아이콘 URL 반환 */
|
/** 선종 아이콘 URL 반환 */
|
||||||
function getShipKindIcon(signalKindCode) {
|
function getShipKindIcon(signalKindCode: string): string {
|
||||||
return SHIP_KIND_ICONS[signalKindCode] || etcIcon;
|
return SHIP_KIND_ICONS[signalKindCode] || etcIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,10 +59,8 @@ function getShipKindIcon(signalKindCode) {
|
|||||||
* 국기 아이콘 URL 반환 (서버 API)
|
* 국기 아이콘 URL 반환 (서버 API)
|
||||||
* 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag()
|
* 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag()
|
||||||
* 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨
|
* 개발 환경에서는 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;
|
if (!nationalCode) return null;
|
||||||
// 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달)
|
// 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달)
|
||||||
return `/ship/image/small/${nationalCode}.svg`;
|
return `/ship/image/small/${nationalCode}.svg`;
|
||||||
@ -69,10 +69,8 @@ function getNationalFlagUrl(nationalCode) {
|
|||||||
/**
|
/**
|
||||||
* receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환
|
* receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환
|
||||||
* 입력 예: '20241123112300' 또는 '2024-11-23 11:23:00' 또는 '2024-11-23T11:23:00'
|
* 입력 예: '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 '-';
|
if (!raw) return '-';
|
||||||
|
|
||||||
// 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환
|
// 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환
|
||||||
@ -97,10 +95,21 @@ function formatDateTime(raw) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 시간 범위 */
|
||||||
|
interface TimeRange {
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SignalFlags Props */
|
||||||
|
interface SignalFlagsProps {
|
||||||
|
ship: ShipFeature;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AVETDR 신호 플래그 표시
|
* AVETDR 신호 플래그 표시
|
||||||
*/
|
*/
|
||||||
function SignalFlags({ ship }) {
|
function SignalFlags({ ship }: SignalFlagsProps) {
|
||||||
const isIntegrate = useShipStore((s) => s.isIntegrate);
|
const isIntegrate = useShipStore((s) => s.isIntegrate);
|
||||||
// 통합선박 판별: 언더스코어 또는 integrate 플래그
|
// 통합선박 판별: 언더스코어 또는 integrate 플래그
|
||||||
const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
|
const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
|
||||||
@ -113,7 +122,7 @@ function SignalFlags({ ship }) {
|
|||||||
let isVisible = false;
|
let isVisible = false;
|
||||||
|
|
||||||
if (useIntegratedMode) {
|
if (useIntegratedMode) {
|
||||||
const val = ship[config.dataKey];
|
const val = ship[config.dataKey] as string | undefined;
|
||||||
if (val === '1') { isVisible = true; isActive = true; }
|
if (val === '1') { isVisible = true; isActive = true; }
|
||||||
else if (val === '0') { isVisible = true; }
|
else if (val === '0') { isVisible = true; }
|
||||||
} else {
|
} else {
|
||||||
@ -139,11 +148,16 @@ function SignalFlags({ ship }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ShipGallery Props */
|
||||||
|
interface ShipGalleryProps {
|
||||||
|
imageUrlList: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 사진 갤러리
|
* 선박 사진 갤러리
|
||||||
* 이미지가 없으면 기본 이미지(default-ship.png) 표시
|
* 이미지가 없으면 기본 이미지(default-ship.png) 표시
|
||||||
*/
|
*/
|
||||||
function ShipGallery({ imageUrlList }) {
|
function ShipGallery({ imageUrlList }: ShipGalleryProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const hasImages = imageUrlList && imageUrlList.length > 0;
|
const hasImages = imageUrlList && imageUrlList.length > 0;
|
||||||
const images = hasImages ? imageUrlList : [defaultShipImg];
|
const images = hasImages ? imageUrlList : [defaultShipImg];
|
||||||
@ -158,7 +172,7 @@ function ShipGallery({ imageUrlList }) {
|
|||||||
setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1));
|
setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1));
|
||||||
}, [total]);
|
}, [total]);
|
||||||
|
|
||||||
const handleIndicatorClick = useCallback((index) => {
|
const handleIndicatorClick = useCallback((index: number) => {
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -179,7 +193,7 @@ function ShipGallery({ imageUrlList }) {
|
|||||||
className="galleryImg"
|
className="galleryImg"
|
||||||
src={images[currentIndex]}
|
src={images[currentIndex]}
|
||||||
alt="선박 이미지"
|
alt="선박 이미지"
|
||||||
onError={(e) => { e.target.src = defaultShipImg; }}
|
onError={(e) => { (e.target as HTMLImageElement).src = defaultShipImg; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canSlide && (
|
{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 closeDetailModal = useShipStore((s) => s.closeDetailModal);
|
||||||
const updateModalPos = useShipStore((s) => s.updateModalPos);
|
const updateModalPos = useShipStore((s) => s.updateModalPos);
|
||||||
const isIntegrateMode = useShipStore((s) => s.isIntegrate);
|
const isIntegrateMode = useShipStore((s) => s.isIntegrate);
|
||||||
@ -211,11 +229,11 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
// 항적조회 패널 상태
|
// 항적조회 패널 상태
|
||||||
const [showTrackPanel, setShowTrackPanel] = useState(false);
|
const [showTrackPanel, setShowTrackPanel] = useState(false);
|
||||||
const [isQuerying, setIsQuerying] = useState(false);
|
const [isQuerying, setIsQuerying] = useState(false);
|
||||||
const [timeRange, setTimeRange] = useState(() => {
|
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전
|
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => 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 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) };
|
return { fromDate: toLocal(from), toDate: toLocal(now) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -226,7 +244,7 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
const dragStart = useRef({ x: 0, y: 0 });
|
const dragStart = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
// 드래그 핸들러
|
// 드래그 핸들러
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
dragStart.current = {
|
dragStart.current = {
|
||||||
x: e.clientX - position.x,
|
x: e.clientX - position.x,
|
||||||
@ -236,7 +254,7 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
}, [position]);
|
}, [position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!dragging.current) return;
|
if (!dragging.current) return;
|
||||||
const newPos = {
|
const newPos = {
|
||||||
x: e.clientX - dragStart.current.x,
|
x: e.clientX - dragStart.current.x,
|
||||||
@ -263,13 +281,13 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
}, [modal.id, updateModalPos]);
|
}, [modal.id, updateModalPos]);
|
||||||
|
|
||||||
// KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음)
|
// KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음)
|
||||||
const toKstISOString = useCallback((date) => {
|
const toKstISOString = useCallback((date: Date): string => {
|
||||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
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())}`;
|
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 { ship } = modal;
|
||||||
const startTime = new Date(fromDate);
|
const startTime = new Date(fromDate);
|
||||||
const endTime = new Date(toDate);
|
const endTime = new Date(toDate);
|
||||||
@ -277,7 +295,6 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return;
|
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return;
|
||||||
if (startTime >= endTime) return;
|
if (startTime >= endTime) return;
|
||||||
|
|
||||||
const isIntegrated = isIntegratedTargetId(ship.targetId);
|
|
||||||
// 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회
|
// 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회
|
||||||
// isIntegration API 파라미터는 항상 '0' (개별 항적 반환)
|
// isIntegration API 파라미터는 항상 '0' (개별 항적 반환)
|
||||||
const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode);
|
const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode);
|
||||||
@ -320,8 +337,8 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
// 즉시 3일 항적 조회
|
// 즉시 3일 항적 조회
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => 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 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) };
|
const newTimeRange = { fromDate: toLocal(from), toDate: toLocal(now) };
|
||||||
setTimeRange(newTimeRange);
|
setTimeRange(newTimeRange);
|
||||||
|
|
||||||
@ -366,9 +383,9 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
{ship.nationalCode && (
|
{ship.nationalCode && (
|
||||||
<span className="countryFlag">
|
<span className="countryFlag">
|
||||||
<img
|
<img
|
||||||
src={getNationalFlagUrl(ship.nationalCode)}
|
src={getNationalFlagUrl(ship.nationalCode) || ''}
|
||||||
alt="국기"
|
alt="국기"
|
||||||
onError={(e) => { e.target.style.display = 'none'; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -384,7 +401,7 @@ export default function ShipDetailModal({ modal }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* gallery */}
|
{/* gallery */}
|
||||||
<ShipGallery imageUrlList={ship.imageUrlList} />
|
<ShipGallery imageUrlList={ship.imageUrlList as string[] | undefined} />
|
||||||
|
|
||||||
{/* body */}
|
{/* body */}
|
||||||
<div className="pmBody">
|
<div className="pmBody">
|
||||||
@ -3,15 +3,21 @@
|
|||||||
* - 선종별 표시/숨김 토글
|
* - 선종별 표시/숨김 토글
|
||||||
* - 기존 DisplayComponent의 필터 탭을 민간화 버전으로 재구현
|
* - 기존 DisplayComponent의 필터 탭을 민간화 버전으로 재구현
|
||||||
*/
|
*/
|
||||||
import { useState, memo, useCallback } from 'react';
|
import { useState, memo, useCallback, ReactNode } from 'react';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
import { SHIP_KIND_LIST } from '../../types/constants';
|
import { SHIP_KIND_LIST } from '../../types/constants';
|
||||||
|
|
||||||
|
interface SwitchGroupProps {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스위치 그룹 헤더 + 접이식 본문
|
* 스위치 그룹 헤더 + 접이식 본문
|
||||||
*/
|
*/
|
||||||
const SwitchGroup = memo(({ title, children, defaultOpen = true }) => {
|
const SwitchGroup = memo(function SwitchGroup({ title, children, defaultOpen = true }: SwitchGroupProps) {
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
const [isOpen, setIsOpen] = useState<boolean>(defaultOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="switchGroup">
|
<div className="switchGroup">
|
||||||
@ -31,45 +37,66 @@ const SwitchGroup = memo(({ title, children, defaultOpen = true }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개별 토글 스위치 (CSS 토글은 기존 common.css의 .switch 클래스 사용)
|
* 개별 토글 스위치 (CSS 토글은 기존 common.css의 .switch 클래스 사용)
|
||||||
*/
|
*/
|
||||||
const ToggleSwitch = memo(({ label, checked, onChange }) => (
|
const ToggleSwitch = memo(function ToggleSwitch({ label, checked, onChange }: ToggleSwitchProps) {
|
||||||
<li>
|
return (
|
||||||
<span>{label}</span>
|
<li>
|
||||||
<label className="switch">
|
<span>{label}</span>
|
||||||
<input type="checkbox" checked={checked} onChange={onChange} />
|
<label className="switch">
|
||||||
<span className="slider" />
|
<input type="checkbox" checked={checked} onChange={onChange} />
|
||||||
</label>
|
<span className="slider" />
|
||||||
</li>
|
</label>
|
||||||
));
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AllToggleProps {
|
||||||
|
label: string;
|
||||||
|
allChecked: boolean;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 ON/OFF 토글
|
* 전체 ON/OFF 토글
|
||||||
*/
|
*/
|
||||||
const AllToggle = memo(({ label, allChecked, onToggleAll }) => (
|
const AllToggle = memo(function AllToggle({ label, allChecked, onToggleAll }: AllToggleProps) {
|
||||||
<li>
|
return (
|
||||||
<span style={{ fontWeight: 'bold' }}>{label}</span>
|
<li>
|
||||||
<label className="switch">
|
<span style={{ fontWeight: 'bold' }}>{label}</span>
|
||||||
<input type="checkbox" checked={allChecked} onChange={onToggleAll} />
|
<label className="switch">
|
||||||
<span className="slider" />
|
<input type="checkbox" checked={allChecked} onChange={onToggleAll} />
|
||||||
</label>
|
<span className="slider" />
|
||||||
</li>
|
</label>
|
||||||
));
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ShipFilterPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 필터 패널 메인 컴포넌트
|
* 선박 필터 패널 메인 컴포넌트
|
||||||
*/
|
*/
|
||||||
export default function ShipFilterPanel({ isOpen, onToggle }) {
|
export default function ShipFilterPanel({ isOpen, onToggle }: ShipFilterPanelProps) {
|
||||||
const kindVisibility = useShipStore((s) => s.kindVisibility);
|
const kindVisibility = useShipStore((s: { kindVisibility: Record<string, boolean> }) => s.kindVisibility);
|
||||||
const kindCounts = useShipStore((s) => s.kindCounts);
|
const kindCounts = useShipStore((s: { kindCounts: Record<string, number> }) => s.kindCounts);
|
||||||
const toggleKindVisibility = useShipStore((s) => s.toggleKindVisibility);
|
const toggleKindVisibility = useShipStore((s: { toggleKindVisibility: (code: string) => void }) => s.toggleKindVisibility);
|
||||||
|
|
||||||
// 선종 전체 토글
|
// 선종 전체 토글
|
||||||
const allKindVisible = Object.values(kindVisibility).every(Boolean);
|
const allKindVisible = Object.values(kindVisibility).every(Boolean);
|
||||||
const handleToggleAllKind = useCallback(() => {
|
const handleToggleAllKind = useCallback(() => {
|
||||||
const nextValue = !allKindVisible;
|
const nextValue = !allKindVisible;
|
||||||
SHIP_KIND_LIST.forEach(({ code }) => {
|
SHIP_KIND_LIST.forEach(({ code }: { code: string }) => {
|
||||||
if (kindVisibility[code] !== nextValue) {
|
if (kindVisibility[code] !== nextValue) {
|
||||||
toggleKindVisibility(code);
|
toggleKindVisibility(code);
|
||||||
}
|
}
|
||||||
@ -77,7 +104,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) {
|
|||||||
}, [allKindVisible, kindVisibility, toggleKindVisibility]);
|
}, [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 (
|
return (
|
||||||
<div className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
|
<div className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
|
||||||
@ -103,7 +130,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) {
|
|||||||
allChecked={allKindVisible}
|
allChecked={allKindVisible}
|
||||||
onToggleAll={handleToggleAllKind}
|
onToggleAll={handleToggleAllKind}
|
||||||
/>
|
/>
|
||||||
{SHIP_KIND_LIST.map(({ code, label }) => (
|
{SHIP_KIND_LIST.map(({ code, label }: { code: string; label: string }) => (
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
key={code}
|
key={code}
|
||||||
label={`${label} (${(kindCounts[code] || 0).toLocaleString()})`}
|
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';
|
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_FISHING]: fishingIcon,
|
||||||
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
|
||||||
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
|
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
|
||||||
@ -45,10 +45,15 @@ const SHIP_KIND_ICONS = {
|
|||||||
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
|
[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_FISHING, label: '어선' },
|
||||||
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
|
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
|
||||||
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
|
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
|
||||||
@ -59,10 +64,19 @@ const LEGEND_ITEMS = [
|
|||||||
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
|
{ 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;
|
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 } =
|
const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
|
||||||
useShipStore(
|
useShipStore(
|
||||||
(state) => ({
|
(state: {
|
||||||
|
kindCounts: Record<string, number>;
|
||||||
|
kindVisibility: Record<string, boolean>;
|
||||||
|
isShipVisible: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
isConnected: boolean;
|
||||||
|
}) => ({
|
||||||
kindCounts: state.kindCounts,
|
kindCounts: state.kindCounts,
|
||||||
kindVisibility: state.kindVisibility,
|
kindVisibility: state.kindVisibility,
|
||||||
isShipVisible: state.isShipVisible,
|
isShipVisible: state.isShipVisible,
|
||||||
@ -100,19 +130,19 @@ const ShipLegend = memo(() => {
|
|||||||
}),
|
}),
|
||||||
shallow
|
shallow
|
||||||
);
|
);
|
||||||
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
const toggleKindVisibility = useShipStore((state: { toggleKindVisibility: (code: string) => void }) => state.toggleKindVisibility);
|
||||||
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
|
const toggleShipVisible = useShipStore((state: { toggleShipVisible: () => void }) => state.toggleShipVisible);
|
||||||
|
|
||||||
// 항적분석 활성 시 결과 카운트
|
// 항적분석 활성 시 결과 카운트
|
||||||
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
const areaSearchCompleted = useAreaSearchStore((s: { queryCompleted: boolean }) => s.queryCompleted);
|
||||||
const areaSearchTracks = useAreaSearchStore((s) => s.tracks);
|
const areaSearchTracks = useAreaSearchStore((s: { tracks: AreaSearchTrack[] }) => s.tracks);
|
||||||
const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds);
|
const areaSearchDisabledIds = useAreaSearchStore((s: { disabledVesselIds: Set<string> }) => s.disabledVesselIds);
|
||||||
const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
|
const areaSearchKindFilter = useAreaSearchStore((s: { shipKindCodeFilter: Set<string> }) => s.shipKindCodeFilter);
|
||||||
const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode);
|
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;
|
if (!areaSearchCompleted || areaSearchTracks.length === 0) return null;
|
||||||
const counts = {};
|
const counts: Record<string, number> = {};
|
||||||
let total = 0;
|
let total = 0;
|
||||||
areaSearchTracks.forEach((track) => {
|
areaSearchTracks.forEach((track) => {
|
||||||
if (areaSearchDisabledIds.has(track.vesselId)) return;
|
if (areaSearchDisabledIds.has(track.vesselId)) return;
|
||||||
@ -8,10 +8,24 @@ import './ShipTooltip.scss';
|
|||||||
const OFFSET_X = 12;
|
const OFFSET_X = 12;
|
||||||
const OFFSET_Y = -40;
|
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;
|
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 sog = Number(ship.sog) || 0;
|
||||||
const cog = Number(ship.cog) || 0;
|
const cog = Number(ship.cog) || 0;
|
||||||
const isMoving = sog > SPEED_THRESHOLD;
|
const isMoving = sog > SPEED_THRESHOLD;
|
||||||
@ -11,12 +11,21 @@
|
|||||||
*
|
*
|
||||||
* 모달 모드에서는 재생/배속 컨트롤 없음 (Phase 2 확장점)
|
* 모달 모드에서는 재생/배속 컨트롤 없음 (Phase 2 확장점)
|
||||||
*/
|
*/
|
||||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import useTrackStore, { getShipKindTrackColor } from '../../stores/trackStore';
|
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 { 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 { showToast } from '../common/Toast';
|
||||||
import './TrackQueryModal.scss';
|
import './TrackQueryModal.scss';
|
||||||
|
|
||||||
|
/** TrackModal 인터페이스 (trackStore 내부 정의와 동일) */
|
||||||
|
interface TrackModal {
|
||||||
|
ships: ShipFeature[];
|
||||||
|
id: string;
|
||||||
|
isIntegrated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** 기본 조회 기간 (일) */
|
/** 기본 조회 기간 (일) */
|
||||||
const DEFAULT_QUERY_DAYS = TRACK_QUERY_DEFAULT_DAYS;
|
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;
|
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** datetime-local 입력용 포맷 */
|
/** datetime-local 입력용 포맷 */
|
||||||
function toDateTimeLocal(date) {
|
function toDateTimeLocal(date: Date): string {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
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())}`;
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MM-DD HH:mm 형식 */
|
/** MM-DD HH:mm 형식 */
|
||||||
function formatShortDateTime(ms) {
|
function formatShortDateTime(ms: number): string {
|
||||||
if (!ms) return '--/-- --:--';
|
if (!ms) return '--/-- --:--';
|
||||||
const d = new Date(ms);
|
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())}`;
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** YYYY-MM-DD HH:mm:ss 형식 */
|
/** YYYY-MM-DD HH:mm:ss 형식 */
|
||||||
function formatDateTime(ms) {
|
function formatDateTime(ms: number): string {
|
||||||
if (!ms) return '-';
|
if (!ms) return '-';
|
||||||
const d = new Date(ms);
|
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())}`;
|
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);
|
const closeTrackModal = useTrackStore((s) => s.closeTrackModal);
|
||||||
|
|
||||||
// 스토어 상태 구독
|
// 스토어 상태 구독
|
||||||
@ -73,24 +87,24 @@ export default function TrackQueryModal({ modal }) {
|
|||||||
const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date()));
|
const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date()));
|
||||||
|
|
||||||
// 시작일 변경 핸들러
|
// 시작일 변경 핸들러
|
||||||
const handleStartChange = useCallback((e) => {
|
const handleStartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setStartInput(e.target.value);
|
setStartInput(e.target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 종료일 변경 핸들러
|
// 종료일 변경 핸들러
|
||||||
const handleEndChange = useCallback((e) => {
|
const handleEndChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setEndInput(e.target.value);
|
setEndInput(e.target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 조회 기간 검증 및 자동 조정 (blur 시 실행)
|
// 조회 기간 검증 및 자동 조정 (blur 시 실행)
|
||||||
const validateAndAdjustDates = useCallback((changedField) => {
|
const validateAndAdjustDates = useCallback((changedField: 'start' | 'end') => {
|
||||||
const startDate = new Date(startInput);
|
const startDate = new Date(startInput);
|
||||||
const endDate = new Date(endInput);
|
const endDate = new Date(endInput);
|
||||||
|
|
||||||
// 유효하지 않은 날짜면 무시
|
// 유효하지 않은 날짜면 무시
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
|
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') {
|
if (changedField === 'start') {
|
||||||
// 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정
|
// 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정
|
||||||
@ -151,7 +165,7 @@ export default function TrackQueryModal({ modal }) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// 드래그 핸들러
|
// 드래그 핸들러
|
||||||
const handleDragStart = useCallback((e) => {
|
const handleDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
dragStart.current = {
|
dragStart.current = {
|
||||||
x: e.clientX - position.x,
|
x: e.clientX - position.x,
|
||||||
@ -161,7 +175,7 @@ export default function TrackQueryModal({ modal }) {
|
|||||||
}, [position]);
|
}, [position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!dragging.current) return;
|
if (!dragging.current) return;
|
||||||
setPosition({
|
setPosition({
|
||||||
x: e.clientX - dragStart.current.x,
|
x: e.clientX - dragStart.current.x,
|
||||||
@ -197,7 +211,7 @@ export default function TrackQueryModal({ modal }) {
|
|||||||
}, [startInput, endInput, modal.ships]);
|
}, [startInput, endInput, modal.ships]);
|
||||||
|
|
||||||
// 프로그레스 바 클릭
|
// 프로그레스 바 클릭
|
||||||
const handleProgressClick = useCallback((e) => {
|
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
useTrackStore.getState().setProgressByRatio(ratio);
|
useTrackStore.getState().setProgressByRatio(ratio);
|
||||||
@ -392,18 +406,23 @@ export default function TrackQueryModal({ modal }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EquipmentFilter Props */
|
||||||
|
interface EquipmentFilterProps {
|
||||||
|
ship: ShipFeature;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 장비 필터 (통합선박 AVETDR)
|
* 장비 필터 (통합선박 AVETDR)
|
||||||
* 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts
|
* 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts
|
||||||
*/
|
*/
|
||||||
function EquipmentFilter({ ship }) {
|
function EquipmentFilter({ ship }: EquipmentFilterProps) {
|
||||||
const tracks = useTrackStore((s) => s.tracks);
|
const tracks = useTrackStore((s) => s.tracks);
|
||||||
const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds);
|
const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds);
|
||||||
|
|
||||||
// 항적 데이터에 존재하는 장비만 표시
|
// 항적 데이터에 존재하는 장비만 표시
|
||||||
const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd));
|
const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd));
|
||||||
|
|
||||||
const handleToggle = useCallback((sigSrcCd) => {
|
const handleToggle = useCallback((sigSrcCd: string) => {
|
||||||
useTrackStore.getState().toggleEquipment(sigSrcCd);
|
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(() => {
|
const handleToggle = useCallback(() => {
|
||||||
useTrackStore.getState().toggleVesselEnabled(track.vesselId);
|
useTrackStore.getState().toggleVesselEnabled(track.vesselId);
|
||||||
}, [track.vesselId]);
|
}, [track.vesselId]);
|
||||||
@ -5,6 +5,6 @@
|
|||||||
* 비활성화: 내부망 API(/api/gis) 접근 불가 (민간화)
|
* 비활성화: 내부망 API(/api/gis) 접근 불가 (민간화)
|
||||||
* TODO: 외부 API 연동 시 복원
|
* TODO: 외부 API 연동 시 복원
|
||||||
*/
|
*/
|
||||||
export default function useFavoriteData() {
|
export default function useFavoriteData(): void {
|
||||||
// noop — 내부망 /api/gis 의존 제거
|
// noop — 내부망 /api/gis 의존 제거
|
||||||
}
|
}
|
||||||
@ -12,6 +12,44 @@ import useTrackingModeStore, {
|
|||||||
NM_TO_METERS,
|
NM_TO_METERS,
|
||||||
} from '../stores/trackingModeStore';
|
} 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도당 대략적인 미터 (위도에 따라 다름)
|
* 경도 1도당 대략적인 미터 (위도에 따라 다름)
|
||||||
* 중위도(35도) 기준 약 91km
|
* 중위도(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 mode = useTrackingModeStore((s) => s.mode);
|
||||||
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
|
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
|
||||||
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
||||||
@ -32,7 +70,7 @@ export default function useRadiusFilter() {
|
|||||||
const isRadiusFilterActive = mode === 'ship' && trackedShip !== null;
|
const isRadiusFilterActive = mode === 'ship' && trackedShip !== null;
|
||||||
|
|
||||||
// 반경 중심 좌표
|
// 반경 중심 좌표
|
||||||
const radiusCenter = useMemo(() => {
|
const radiusCenter = useMemo((): RadiusCenter | null => {
|
||||||
if (!isRadiusFilterActive || !trackedShip) return null;
|
if (!isRadiusFilterActive || !trackedShip) return null;
|
||||||
return {
|
return {
|
||||||
lon: trackedShip.longitude,
|
lon: trackedShip.longitude,
|
||||||
@ -44,7 +82,7 @@ export default function useRadiusFilter() {
|
|||||||
* Bounding Box 계산 (사전 필터링용)
|
* Bounding Box 계산 (사전 필터링용)
|
||||||
* 반경을 감싸는 사각형 영역
|
* 반경을 감싸는 사각형 영역
|
||||||
*/
|
*/
|
||||||
const boundingBox = useMemo(() => {
|
const boundingBox = useMemo((): BoundingBox | null => {
|
||||||
if (!radiusCenter) return null;
|
if (!radiusCenter) return null;
|
||||||
|
|
||||||
const radiusMeters = radiusNM * NM_TO_METERS;
|
const radiusMeters = radiusNM * NM_TO_METERS;
|
||||||
@ -62,7 +100,7 @@ export default function useRadiusFilter() {
|
|||||||
/**
|
/**
|
||||||
* 선박이 Bounding Box 내에 있는지 빠른 체크
|
* 선박이 Bounding Box 내에 있는지 빠른 체크
|
||||||
*/
|
*/
|
||||||
const isInBoundingBox = useCallback((ship) => {
|
const isInBoundingBox = useCallback((ship: ShipWithCoords): boolean => {
|
||||||
if (!boundingBox) return true;
|
if (!boundingBox) return true;
|
||||||
if (!ship.longitude || !ship.latitude) return false;
|
if (!ship.longitude || !ship.latitude) return false;
|
||||||
|
|
||||||
@ -79,7 +117,7 @@ export default function useRadiusFilter() {
|
|||||||
* @param {Array} ships - 선박 배열
|
* @param {Array} ships - 선박 배열
|
||||||
* @returns {Array} 반경 내 선박만
|
* @returns {Array} 반경 내 선박만
|
||||||
*/
|
*/
|
||||||
const filterByRadius = useCallback((ships) => {
|
const filterByRadius = useCallback(<T extends ShipWithCoords>(ships: T[]): T[] => {
|
||||||
// 반경 필터 비활성화 시 전체 반환
|
// 반경 필터 비활성화 시 전체 반환
|
||||||
if (!isRadiusFilterActive || !radiusCenter) {
|
if (!isRadiusFilterActive || !radiusCenter) {
|
||||||
return ships;
|
return ships;
|
||||||
@ -99,7 +137,7 @@ export default function useRadiusFilter() {
|
|||||||
* @param {Object} ship
|
* @param {Object} ship
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
const isShipInRadius = useCallback((ship) => {
|
const isShipInRadius = useCallback((ship: ShipWithCoords): boolean => {
|
||||||
if (!isRadiusFilterActive || !radiusCenter) return true;
|
if (!isRadiusFilterActive || !radiusCenter) return true;
|
||||||
if (!isInBoundingBox(ship)) return false;
|
if (!isInBoundingBox(ship)) return false;
|
||||||
return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM);
|
return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM);
|
||||||
@ -110,12 +148,12 @@ export default function useRadiusFilter() {
|
|||||||
* @param {Map} featuresMap - featureId -> ship Map
|
* @param {Map} featuresMap - featureId -> ship Map
|
||||||
* @returns {Map} 반경 내 선박만
|
* @returns {Map} 반경 내 선박만
|
||||||
*/
|
*/
|
||||||
const filterFeaturesMapByRadius = useCallback((featuresMap) => {
|
const filterFeaturesMapByRadius = useCallback(<T extends ShipWithCoords>(featuresMap: Map<string, T>): Map<string, T> => {
|
||||||
if (!isRadiusFilterActive || !radiusCenter) {
|
if (!isRadiusFilterActive || !radiusCenter) {
|
||||||
return featuresMap;
|
return featuresMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMap = new Map();
|
const filteredMap = new Map<string, T>();
|
||||||
featuresMap.forEach((ship, featureId) => {
|
featuresMap.forEach((ship, featureId) => {
|
||||||
if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
|
if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
|
||||||
filteredMap.set(featureId, ship);
|
filteredMap.set(featureId, ship);
|
||||||
@ -140,7 +178,7 @@ export default function useRadiusFilter() {
|
|||||||
* 반경 필터 유틸리티 (비훅 버전)
|
* 반경 필터 유틸리티 (비훅 버전)
|
||||||
* shipStore나 다른 스토어에서 직접 사용
|
* shipStore나 다른 스토어에서 직접 사용
|
||||||
*/
|
*/
|
||||||
export function getRadiusFilterState() {
|
export function getRadiusFilterState(): RadiusFilterState {
|
||||||
const state = useTrackingModeStore.getState();
|
const state = useTrackingModeStore.getState();
|
||||||
const { mode, trackedShip, radiusNM } = state;
|
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();
|
const { isActive, center, radiusNM } = getRadiusFilterState();
|
||||||
if (!isActive || !center) return true;
|
if (!isActive || !center) return true;
|
||||||
return isWithinRadius(ship, center.lon, center.lat, radiusNM);
|
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 VectorSource from 'ol/source/Vector';
|
||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import Polygon from 'ol/geom/Polygon';
|
import Polygon from 'ol/geom/Polygon';
|
||||||
|
import type { Geometry } from 'ol/geom';
|
||||||
import { Style, Fill, Stroke, Text } from 'ol/style';
|
import { Style, Fill, Stroke, Text } from 'ol/style';
|
||||||
import useFavoriteStore from '../stores/favoriteStore';
|
import useFavoriteStore from '../stores/favoriteStore';
|
||||||
import { useMapStore } from '../stores/mapStore';
|
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 레이어 관리 훅
|
* 관심구역 OpenLayers 레이어 관리 훅
|
||||||
* 참조: mda-react-front/src/services/commonService.ts - getRealmLayer()
|
* 참조: mda-react-front/src/services/commonService.ts - getRealmLayer()
|
||||||
*/
|
*/
|
||||||
export default function useRealmLayer() {
|
export default function useRealmLayer(): void {
|
||||||
const map = useMapStore((s) => s.map);
|
const map = useMapStore((s) => s.map);
|
||||||
const layerRef = useRef(null);
|
const layerRef = useRef<VectorLayer<Feature<Geometry>> | null>(null);
|
||||||
const sourceRef = useRef(null);
|
const sourceRef = useRef<VectorSource | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -34,7 +50,7 @@ export default function useRealmLayer() {
|
|||||||
console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`);
|
console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`);
|
||||||
layer.setVisible(isRealmVisible);
|
layer.setVisible(isRealmVisible);
|
||||||
if (realmList.length > 0) {
|
if (realmList.length > 0) {
|
||||||
renderRealms(source, realmList);
|
renderRealms(source, realmList as unknown as RealmData[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// realmList 변경 구독
|
// realmList 변경 구독
|
||||||
@ -45,14 +61,14 @@ export default function useRealmLayer() {
|
|||||||
if (newRealmList.length > 0) {
|
if (newRealmList.length > 0) {
|
||||||
console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300));
|
console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300));
|
||||||
}
|
}
|
||||||
renderRealms(source, newRealmList);
|
renderRealms(source, newRealmList as unknown as RealmData[]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// isRealmVisible 변경 구독
|
// isRealmVisible 변경 구독
|
||||||
const unsubVisible = useFavoriteStore.subscribe(
|
const unsubVisible = useFavoriteStore.subscribe(
|
||||||
(state) => state.isRealmVisible,
|
(state) => state.isRealmVisible,
|
||||||
(isVisible) => {
|
(isVisible: boolean) => {
|
||||||
console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`);
|
console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`);
|
||||||
if (layerRef.current) {
|
if (layerRef.current) {
|
||||||
layerRef.current.setVisible(isVisible);
|
layerRef.current.setVisible(isVisible);
|
||||||
@ -78,7 +94,8 @@ export default function useRealmLayer() {
|
|||||||
* @param {Array} coordinates - 좌표 데이터
|
* @param {Array} coordinates - 좌표 데이터
|
||||||
* @returns {Array} Polygon rings 배열 [[lon,lat], ...]
|
* @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;
|
if (!Array.isArray(coordinates) || coordinates.length === 0) return null;
|
||||||
|
|
||||||
// coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...]
|
// coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...]
|
||||||
@ -99,7 +116,7 @@ function normalizeCoordinates(coordinates) {
|
|||||||
* @param {VectorSource} source - OL VectorSource
|
* @param {VectorSource} source - OL VectorSource
|
||||||
* @param {Array} realmList - 관심구역 데이터 배열
|
* @param {Array} realmList - 관심구역 데이터 배열
|
||||||
*/
|
*/
|
||||||
function renderRealms(source, realmList) {
|
function renderRealms(source: VectorSource, realmList: RealmData[]): void {
|
||||||
source.clear();
|
source.clear();
|
||||||
|
|
||||||
if (!realmList || realmList.length === 0) return;
|
if (!realmList || realmList.length === 0) return;
|
||||||
@ -16,18 +16,31 @@ const INITIAL_LOAD_MINUTES = 60;
|
|||||||
/** 증분 로드 기간 (분) */
|
/** 증분 로드 기간 (분) */
|
||||||
const INCREMENT_MINUTES = 2; // 약간의 중복 허용으로 누락 방지
|
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)
|
* @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 { autoConnect = true } = options;
|
||||||
|
|
||||||
const pollingRef = useRef(null);
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const initialLoadDoneRef = useRef(false);
|
const initialLoadDoneRef = useRef<boolean>(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const mergeFeatures = useShipStore((s) => s.mergeFeatures);
|
const mergeFeatures = useShipStore((s) => s.mergeFeatures);
|
||||||
const setConnected = useShipStore((s) => s.setConnected);
|
const setConnected = useShipStore((s) => s.setConnected);
|
||||||
const isConnected = useShipStore((s) => s.isConnected);
|
const isConnected = useShipStore((s) => s.isConnected);
|
||||||
@ -35,7 +48,7 @@ export default function useShipData(options = {}) {
|
|||||||
/**
|
/**
|
||||||
* AIS 데이터를 shipStore feature 형식으로 변환하여 머지
|
* AIS 데이터를 shipStore feature 형식으로 변환하여 머지
|
||||||
*/
|
*/
|
||||||
const loadAndMerge = useCallback(async (minutes) => {
|
const loadAndMerge = useCallback(async (minutes: number): Promise<number> => {
|
||||||
try {
|
try {
|
||||||
const aisTargets = await searchAisTargets(minutes);
|
const aisTargets = await searchAisTargets(minutes);
|
||||||
if (aisTargets.length > 0) {
|
if (aisTargets.length > 0) {
|
||||||
@ -53,7 +66,7 @@ export default function useShipData(options = {}) {
|
|||||||
/**
|
/**
|
||||||
* 폴링 시작
|
* 폴링 시작
|
||||||
*/
|
*/
|
||||||
const startPolling = useCallback(() => {
|
const startPolling = useCallback((): void => {
|
||||||
if (pollingRef.current) return;
|
if (pollingRef.current) return;
|
||||||
|
|
||||||
pollingRef.current = setInterval(() => {
|
pollingRef.current = setInterval(() => {
|
||||||
@ -66,7 +79,7 @@ export default function useShipData(options = {}) {
|
|||||||
/**
|
/**
|
||||||
* 폴링 중지
|
* 폴링 중지
|
||||||
*/
|
*/
|
||||||
const stopPolling = useCallback(() => {
|
const stopPolling = useCallback((): void => {
|
||||||
if (pollingRef.current) {
|
if (pollingRef.current) {
|
||||||
clearInterval(pollingRef.current);
|
clearInterval(pollingRef.current);
|
||||||
pollingRef.current = null;
|
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) {
|
if (initialLoadDoneRef.current) {
|
||||||
startPolling();
|
startPolling();
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
@ -102,7 +115,7 @@ export default function useShipData(options = {}) {
|
|||||||
/**
|
/**
|
||||||
* 연결 해제
|
* 연결 해제
|
||||||
*/
|
*/
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback((): void => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
}, [stopPolling, setConnected]);
|
}, [stopPolling, setConnected]);
|
||||||
@ -10,6 +10,7 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { Deck } from '@deck.gl/core';
|
import { Deck } from '@deck.gl/core';
|
||||||
import { toLonLat } from 'ol/proj';
|
import { toLonLat } from 'ol/proj';
|
||||||
|
import type Map from 'ol/Map';
|
||||||
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
|
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
|
||||||
import useShipStore from '../stores/shipStore';
|
import useShipStore from '../stores/shipStore';
|
||||||
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
||||||
@ -22,27 +23,42 @@ import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry';
|
|||||||
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
||||||
import useFavoriteStore from '../stores/favoriteStore';
|
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 맵 인스턴스
|
* @param {Map | null} map - OpenLayers 맵 인스턴스
|
||||||
* @returns {Object} { deckCanvas }
|
* @returns {UseShipLayerReturn} { deckCanvas }
|
||||||
*/
|
*/
|
||||||
export default function useShipLayer(map) {
|
export default function useShipLayer(map: Map | null): UseShipLayerReturn {
|
||||||
const deckRef = useRef(null);
|
const deckRef = useRef<Deck | null>(null);
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const animationFrameRef = useRef(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
const batchRendererInitialized = useRef(false);
|
const batchRendererInitialized = useRef<boolean>(false);
|
||||||
|
|
||||||
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
|
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
|
||||||
const isShipVisible = useShipStore((s) => s.isShipVisible);
|
const isShipVisible = useShipStore((s) => s.isShipVisible);
|
||||||
|
|
||||||
// 마지막 선박 레이어: 캐시용
|
// 마지막 선박 레이어: 캐시용
|
||||||
const lastShipLayersRef = useRef([]);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const lastShipLayersRef = useRef<any[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deck.gl 인스턴스 초기화
|
* Deck.gl 인스턴스 초기화
|
||||||
*/
|
*/
|
||||||
const initDeck = useCallback((container) => {
|
const initDeck = useCallback((container: HTMLElement): void => {
|
||||||
if (deckRef.current) return;
|
if (deckRef.current) return;
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@ -63,7 +79,7 @@ export default function useShipLayer(map) {
|
|||||||
layers: [],
|
layers: [],
|
||||||
useDevicePixels: true,
|
useDevicePixels: true,
|
||||||
pickingRadius: 20,
|
pickingRadius: 20,
|
||||||
onError: (error) => {
|
onError: (error: Error) => {
|
||||||
console.error('[Deck.gl] Error:', error);
|
console.error('[Deck.gl] Error:', error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -72,7 +88,7 @@ export default function useShipLayer(map) {
|
|||||||
/**
|
/**
|
||||||
* Deck.gl viewState를 OpenLayers 뷰와 동기화
|
* Deck.gl viewState를 OpenLayers 뷰와 동기화
|
||||||
*/
|
*/
|
||||||
const syncViewState = useCallback(() => {
|
const syncViewState = useCallback((): void => {
|
||||||
if (!map || !deckRef.current) return;
|
if (!map || !deckRef.current) return;
|
||||||
|
|
||||||
const view = map.getView();
|
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;
|
if (!map) return null;
|
||||||
|
|
||||||
const view = map.getView();
|
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;
|
if (!deckRef.current || !map) return;
|
||||||
|
|
||||||
const view = map.getView();
|
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 (!deckRef.current || !map) return;
|
||||||
|
|
||||||
if (!isShipVisible) {
|
if (!isShipVisible) {
|
||||||
@ -184,7 +201,7 @@ export default function useShipLayer(map) {
|
|||||||
/**
|
/**
|
||||||
* 렌더링 루프
|
* 렌더링 루프
|
||||||
*/
|
*/
|
||||||
const render = useCallback(() => {
|
const render = useCallback((): void => {
|
||||||
syncViewState();
|
syncViewState();
|
||||||
updateLayers();
|
updateLayers();
|
||||||
deckRef.current?.redraw();
|
deckRef.current?.redraw();
|
||||||
@ -202,8 +219,8 @@ export default function useShipLayer(map) {
|
|||||||
batchRendererInitialized.current = true;
|
batchRendererInitialized.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveEnd = () => { render(); };
|
const handleMoveEnd = (): void => { render(); };
|
||||||
const handlePostRender = () => {
|
const handlePostRender = (): void => {
|
||||||
syncViewState();
|
syncViewState();
|
||||||
deckRef.current?.redraw();
|
deckRef.current?.redraw();
|
||||||
};
|
};
|
||||||
@ -240,7 +257,8 @@ export default function useShipLayer(map) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = useShipStore.subscribe(
|
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],
|
(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 =
|
const filterChanged =
|
||||||
current[1] !== prev[1] ||
|
current[1] !== prev[1] ||
|
||||||
current[2] !== prev[2] ||
|
current[2] !== prev[2] ||
|
||||||
@ -261,7 +279,8 @@ export default function useShipLayer(map) {
|
|||||||
|
|
||||||
updateLayers();
|
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(); };
|
return () => { unsubscribe(); };
|
||||||
@ -278,7 +297,8 @@ export default function useShipLayer(map) {
|
|||||||
shipBatchRenderer.requestRender();
|
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();
|
return () => unsubscribe();
|
||||||
@ -297,7 +317,8 @@ export default function useShipLayer(map) {
|
|||||||
shipBatchRenderer.immediateRender();
|
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();
|
return () => unsubscribe();
|
||||||
@ -331,7 +352,8 @@ export default function useShipLayer(map) {
|
|||||||
shipBatchRenderer.immediateRender();
|
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();
|
return () => unsubscribe();
|
||||||
@ -19,6 +19,7 @@ import { fromLonLat } from 'ol/proj';
|
|||||||
import useShipStore from '../stores/shipStore';
|
import useShipStore from '../stores/shipStore';
|
||||||
import { useMapStore } from '../stores/mapStore';
|
import { useMapStore } from '../stores/mapStore';
|
||||||
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
|
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
|
||||||
|
import type { ShipFeature } from '../types/ship';
|
||||||
|
|
||||||
// 레이더 신호원 코드
|
// 레이더 신호원 코드
|
||||||
const SIGNAL_SOURCE_CODE_RADAR = '000005';
|
const SIGNAL_SOURCE_CODE_RADAR = '000005';
|
||||||
@ -32,12 +33,47 @@ const DEBOUNCE_MS = 200;
|
|||||||
// 한글 정규식
|
// 한글 정규식
|
||||||
const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/;
|
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);
|
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 '';
|
if (!text) return '';
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '')
|
.replace(/[\s\-_.,:;!@#$%^&*()+=[\]{}|\\/<>?'"]/g, '')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,11 +95,8 @@ function normalizeSearchText(text) {
|
|||||||
* 최소 검색 길이 확인
|
* 최소 검색 길이 확인
|
||||||
* - 한글 포함: 최소 2자
|
* - 한글 포함: 최소 2자
|
||||||
* - 영문/숫자만: 최소 3자
|
* - 영문/숫자만: 최소 3자
|
||||||
* @param {string} originalText - 원본 입력값
|
|
||||||
* @param {string} normalizedText - 정규화된 검색어
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
function meetsMinLength(originalText, normalizedText) {
|
function meetsMinLength(originalText: string, normalizedText: string): boolean {
|
||||||
if (!normalizedText) return false;
|
if (!normalizedText) return false;
|
||||||
|
|
||||||
const hasKorean = containsKorean(originalText);
|
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 [searchValue, setSearchValueState] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
|
||||||
const map = useMapStore((s) => s.map);
|
const map = useMapStore((s) => s.map);
|
||||||
const features = useShipStore((s) => s.features);
|
const features = useShipStore((s) => s.features);
|
||||||
@ -95,7 +125,7 @@ export default function useShipSearch() {
|
|||||||
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
|
||||||
|
|
||||||
// 디바운스 타이머 ref
|
// 디바운스 타이머 ref
|
||||||
const debounceTimerRef = useRef(null);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 타이머 정리
|
// 컴포넌트 언마운트 시 타이머 정리
|
||||||
useEffect(() => {
|
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);
|
const normalizedKeyword = normalizeSearchText(keyword);
|
||||||
|
|
||||||
// 최소 길이 미달 시 결과 초기화
|
// 최소 길이 미달 시 결과 초기화
|
||||||
@ -121,8 +150,8 @@ export default function useShipSearch() {
|
|||||||
|
|
||||||
// 반경 필터 상태 확인
|
// 반경 필터 상태 확인
|
||||||
const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null;
|
const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null;
|
||||||
let radiusBoundingBox = null;
|
let radiusBoundingBox: RadiusBoundingBox | null = null;
|
||||||
let radiusCenter = null;
|
let radiusCenter: RadiusCenter | null = null;
|
||||||
|
|
||||||
if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) {
|
if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) {
|
||||||
radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude };
|
radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude };
|
||||||
@ -140,7 +169,7 @@ export default function useShipSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasKorean = containsKorean(keyword);
|
const hasKorean = containsKorean(keyword);
|
||||||
const matchedShips = [];
|
const matchedShips: SearchResult[] = [];
|
||||||
|
|
||||||
// Map을 배열로 변환하여 for...of로 조기 종료 가능하게
|
// Map을 배열로 변환하여 for...of로 조기 종료 가능하게
|
||||||
const featuresArray = Array.from(features.entries());
|
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);
|
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;
|
if (!map || !result) return;
|
||||||
|
|
||||||
const { featureId, longitude, latitude, ship } = result;
|
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