refactor: OpenLayers → MapLibre GL JS 완전 전환 (Phase 3 Step 2) #1

병합
htlee refactor/maplibre-migration 에서 develop 로 8 commits 를 머지했습니다 2026-02-15 17:52:59 +09:00
174개의 변경된 파일6691개의 추가작업 그리고 2385개의 파일을 삭제
Showing only changes of commit 6e3ad9e0d8 - Show all commits

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
파일 보기

@ -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/

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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>

파일 보기

@ -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;
// 시퀀스 번호 순 정렬 후 INOUT 순서 // 시퀀스 번호 순 정렬 후 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; }

파일 보기

@ -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}&deg;</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="검색어 지우기"
> >
× &times;
</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">&#9654;</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,10 +37,17 @@ 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) {
return (
<li> <li>
<span>{label}</span> <span>{label}</span>
<label className="switch"> <label className="switch">
@ -42,12 +55,20 @@ const ToggleSwitch = memo(({ label, checked, onChange }) => (
<span className="slider" /> <span className="slider" />
</label> </label>
</li> </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) {
return (
<li> <li>
<span style={{ fontWeight: 'bold' }}>{label}</span> <span style={{ fontWeight: 'bold' }}>{label}</span>
<label className="switch"> <label className="switch">
@ -55,21 +76,27 @@ const AllToggle = memo(({ label, allChecked, onToggleAll }) => (
<span className="slider" /> <span className="slider" />
</label> </label>
</li> </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