chore: JavaScript → TypeScript 전환 완료 (77개 파일)

JS/JSX 77개 파일을 TS/TSX로 전환하고 JS 원본을 삭제.
- stores 7개, map core 6개, hooks 4개 등 전체 모듈 전환
- TypeScript strict 모드, OL/Deck.gl 타입 적용
- .gitignore에서 TS/TSX 무시 규칙 제거
- pre-commit hook: .js,.jsx → .ts,.tsx 확장자 변경
- tsc --noEmit 0 에러, ESLint 0 에러, yarn build 성공

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-15 10:28:27 +09:00
부모 2d871306ed
커밋 6e3ad9e0d8
174개의 변경된 파일6691개의 추가작업 그리고 2385개의 파일을 삭제

27
.eslintrc.cjs Normal file
파일 보기

@ -0,0 +1,27 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
settings: {
react: { version: 'detect' },
},
};

파일 보기

@ -1,6 +1,6 @@
#!/bin/bash
#==============================================================================
# pre-commit hook (React JavaScript)
# pre-commit hook (React TypeScript)
# ESLint 검증 — 실패 시 커밋 차단
#==============================================================================
@ -19,7 +19,7 @@ fi
# ESLint 검증 (설정 파일이 있는 경우만)
if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then
echo "pre-commit: ESLint 검증 중..."
npx eslint src/ --ext .js,.jsx --quiet 2>&1
npx eslint src/ --ext .ts,.tsx --quiet 2>&1
LINT_RESULT=$?
if [ $LINT_RESULT -ne 0 ]; then

7
.gitignore vendored
파일 보기

@ -31,8 +31,5 @@ Desktop.ini
.claude/settings.local.json
.claude/scripts/
# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외)
**/*.ts
**/*.tsx
# tracking VesselListManager (참조용)
src/tracking/components/VesselListManager/
# TypeScript config (vite.config.ts 등은 추적)
# tsconfig*.json은 추적

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>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

파일 보기

@ -5,14 +5,15 @@
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "vite build",
"build:dev": "vite build --mode dev",
"build:qa": "vite build --mode qa",
"build:prod": "vite build",
"build": "tsc -b && vite build",
"build:dev": "tsc -b && vite build --mode dev",
"build:qa": "tsc -b && vite build --mode qa",
"build:prod": "tsc -b && vite build",
"preview": "vite preview --port 3000",
"preview:dev": "vite preview --mode dev --port 3000",
"preview:qa": "vite preview --mode qa --port 3000",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
"type-check": "tsc -b --noEmit",
"lint": "eslint src --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@deck.gl/core": "^9.2.6",
@ -34,12 +35,18 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.0.1",
"eslint": "^8.44.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"sass": "^1.77.8",
"typescript": "~5.7.2",
"vite": "^5.2.10"
}
}

파일 보기

@ -3,17 +3,44 @@
* SNP-Batch AIS HTTP
*/
import axios from 'axios';
import type { ShipFeature } from '../types/ship';
// dev: Vite 프록시 (/snp-api → 211.208.115.83:8041)
// prod: 환경변수로 직접 지정
const BASE_URL = import.meta.env.VITE_API_URL || '/snp-api';
const BASE_URL: string = import.meta.env.VITE_API_URL || '/snp-api';
/** AIS Target API 응답 단건 인터페이스 */
interface AisTargetResponse {
mmsi?: number | string;
imo?: number | string;
name?: string;
callsign?: string;
vesselType?: string;
lat?: number;
lon?: number;
heading?: number;
sog?: number;
cog?: number;
rot?: number;
length?: number;
width?: number;
draught?: number | string;
destination?: string;
eta?: string;
status?: string;
messageTimestamp?: string;
receivedDate?: string;
source?: string;
classType?: string;
signalKindCode?: string;
}
/**
* AIS ( N분 )
* @param {number} minutes - ()
* @returns {Promise<Array>} AIS
* @returns {Promise<AisTargetResponse[]>} AIS
*/
export async function searchAisTargets(minutes = 60) {
export async function searchAisTargets(minutes: number = 60): Promise<AisTargetResponse[]> {
const res = await axios.get(`${BASE_URL}/api/ais-target/search`, {
params: { minutes },
timeout: 30000,
@ -30,10 +57,10 @@ export async function searchAisTargets(minutes = 60) {
* destination, eta, status, messageTimestamp, receivedDate,
* source, classType
*
* @param {Object} aisTarget - API
* @returns {Object} shipStore feature
* @param {AisTargetResponse} aisTarget - API
* @returns {ShipFeature} shipStore feature
*/
export function aisTargetToFeature(aisTarget) {
export function aisTargetToFeature(aisTarget: AisTargetResponse): ShipFeature {
const mmsi = String(aisTarget.mmsi || '');
// 백엔드에서 signalKindCode를 직접 제공, 없으면 vesselType 기반 fallback
const signalKindCode = aisTarget.signalKindCode || mapVesselTypeToKindCode(aisTarget.vesselType);
@ -109,7 +136,7 @@ export function aisTargetToFeature(aisTarget) {
/**
* vesselType
*/
function mapVesselTypeToKindCode(vesselType) {
function mapVesselTypeToKindCode(vesselType: string | undefined): string {
if (!vesselType) return '000027'; // 일반
const vt = vesselType.toLowerCase();
@ -125,11 +152,11 @@ function mapVesselTypeToKindCode(vesselType) {
/**
* ISO "YYYYMMDDHHmmss"
*/
function formatTimestamp(isoString) {
function formatTimestamp(isoString: string | undefined): string {
if (!isoString) return '';
try {
const d = new Date(isoString);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
} catch {
return '';

파일 보기

@ -5,13 +5,20 @@ import { fetchWithAuth } from './fetchWithAuth';
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
/** 공통코드 아이템 인터페이스 */
export interface CommonCodeItem {
commonCodeTypeName: string;
commonCodeTypeNumber: string;
commonCodeEtc: string;
}
/**
*
*
* @param {string} commonCodeTypeNumber -
* @returns {Promise<Array<{ commonCodeTypeName: string, commonCodeTypeNumber: string, commonCodeEtc: string }>>}
* @returns {Promise<CommonCodeItem[]>}
*/
export async function fetchCommonCodeList(commonCodeTypeNumber) {
export async function fetchCommonCodeList(commonCodeTypeNumber: string): Promise<CommonCodeItem[]> {
try {
const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, {
method: 'POST',

파일 보기

@ -1,10 +1,22 @@
import { fetchWithAuth } from './fetchWithAuth';
/** 관심선박 API 응답 아이템 */
export interface FavoriteShipItem {
signalSourceCode?: string;
targetId?: string;
[key: string]: unknown;
}
/** 관심구역 API 응답 아이템 */
export interface RealmItem {
[key: string]: unknown;
}
/**
*
* @returns {Promise<Array>}
* @returns {Promise<FavoriteShipItem[]>}
*/
export async function fetchFavoriteShips() {
export async function fetchFavoriteShips(): Promise<FavoriteShipItem[]> {
const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
@ -13,9 +25,9 @@ export async function fetchFavoriteShips() {
/**
*
* @returns {Promise<Array>}
* @returns {Promise<RealmItem[]>}
*/
export async function fetchRealms() {
export async function fetchRealms(): Promise<RealmItem[]> {
const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

파일 보기

@ -7,7 +7,7 @@ import { SESSION_TIMEOUT_MS } from '../types/constants';
* - 체크: 4011 ( )
* - credentials: 'include'
*/
export async function fetchWithAuth(url, options = {}) {
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
// 로컬 개발: 세션 타임아웃 체크 우회
if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') {
const loginDate = localStorage.getItem('loginDate');
@ -32,7 +32,7 @@ export async function fetchWithAuth(url, options = {}) {
throw new Error('Session expired (4011)');
}
} catch (e) {
if (e.message.includes('Session expired')) throw e;
if (e instanceof Error && e.message.includes('Session expired')) throw e;
}
}
}

파일 보기

@ -4,14 +4,15 @@
*/
import { parsePipeMessage, rowToShipObject } from '../common/stompClient';
import type { ShipFeature } from '../types/ship';
/**
* 12
* STOMP
*
* @returns {Promise<Array>}
* @returns {Promise<ShipFeature[]>}
*/
export async function fetchAllSignals() {
export async function fetchAllSignals(): Promise<ShipFeature[]> {
try {
const response = await fetch('/signal-api/all/12');
@ -30,10 +31,10 @@ export async function fetchAllSignals() {
}
// 각 행을 선박 객체로 변환
const ships = rawData.map((row) => {
const ships: ShipFeature[] = rawData.map((row: string | string[]) => {
// row가 문자열이면 파이프로 파싱, 배열이면 그대로 사용
const parsed = typeof row === 'string' ? parsePipeMessage(row) : row;
return rowToShipObject(parsed);
return rowToShipObject(parsed) as ShipFeature;
});
// 좌표가 있는 선박만 필터링
@ -54,7 +55,7 @@ export async function fetchAllSignals() {
*
* @returns {Promise<string[]>}
*/
export async function fetchAllSignalsRaw() {
export async function fetchAllSignalsRaw(): Promise<string[]> {
try {
const response = await fetch('/signal-api/all/12');
@ -73,7 +74,7 @@ export async function fetchAllSignalsRaw() {
}
// 문자열 배열로 변환 (각 행이 이미 문자열이면 그대로, 배열이면 파이프로 조인)
const rawLines = rawData.map((row) => {
const rawLines: string[] = rawData.map((row: string | string[]) => {
if (typeof row === 'string') {
return row;
}
@ -81,7 +82,7 @@ export async function fetchAllSignalsRaw() {
return row.join('|');
}
return '';
}).filter(line => line.trim());
}).filter((line: string) => line.trim());
console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`);

파일 보기

@ -9,21 +9,73 @@
*/
import useShipStore from '../stores/shipStore';
import { fetchWithAuth } from './fetchWithAuth';
import type { ShipFeature } from '../types/ship';
/** API 엔드포인트 (메인 프로젝트와 동일) */
const API_ENDPOINT = '/api/v2/tracks/vessels';
/** 선박 식별자 (항적 조회용) */
export interface VesselIdentifier {
sigSrcCd: string;
targetId: string;
}
/** 항적 통계 */
interface TrackStats {
totalDistance: number;
avgSpeed: number;
maxSpeed: number;
pointCount: number;
}
/** 가공된 항적 데이터 */
export interface ProcessedTrack {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
integrationTargetId: string;
geometry: number[][];
timestampsMs: number[];
speeds: number[];
stats: TrackStats;
}
/** 항적 조회 요청 파라미터 */
interface TrackQueryParams {
startTime: string;
endTime: string;
vessels: VesselIdentifier[];
isIntegration?: boolean;
}
/** API 원시 응답 항적 */
interface RawTrack {
vesselId?: string;
sigSrcCd?: string;
targetId?: string;
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
integrationTargetId?: string;
geometry?: number[][];
timestamps?: (string | number)[];
speeds?: number[];
totalDistance?: number;
avgSpeed?: number;
maxSpeed?: number;
pointCount?: number;
}
/**
*
*
* @param {Object} params
* @param {string} params.startTime - (ISO 8601, e.g. '2026-01-01T00:00:00')
* @param {string} params.endTime - (ISO 8601)
* @param {Array<{ sigSrcCd: string, targetId: string }>} params.vessels -
* @param {boolean} [params.isIntegration=false] -
* @returns {Promise<Array>} ProcessedTrack
* @param {TrackQueryParams} params
* @returns {Promise<ProcessedTrack[]>} ProcessedTrack
*/
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) {
export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }: TrackQueryParams): Promise<ProcessedTrack[]> {
try {
const body = {
startTime,
@ -45,7 +97,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
const result = await response.json();
// v2 API는 배열을 직접 반환
const rawTracks = Array.isArray(result) ? result : (result?.data || []);
const rawTracks: RawTrack[] = Array.isArray(result) ? result : (result?.data || []);
if (!Array.isArray(rawTracks)) {
console.warn('[fetchTrackQuery] Invalid response format:', result);
@ -55,7 +107,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
// 가공: CompactVesselTrack → ProcessedTrack
const processed = rawTracks
.map((raw) => processTrack(raw))
.filter((t) => t !== null);
.filter((t): t is ProcessedTrack => t !== null);
console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`);
return processed;
@ -69,10 +121,10 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati
* API ProcessedTrack으로
* 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks
*
* @param {Object} raw - API
* @returns {Object|null} ProcessedTrack
* @param {RawTrack} raw - API
* @returns {ProcessedTrack|null} ProcessedTrack
*/
function processTrack(raw) {
function processTrack(raw: RawTrack): ProcessedTrack | null {
if (!raw || !raw.geometry || raw.geometry.length === 0) return null;
const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`;
@ -115,11 +167,11 @@ function processTrack(raw) {
/**
*
* @param {string} targetId
* @param {string} sigSrcCd
* @returns {Object|null}
* @param {string|undefined} targetId
* @param {string|undefined} sigSrcCd
* @returns {ShipFeature|null}
*/
function findLiveShipData(targetId, sigSrcCd) {
function findLiveShipData(targetId: string | undefined, sigSrcCd: string | undefined): ShipFeature | null {
if (!targetId) return null;
const features = useShipStore.getState().features;
@ -132,7 +184,7 @@ function findLiveShipData(targetId, sigSrcCd) {
}
// featureId로 못 찾으면 originalTargetId로 검색
let found = null;
let found: ShipFeature | null = null;
features.forEach((ship) => {
if (ship.originalTargetId === targetId) {
found = ship;
@ -144,10 +196,10 @@ function findLiveShipData(targetId, sigSrcCd) {
/**
*
* @param {Object} ship - shipStore의
* @returns {{ sigSrcCd: string, targetId: string }}
* @param {ShipFeature} ship - shipStore의
* @returns {VesselIdentifier}
*/
export function extractVesselIdentifier(ship) {
export function extractVesselIdentifier(ship: ShipFeature): VesselIdentifier {
return {
sigSrcCd: ship.signalSourceCode || '',
targetId: ship.originalTargetId || ship.targetId || '',
@ -159,8 +211,8 @@ export function extractVesselIdentifier(ship) {
* @param {Date} date
* @returns {string} 'YYYY-MM-DDTHH:mm:ss'
*/
export function toLocalISOString(date) {
const pad = (n) => String(n).padStart(2, '0');
export function toLocalISOString(date: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
@ -170,14 +222,14 @@ export function toLocalISOString(date) {
* 00000
*
* @param {string} targetId - TARGET_ID
* @returns {Array<{ sigSrcCd: string, targetId: string }>}
* @returns {VesselIdentifier[]}
*/
export function parseIntegratedTargetId(targetId) {
export function parseIntegratedTargetId(targetId: string): VesselIdentifier[] {
if (!targetId) return [];
const parts = targetId.split('_');
// 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF
const equipmentMap = [
const equipmentMap: { sigSrcCd: string; index: number }[] = [
{ sigSrcCd: '000001', index: 0 }, // AIS
{ sigSrcCd: '000003', index: 1 }, // VPASS
{ sigSrcCd: '000002', index: 2 }, // ENAV
@ -185,7 +237,7 @@ export function parseIntegratedTargetId(targetId) {
{ sigSrcCd: '000016', index: 4 }, // D_MF_HF
];
const vessels = [];
const vessels: VesselIdentifier[] = [];
equipmentMap.forEach(({ sigSrcCd, index }) => {
const id = parts[index];
if (id && id !== '00000' && id !== '0' && id !== '') {
@ -201,10 +253,10 @@ export function parseIntegratedTargetId(targetId) {
* 통합선박: TARGET_ID ( )
* 단일선박: 기본 identifier
*
* @param {Object} ship - shipStore
* @returns {Array<{ sigSrcCd: string, targetId: string }>}
* @param {ShipFeature} ship - shipStore
* @returns {VesselIdentifier[]}
*/
export function buildVesselListForQuery(ship) {
export function buildVesselListForQuery(ship: ShipFeature): VesselIdentifier[] {
if (ship.integrate && ship.targetId && ship.targetId.includes('_')) {
return parseIntegratedTargetId(ship.targetId);
}

파일 보기

@ -4,11 +4,24 @@ import { USER_SETTING_FILTER } from '../types/constants';
const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search';
const SAVE_ENDPOINT = '/api/cmn/personal/settings/save';
/** 필터 설정 아이템 */
export interface FilterSettingItem {
settingCode: string;
settingValue: string;
[key: string]: unknown;
}
/** 필터 저장 요청 아이템 */
export interface FilterSaveItem {
code: string;
value: string;
}
/**
*
* @returns {Promise<Array|null>} null ( )
* @returns {Promise<FilterSettingItem[]|null>} null ( )
*/
export async function fetchUserFilter() {
export async function fetchUserFilter(): Promise<FilterSettingItem[] | null> {
const url = `${SEARCH_ENDPOINT}?type=${USER_SETTING_FILTER}`;
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@ -19,9 +32,9 @@ export async function fetchUserFilter() {
/**
*
* @param {Array<{code: string, value: string}>} settings
* @param {FilterSaveItem[]} settings
*/
export async function saveUserFilter(settings) {
export async function saveUserFilter(settings: FilterSaveItem[]): Promise<unknown> {
const response = await fetchWithAuth(SAVE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

파일 보기

@ -6,6 +6,8 @@ import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'
import { fetchAreaSearch } from '../services/areaSearchApi';
import { fetchVesselContacts } from '../services/stsApi';
import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types';
import type { AnalysisTab, Zone } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import { showToast } from '../../components/common/Toast';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
@ -16,12 +18,17 @@ import StsAnalysisTab from './StsAnalysisTab';
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
function toKstISOString(date) {
const pad = (n) => String(n).padStart(2, '0');
function toKstISOString(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
export default function AreaSearchPage({ isOpen, onToggle }) {
interface AreaSearchPageProps {
isOpen: boolean;
onToggle: () => void;
}
export default function AreaSearchPage({ isOpen, onToggle }: AreaSearchPageProps) {
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('00:00');
const [endDate, setEndDate] = useState('');
@ -67,7 +74,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
// ========== 탭 전환 ==========
const handleTabChange = useCallback((newTab) => {
const handleTabChange = useCallback((newTab: AnalysisTab) => {
if (newTab === activeTab) return;
const areaState = useAreaSearchStore.getState();
@ -101,12 +108,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
// ========== 날짜 핸들러 ==========
const handleStartDateChange = useCallback((newStartDate) => {
const handleStartDateChange = useCallback((newStartDate: string) => {
setStartDate(newStartDate);
const start = new Date(`${newStartDate}T${startTime}:00`);
const end = new Date(`${endDate}T${endTime}:00`);
const diffDays = (end - start) / DAYS_TO_MS;
const pad = (n) => String(n).padStart(2, '0');
const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS;
const pad = (n: number) => String(n).padStart(2, '0');
if (diffDays < 0) {
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
@ -121,12 +128,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
}
}, [startTime, endDate, endTime]);
const handleEndDateChange = useCallback((newEndDate) => {
const handleEndDateChange = useCallback((newEndDate: string) => {
setEndDate(newEndDate);
const start = new Date(`${startDate}T${startTime}:00`);
const end = new Date(`${newEndDate}T${endTime}:00`);
const diffDays = (end - start) / DAYS_TO_MS;
const pad = (n) => String(n).padStart(2, '0');
const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS;
const pad = (n: number) => String(n).padStart(2, '0');
if (diffDays < 0) {
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
@ -152,7 +159,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
setErrorMessage('');
useAreaSearchStore.getState().setLoading(true);
const polygons = zones.map((z) => ({
const polygons = zones.map((z: Zone) => ({
id: z.id,
name: z.name,
coordinates: z.coordinates,
@ -177,7 +184,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
let minTime = Infinity;
let maxTime = -Infinity;
result.tracks.forEach((t) => {
result.tracks.forEach((t: ProcessedTrack) => {
if (t.timestampsMs.length > 0) {
minTime = Math.min(minTime, t.timestampsMs[0]);
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
@ -190,7 +197,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
} catch (error) {
console.error('[AreaSearch] 조회 실패:', error);
useAreaSearchStore.getState().setLoading(false);
setErrorMessage(`조회 실패: ${error.message}`);
setErrorMessage(`조회 실패: ${(error as Error).message}`);
}
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
@ -228,7 +235,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
let minTime = Infinity;
let maxTime = -Infinity;
result.tracks.forEach((t) => {
result.tracks.forEach((t: ProcessedTrack) => {
if (t.timestampsMs.length > 0) {
minTime = Math.min(minTime, t.timestampsMs[0]);
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
@ -241,7 +248,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
} catch (error) {
console.error('[STS] 조회 실패:', error);
useStsStore.getState().setLoading(false);
setErrorMessage(`조회 실패: ${error.message}`);
setErrorMessage(`조회 실패: ${(error as Error).message}`);
}
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);

파일 보기

@ -12,13 +12,20 @@ import { useAreaSearchStore } from '../stores/areaSearchStore';
import {
SEARCH_MODE_LABELS,
} from '../types/areaSearch.types';
import type { SearchMode } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import ZoneDrawPanel from './ZoneDrawPanel';
import VesselDetailModal from './VesselDetailModal';
import { exportSearchResultToCSV } from '../utils/csvExport';
export default function AreaSearchTab({ isLoading, errorMessage }) {
const [detailVesselId, setDetailVesselId] = useState(null);
interface AreaSearchTabProps {
isLoading: boolean;
errorMessage: string;
}
export default function AreaSearchTab({ isLoading, errorMessage }: AreaSearchTabProps) {
const [detailVesselId, setDetailVesselId] = useState<string | null>(null);
const zones = useAreaSearchStore((s) => s.zones);
const searchMode = useAreaSearchStore((s) => s.searchMode);
@ -30,11 +37,11 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
const handleToggleVessel = useCallback((vesselId) => {
const handleToggleVessel = useCallback((vesselId: string) => {
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
}, []);
const handleHighlightVessel = useCallback((vesselId) => {
const handleHighlightVessel = useCallback((vesselId: string | null) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
}, []);
@ -42,7 +49,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
exportSearchResultToCSV(tracks, hitDetails, zones);
}, [tracks, hitDetails, zones]);
const listRef = useRef(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (!highlightedVesselId || !listRef.current) return;
@ -73,7 +80,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
checked={searchMode === mode}
onChange={() => {
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
setSearchMode(mode);
setSearchMode(mode as SearchMode);
}}
disabled={isLoading}
/>
@ -105,7 +112,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
</div>
<ul className="vessel-list" ref={listRef}>
{tracks.map((track) => {
{tracks.map((track: ProcessedTrack) => {
const isDisabled = disabledVesselIds.has(track.vesselId);
const isHighlighted = highlightedVesselId === track.vesselId;
const color = getShipKindColor(track.shipKindCode);

파일 보기

@ -1,13 +1,13 @@
/**
*
* 참조: src/replay/components/ReplayTimeline.jsx ()
* 참조: src/replay/components/ReplayTimeline.tsx ()
*
* - //
* - (1x ~ 1000x)
* - (range slider)
* -
*/
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { useCallback, useEffect, useRef, useState, useMemo, ChangeEvent } from 'react';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useStsStore } from '../stores/stsStore';
@ -21,10 +21,10 @@ import './AreaSearchTimeline.scss';
const PATH_LABEL = '항적';
const TRAIL_LABEL = '궤적';
function formatDateTime(ms) {
function formatDateTime(ms: number): string {
if (!ms || ms <= 0) return '--:--:--';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
@ -68,18 +68,18 @@ export default function AreaSearchTimeline() {
}, [currentTime, startTime, endTime]);
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const speedMenuRef = useRef(null);
const speedMenuRef = useRef<HTMLDivElement>(null);
// 드래그
const [isDragging, setIsDragging] = useState(false);
const [hasDragged, setHasDragged] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) {
const handleClickOutside = (event: MouseEvent) => {
if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) {
setShowSpeedMenu(false);
}
};
@ -87,7 +87,7 @@ export default function AreaSearchTimeline() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showSpeedMenu]);
const handleMouseDown = useCallback((e) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const parent = containerRef.current.parentElement;
@ -103,7 +103,7 @@ export default function AreaSearchTimeline() {
}, [hasDragged]);
useEffect(() => {
const handleMouseMove = (e) => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !containerRef.current) return;
const parent = containerRef.current.parentElement;
if (!parent) return;
@ -135,12 +135,12 @@ export default function AreaSearchTimeline() {
const handleStop = useCallback(() => { stop(); }, [stop]);
const handleSpeedChange = useCallback((speed) => {
const handleSpeedChange = useCallback((speed: number) => {
setPlaybackSpeed(speed);
setShowSpeedMenu(false);
}, [setPlaybackSpeed]);
const handleSliderChange = useCallback((e) => {
const handleSliderChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setCurrentTime(parseFloat(e.target.value));
}, [setCurrentTime]);
@ -232,7 +232,7 @@ export default function AreaSearchTimeline() {
value={currentTime}
onChange={handleSliderChange}
disabled={!hasData}
style={{ '--progress': `${progress}%` }}
style={{ '--progress': `${progress}%` } as React.CSSProperties}
/>
</div>

파일 보기

@ -6,26 +6,28 @@
import { useMemo } from 'react';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import type { Zone, HitDetail } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import './AreaSearchTooltip.scss';
const OFFSET_X = 14;
const OFFSET_Y = -20;
/** nationalCode 국기 SVG URL */
function getNationalFlagUrl(nationalCode) {
/** nationalCode -> 국기 SVG URL */
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
export function formatTimestamp(ms) {
export function formatTimestamp(ms: number | null | undefined): string {
if (!ms) return '-';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function formatPosition(pos) {
export function formatPosition(pos: number[] | null | undefined): string | null {
if (!pos || pos.length < 2) return null;
const lon = pos[0];
const lat = pos[1];
@ -41,8 +43,8 @@ export default function AreaSearchTooltip() {
const zones = useAreaSearchStore((s) => s.zones);
const zoneMap = useMemo(() => {
const map = new Map();
zones.forEach((z, idx) => {
const map = new Map<string | number, Zone>();
zones.forEach((z: Zone, idx: number) => {
map.set(z.id, z);
map.set(z.name, z);
map.set(idx, z);
@ -54,16 +56,16 @@ export default function AreaSearchTooltip() {
if (!tooltip) return null;
const { vesselId, x, y } = tooltip;
const track = tracks.find((t) => t.vesselId === vesselId);
const track = tracks.find((t: ProcessedTrack) => t.vesselId === vesselId);
if (!track) return null;
const hits = hitDetails[vesselId] || [];
const hits: HitDetail[] = hitDetails[vesselId] || [];
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const flagUrl = getNationalFlagUrl(track.nationalCode);
// 시간순 정렬 (구역 무관)
const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp);
const sortedHits = [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0));
return (
<div
@ -77,7 +79,7 @@ export default function AreaSearchTooltip() {
<img
src={flagUrl}
alt="국기"
onError={(e) => { e.target.style.display = 'none'; }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</span>
)}

파일 보기

@ -5,33 +5,37 @@
* - STS ( , )
* - 결과: StsContactList
*/
import { useCallback, useState } from 'react';
import { useCallback, useState, ChangeEvent } from 'react';
import './StsAnalysisTab.scss';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { STS_LIMITS } from '../types/sts.types';
import ZoneDrawPanel from './ZoneDrawPanel';
import StsContactList from './StsContactList';
import StsContactDetailModal from './StsContactDetailModal';
export default function StsAnalysisTab({ isLoading, errorMessage }) {
interface StsAnalysisTabProps {
isLoading: boolean;
errorMessage: string;
}
export default function StsAnalysisTab({ isLoading, errorMessage }: StsAnalysisTabProps) {
const queryCompleted = useStsStore((s) => s.queryCompleted);
const groupedContacts = useStsStore((s) => s.groupedContacts);
const summary = useStsStore((s) => s.summary);
const minContactDuration = useStsStore((s) => s.minContactDurationMinutes);
const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters);
const handleDurationChange = useCallback((e) => {
const handleDurationChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
useStsStore.getState().setMinContactDuration(Number(e.target.value));
}, []);
const handleDistanceChange = useCallback((e) => {
const handleDistanceChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
useStsStore.getState().setMaxContactDistance(Number(e.target.value));
}, []);
const [detailGroupIndex, setDetailGroupIndex] = useState(null);
const [detailGroupIndex, setDetailGroupIndex] = useState<number | null>(null);
const handleDetailClick = useCallback((idx) => {
const handleDetailClick = useCallback((idx: number) => {
setDetailGroupIndex(idx);
}, []);

파일 보기

@ -1,10 +1,10 @@
/**
* STS OL + +
* STS -- OL + +
* 기반: 동일
*/
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Map from 'ol/Map';
import OlMap from 'ol/Map';
import View from 'ol/View';
import { XYZ } from 'ol/source';
import TileLayer from 'ol/layer/Tile';
@ -21,6 +21,9 @@ import html2canvas from 'html2canvas';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import type { Zone } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { StsVessel, StsContact } from '../types/sts.types';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
import {
@ -32,13 +35,13 @@ import {
import { mapLayerConfig } from '../../map/layers/baseLayer';
import './StsContactDetailModal.scss';
function getNationalFlagUrl(nationalCode) {
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function createZoneFeatures(zones) {
const features = [];
function createZoneFeatures(zones: Zone[]): Feature[] {
const features: Feature[] = [];
zones.forEach((zone) => {
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
const polygon = new Polygon([coords3857]);
@ -68,7 +71,7 @@ function createZoneFeatures(zones) {
return features;
}
function createTrackFeature(track) {
function createTrackFeature(track: ProcessedTrack): Feature {
const coords3857 = track.geometry.map((c) => fromLonLat(c));
const line = new LineString(coords3857);
const feature = new Feature({ geometry: line });
@ -82,14 +85,14 @@ function createTrackFeature(track) {
return feature;
}
function createContactMarkers(contacts) {
const features = [];
function createContactMarkers(contacts: StsContact[]): Feature[] {
const features: Feature[] = [];
contacts.forEach((contact, idx) => {
if (!contact.contactCenterPoint) return;
const pos3857 = fromLonLat(contact.contactCenterPoint);
const riskColor = getContactRiskColor(contact.indicators);
const riskColor = getContactRiskColor(contact.indicators ?? null);
const f = new Feature({ geometry: new Point(pos3857) });
f.setStyle(new Style({
@ -131,14 +134,19 @@ function createContactMarkers(contacts) {
const MODAL_WIDTH = 680;
const MODAL_APPROX_HEIGHT = 780;
export default function StsContactDetailModal({ groupIndex, onClose }) {
interface StsContactDetailModalProps {
groupIndex: number;
onClose: () => void;
}
export default function StsContactDetailModal({ groupIndex, onClose }: StsContactDetailModalProps) {
const groupedContacts = useStsStore((s) => s.groupedContacts);
const tracks = useStsStore((s) => s.tracks);
const zones = useAreaSearchStore((s) => s.zones);
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const contentRef = useRef(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<OlMap | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(() => ({
x: (window.innerWidth - MODAL_WIDTH) / 2,
@ -148,7 +156,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - posRef.current.x,
@ -158,7 +166,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
}, []);
useEffect(() => {
const handleMouseMove = (e) => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
@ -180,11 +188,11 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]);
const vessel1Track = useMemo(
() => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId),
() => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel1?.vesselId),
[tracks, group],
);
const vessel2Track = useMemo(
() => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId),
() => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel2?.vesselId),
[tracks, group],
);
@ -193,7 +201,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return;
const tileSource = new XYZ({
url: mapLayerConfig.darkLayer.source.getUrls()[0],
url: mapLayerConfig.darkLayer.source.getUrls()![0],
minZoom: 6,
maxZoom: 11,
});
@ -211,7 +219,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
const markerSource = new VectorSource({ features: markerFeatures });
const markerLayer = new VectorLayer({ source: markerSource });
const map = new Map({
const map = new OlMap({
target: mapContainerRef.current,
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
view: new View({ center: [0, 0], zoom: 7 }),
@ -230,7 +238,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
mapRef.current = map;
return () => {
map.setTarget(null);
map.setTarget(undefined);
map.dispose();
mapRef.current = null;
};
@ -240,7 +248,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
const el = contentRef.current;
if (!el) return;
const modal = el.parentElement;
const modal = el.parentElement as HTMLElement;
const saved = {
elOverflow: el.style.overflow,
modalMaxHeight: modal.style.maxHeight,
@ -261,7 +269,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
const now = new Date();
const v1Name = group?.vessel1?.vesselName || 'V1';
const v2Name = group?.vessel2?.vesselName || 'V2';
@ -301,7 +309,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
<div className="sts-detail-modal__header" onMouseDown={handleMouseDown}>
<div className="sts-detail-modal__title">
<VesselBadge vessel={vessel1} track={vessel1Track} />
<span className="sts-detail-modal__arrow"></span>
<span className="sts-detail-modal__arrow">{'\u2194'}</span>
<VesselBadge vessel={vessel2} track={vessel2Track} />
</div>
<button type="button" className="sts-detail-modal__close" onClick={onClose}>
@ -318,7 +326,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
{/* 접촉 요약 그리드 2열 */}
{/* 접촉 요약 -- 그리드 2열 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title"> </h4>
<div className="sts-detail-modal__summary-grid">
@ -362,7 +370,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title"> ({group.contacts.length})</h4>
<div className="sts-detail-modal__contact-list">
{group.contacts.map((c, ci) => (
{group.contacts.map((c: StsContact, ci: number) => (
<div key={ci} className="sts-detail-modal__contact-item">
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
@ -376,7 +384,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
</div>
)}
{/* 거리 통계 3열 그리드 */}
{/* 거리 통계 -- 3열 그리드 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title"> </h4>
<div className="sts-detail-modal__stats-grid">
@ -407,7 +415,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
</div>
</div>
{/* 선박 상세 2열 그리드 */}
{/* 선박 상세 -- 2열 그리드 */}
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
</div>
@ -423,7 +431,12 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
);
}
function VesselBadge({ vessel, track }) {
interface VesselBadgeProps {
vessel: StsVessel;
track: ProcessedTrack;
}
function VesselBadge({ vessel, track }: VesselBadgeProps) {
const kindName = getShipKindName(track.shipKindCode);
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
return (
@ -434,7 +447,7 @@ function VesselBadge({ vessel, track }) {
className="sts-detail-modal__flag"
src={flagUrl}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span className="sts-detail-modal__name">
@ -444,7 +457,13 @@ function VesselBadge({ vessel, track }) {
);
}
function VesselDetailSection({ label, vessel, track }) {
interface VesselDetailSectionProps {
label: string;
vessel: StsVessel;
track: ProcessedTrack;
}
function VesselDetailSection({ label, vessel, track }: VesselDetailSectionProps) {
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const color = getShipKindColor(track.shipKindCode);
@ -456,7 +475,7 @@ function VesselDetailSection({ label, vessel, track }) {
className="sts-detail-modal__track-dot"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
{label} {vessel.vesselName || vessel.vesselId}
{label} -- {vessel.vesselName || vessel.vesselId}
</h4>
<div className="sts-detail-modal__vessel-grid">
<div className="sts-detail-modal__vessel-grid-item">

파일 보기

@ -2,15 +2,16 @@
* STS ( )
*
* -
* - on/off
* - /
* -
* -
* - -> on/off
* - /
* -
* - ->
*/
import { useCallback, useEffect, useRef } from 'react';
import './StsContactList.scss';
import { useStsStore } from '../stores/stsStore';
import { getShipKindName } from '../../tracking/types/trackQuery.types';
import type { StsGroupedContact, StsVessel, StsContact } from '../types/sts.types';
import {
getIndicatorDetail,
formatDistance,
@ -19,12 +20,18 @@ import {
} from '../types/sts.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
function getNationalFlagUrl(nationalCode) {
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function GroupCard({ group, index, onDetailClick }) {
interface GroupCardProps {
group: StsGroupedContact;
index: number;
onDetailClick?: (idx: number) => void;
}
function GroupCard({ group, index, onDetailClick }: GroupCardProps) {
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
@ -42,19 +49,19 @@ function GroupCard({ group, index, onDetailClick }) {
useStsStore.getState().setHighlightedGroupIndex(null);
}, []);
// 카드 클릭 on/off 토글
// 카드 클릭 -> on/off 토글
const handleClick = useCallback(() => {
useStsStore.getState().toggleGroupEnabled(index);
}, [index]);
// ▼/▲ 버튼 → 하단 정보 확장
const handleExpand = useCallback((e) => {
// 확장/접힘 토글
const handleExpand = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
useStsStore.getState().setExpandedGroupIndex(index);
}, [index]);
// ▶ 버튼 → 모달 열기
const handleDetail = useCallback((e) => {
// 모달 열기
const handleDetail = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDetailClick?.(index);
}, [index, onDetailClick]);
@ -97,7 +104,7 @@ function GroupCard({ group, index, onDetailClick }) {
className="sts-card__flag"
src={v1Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel1.vesselName || vessel1.vesselId}</span>
@ -105,7 +112,7 @@ function GroupCard({ group, index, onDetailClick }) {
{/* 접촉 요약 (그룹 합산) */}
<div className="sts-card__contact-summary">
<span className="sts-card__arrow"></span>
<span className="sts-card__arrow">{'\u2195'}</span>
<span>{formatDuration(group.totalDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span> {formatDistance(group.avgDistanceMeters)}</span>
@ -122,7 +129,7 @@ function GroupCard({ group, index, onDetailClick }) {
className="sts-card__flag"
src={v2Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel2.vesselName || vessel2.vesselId}</span>
@ -132,7 +139,7 @@ function GroupCard({ group, index, onDetailClick }) {
onClick={handleExpand}
title="상세 정보"
>
{isExpanded ? '▲' : '▼'}
{isExpanded ? '\u25B2' : '\u25BC'}
</button>
<button
type="button"
@ -140,7 +147,7 @@ function GroupCard({ group, index, onDetailClick }) {
onClick={handleDetail}
title="상세 모달"
>
{'\u25B6'}
</button>
</div>
@ -167,7 +174,7 @@ function GroupCard({ group, index, onDetailClick }) {
{group.contacts.length > 1 && (
<div className="sts-card__sub-contacts">
<span className="sts-card__sub-title"> ({group.contacts.length})</span>
{group.contacts.map((c, ci) => (
{group.contacts.map((c: StsContact, ci: number) => (
<div key={ci} className="sts-card__sub-contact">
<span className="sts-card__sub-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
@ -208,7 +215,12 @@ function GroupCard({ group, index, onDetailClick }) {
);
}
function VesselDetail({ label, vessel }) {
interface VesselDetailProps {
label: string;
vessel: StsVessel;
}
function VesselDetail({ label, vessel }: VesselDetailProps) {
return (
<div className="sts-card__vessel-detail">
<div className="sts-card__vessel-detail-header">
@ -233,10 +245,14 @@ function VesselDetail({ label, vessel }) {
);
}
export default function StsContactList({ onDetailClick }) {
interface StsContactListProps {
onDetailClick?: (idx: number) => void;
}
export default function StsContactList({ onDetailClick }: StsContactListProps) {
const groupedContacts = useStsStore((s) => s.groupedContacts);
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const listRef = useRef(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (highlightedGroupIndex === null || !listRef.current) return;
@ -253,7 +269,7 @@ export default function StsContactList({ onDetailClick }) {
return (
<ul className="sts-contact-list" ref={listRef}>
{groupedContacts.map((group, idx) => (
{groupedContacts.map((group: StsGroupedContact, idx: number) => (
<GroupCard key={group.pairKey} group={group} index={idx} onDetailClick={onDetailClick} />
))}
</ul>

파일 보기

@ -1,9 +1,9 @@
/**
* OL + +
* -- OL + +
*/
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Map from 'ol/Map';
import OlMap from 'ol/Map';
import View from 'ol/View';
import { XYZ } from 'ol/source';
import TileLayer from 'ol/layer/Tile';
@ -19,18 +19,20 @@ import html2canvas from 'html2canvas';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS } from '../types/areaSearch.types';
import type { Zone, HitDetail } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
import { mapLayerConfig } from '../../map/layers/baseLayer';
import './VesselDetailModal.scss';
function getNationalFlagUrl(nationalCode) {
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
if (!nationalCode) return null;
return `/ship/image/small/${nationalCode}.svg`;
}
function createZoneFeatures(zones) {
const features = [];
function createZoneFeatures(zones: Zone[]): Feature[] {
const features: Feature[] = [];
zones.forEach((zone) => {
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
const polygon = new Polygon([coords3857]);
@ -60,7 +62,7 @@ function createZoneFeatures(zones) {
return features;
}
function createTrackFeature(track) {
function createTrackFeature(track: ProcessedTrack): Feature {
const coords3857 = track.geometry.map((c) => fromLonLat(c));
const line = new LineString(coords3857);
const feature = new Feature({ geometry: line });
@ -74,8 +76,8 @@ function createTrackFeature(track) {
return feature;
}
function createMarkerFeatures(sortedHits) {
const features = [];
function createMarkerFeatures(sortedHits: HitDetail[]): Feature[] {
const features: Feature[] = [];
sortedHits.forEach((hit, idx) => {
const seqNum = idx + 1;
@ -133,26 +135,32 @@ function createMarkerFeatures(sortedHits) {
}
/**
* () , offsetY만
* -- () , offsetY만
*
*/
function adjustOverlappingLabels(features, resolution) {
function adjustOverlappingLabels(features: Feature[], resolution: number | undefined): void {
if (!resolution || features.length < 2) return;
const PROXIMITY_PX = 40;
const proximityMap = resolution * PROXIMITY_PX;
const LINE_HEIGHT_PX = 16;
interface MarkerItem {
feature: Feature;
x: number;
y: number;
}
// 피처별 좌표 추출
const items = features.map((f) => {
const coord = f.getGeometry().getCoordinates();
const items: MarkerItem[] = features.map((f) => {
const coord = (f.getGeometry() as Point).getCoordinates();
return { feature: f, x: coord[0], y: coord[1] };
});
// 근접 그룹 찾기 (Union-Find 방식)
const parent = items.map((_, i) => i);
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
const union = (a, b) => { parent[find(a)] = find(b); };
const find = (i: number): number => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
const union = (a: number, b: number) => { parent[find(a)] = find(b); };
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
@ -164,8 +172,8 @@ function adjustOverlappingLabels(features, resolution) {
}
}
// 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용)
const groups = {};
// 그룹별 텍스트 offsetY 분산
const groups: Record<number, MarkerItem[]> = {};
items.forEach((item, i) => {
const root = find(i);
if (!groups[root]) groups[root] = [];
@ -174,10 +182,10 @@ function adjustOverlappingLabels(features, resolution) {
Object.values(groups).forEach((group) => {
if (group.length < 2) return;
// 시퀀스 번호 순 정렬 후 INOUT 순서
// 시퀀스 번호 순 정렬 후 IN->OUT 순서
group.sort((a, b) => {
const seqA = a.feature.get('_seqNum');
const seqB = b.feature.get('_seqNum');
const seqA = a.feature.get('_seqNum') as number;
const seqB = b.feature.get('_seqNum') as number;
if (seqA !== seqB) return seqA - seqB;
const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1;
const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1;
@ -188,7 +196,7 @@ function adjustOverlappingLabels(features, resolution) {
const startY = -totalHeight / 2 - 8;
group.forEach((item, idx) => {
const style = item.feature.getStyle();
const style = item.feature.getStyle() as Style;
const textStyle = style.getText();
if (textStyle) {
textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX);
@ -200,14 +208,19 @@ function adjustOverlappingLabels(features, resolution) {
const MODAL_WIDTH = 680;
const MODAL_APPROX_HEIGHT = 780;
export default function VesselDetailModal({ vesselId, onClose }) {
interface VesselDetailModalProps {
vesselId: string;
onClose: () => void;
}
export default function VesselDetailModal({ vesselId, onClose }: VesselDetailModalProps) {
const tracks = useAreaSearchStore((s) => s.tracks);
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
const zones = useAreaSearchStore((s) => s.zones);
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const contentRef = useRef(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<OlMap | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 드래그 위치 관리
const [position, setPosition] = useState(() => ({
@ -218,7 +231,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - posRef.current.x,
@ -228,7 +241,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
}, []);
useEffect(() => {
const handleMouseMove = (e) => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
@ -249,14 +262,14 @@ export default function VesselDetailModal({ vesselId, onClose }) {
}, []);
const track = useMemo(
() => tracks.find((t) => t.vesselId === vesselId),
() => tracks.find((t: ProcessedTrack) => t.vesselId === vesselId),
[tracks, vesselId],
);
const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
const hits: HitDetail[] = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
const zoneMap = useMemo(() => {
const lookup = {};
zones.forEach((z, idx) => {
const lookup: Record<string | number, Zone> = {};
zones.forEach((z: Zone, idx: number) => {
lookup[z.id] = z;
lookup[z.name] = z;
lookup[idx] = z;
@ -266,7 +279,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
}, [zones]);
const sortedHits = useMemo(
() => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp),
() => [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)),
[hits],
);
@ -275,7 +288,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
if (!mapContainerRef.current || !track) return;
const tileSource = new XYZ({
url: mapLayerConfig.darkLayer.source.getUrls()[0],
url: mapLayerConfig.darkLayer.source.getUrls()![0],
minZoom: 6,
maxZoom: 11,
});
@ -291,7 +304,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
const markerSource = new VectorSource({ features: markerFeatures });
const markerLayer = new VectorLayer({ source: markerSource });
const map = new Map({
const map = new OlMap({
target: mapContainerRef.current,
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
view: new View({ center: [0, 0], zoom: 7 }),
@ -315,7 +328,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
mapRef.current = map;
return () => {
map.setTarget(null);
map.setTarget(undefined);
map.dispose();
mapRef.current = null;
};
@ -325,7 +338,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
const el = contentRef.current;
if (!el) return;
const modal = el.parentElement;
const modal = el.parentElement as HTMLElement;
const saved = {
elOverflow: el.style.overflow,
modalMaxHeight: modal.style.maxHeight,
@ -347,7 +360,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
const now = new Date();
const name = track?.shipName || track?.targetId || 'vessel';
link.href = url;
@ -383,7 +396,7 @@ export default function VesselDetailModal({ vesselId, onClose }) {
<span className="vessel-detail-modal__kind">{kindName}</span>
{flagUrl && (
<span className="vessel-detail-modal__flag">
<img src={flagUrl} alt="국기" onError={(e) => { e.target.style.display = 'none'; }} />
<img src={flagUrl} alt="국기" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</span>
)}
<span className="vessel-detail-modal__name">

파일 보기

@ -6,8 +6,14 @@ import {
ZONE_DRAW_TYPES,
ZONE_COLORS,
} from '../types/areaSearch.types';
import type { ZoneDrawType, Zone } from '../types/areaSearch.types';
export default function ZoneDrawPanel({ disabled, maxZones }) {
interface ZoneDrawPanelProps {
disabled?: boolean;
maxZones?: number;
}
export default function ZoneDrawPanel({ disabled, maxZones }: ZoneDrawPanelProps) {
const effectiveMaxZones = maxZones ?? MAX_ZONES;
const zones = useAreaSearchStore((s) => s.zones);
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
@ -21,13 +27,13 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
const canAddZone = zones.length < effectiveMaxZones;
const handleDrawClick = useCallback((type) => {
const handleDrawClick = useCallback((type: ZoneDrawType) => {
if (!canAddZone || disabled) return;
if (!confirmAndClearResults()) return;
setActiveDrawType(activeDrawType === type ? null : type);
}, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]);
const handleZoneClick = useCallback((zoneId) => {
const handleZoneClick = useCallback((zoneId: string) => {
if (disabled) return;
if (selectedZoneId === zoneId) {
deselectZone();
@ -37,26 +43,26 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
}
}, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]);
const handleRemoveZone = useCallback((e, zoneId) => {
const handleRemoveZone = useCallback((e: React.MouseEvent, zoneId: string) => {
e.stopPropagation();
if (!confirmAndClearResults()) return;
removeZone(zoneId);
}, [removeZone, confirmAndClearResults]);
// 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적)
const dragIndexRef = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const dragIndexRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = useCallback((e, index) => {
const handleDragStart = useCallback((e: React.DragEvent<HTMLLIElement>, index: number) => {
dragIndexRef.current = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
requestAnimationFrame(() => {
e.target.classList.add('dragging');
(e.target as HTMLElement).classList.add('dragging');
});
}, []);
const handleDragOver = useCallback((e, index) => {
const handleDragOver = useCallback((e: React.DragEvent<HTMLLIElement>, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
@ -64,7 +70,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
}
}, []);
const handleDrop = useCallback((e, toIndex) => {
const handleDrop = useCallback((e: React.DragEvent<HTMLLIElement>, toIndex: number) => {
e.preventDefault();
const fromIndex = dragIndexRef.current;
if (fromIndex !== null && fromIndex !== toIndex) {
@ -127,7 +133,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) {
{/* 구역 목록 */}
{zones.length > 0 && (
<ul className="zone-list">
{zones.map((zone, index) => {
{zones.map((zone: Zone, index: number) => {
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
return (
<li

파일 보기

@ -3,7 +3,7 @@
*
* :
* - currentTime은 zustand.subscribe로 React
* - (PathLayer)
* - (PathLayer) --
* - (IconLayer, TextLayer, TripsLayer)
*/
import { useEffect, useRef, useCallback } from 'react';
@ -11,6 +11,8 @@ import { TripsLayer } from '@deck.gl/geo-layers';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { VesselPosition } from '../types/areaSearch.types';
import {
registerAreaSearchLayers,
unregisterAreaSearchLayers,
@ -21,12 +23,27 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
export default function useAreaSearchLayer() {
const tripsDataRef = useRef([]);
interface TripsDataItem {
vesselId: string;
shipKindCode: string;
path: number[][];
timestamps: number[];
}
interface StaticLayerCacheDeps {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
shipKindCodeFilter: Set<string>;
highlightedVesselId: string | null;
}
export default function useAreaSearchLayer(): void {
const tripsDataRef = useRef<TripsDataItem[]>([]);
const startTimeRef = useRef(0);
// 정적 레이어 캐시 (필터/하이라이트 변경 시에만 갱신)
const staticLayerCacheRef = useRef({ layers: [], deps: null });
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null });
// React 구독: 필터/상태 (비빈번 변경만)
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
@ -36,7 +53,7 @@ export default function useAreaSearchLayer() {
const showPaths = useAreaSearchStore((s) => s.showPaths);
const showTrail = useAreaSearchStore((s) => s.showTrail);
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
// currentTime React 구독 제거, zustand.subscribe로 대체
// currentTime -- React 구독 제거, zustand.subscribe로 대체
/**
* (zustand.subscribe에서 , React )
@ -48,14 +65,15 @@ export default function useAreaSearchLayer() {
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct);
const filteredPositions = allPositions.filter(
(p) => shipKindCodeFilter.has(p.shipKindCode),
(p: VesselPosition) => shipKindCodeFilter.has(p.shipKindCode),
);
const layers = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const layers: any[] = [];
// 1. TripsLayer 궤적 (동적 currentTime 의존)
// 1. TripsLayer 궤적 (동적 -- currentTime 의존)
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId));
const iconVesselIds = new Set(filteredPositions.map((p: VesselPosition) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
@ -65,8 +83,9 @@ export default function useAreaSearchLayer() {
new TripsLayer({
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
// @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry
getPath: (d: TripsDataItem) => d.path,
getTimestamps: (d: TripsDataItem) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
@ -80,7 +99,7 @@ export default function useAreaSearchLayer() {
}
}
// 2. 정적 PathLayer (캐싱 필터/하이라이트 변경 시에만 재생성)
// 2. 정적 PathLayer (캐싱 -- 필터/하이라이트 변경 시에만 재생성)
if (showPaths) {
const deps = staticLayerCacheRef.current.deps;
const needsRebuild = !deps
@ -90,7 +109,7 @@ export default function useAreaSearchLayer() {
|| deps.highlightedVesselId !== highlightedVesselId;
if (needsRebuild) {
const filteredTracks = tracks.filter((t) =>
const filteredTracks = tracks.filter((t: ProcessedTrack) =>
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
);
staticLayerCacheRef.current = {
@ -98,7 +117,7 @@ export default function useAreaSearchLayer() {
tracks: filteredTracks,
showPoints: false,
highlightedVesselId,
onPathHover: (vesselId) => {
onPathHover: (vesselId: string | null) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH },
@ -114,14 +133,14 @@ export default function useAreaSearchLayer() {
currentPositions: filteredPositions,
showVirtualShip: filteredPositions.length > 0,
showLabels: filteredPositions.length > 0,
onIconHover: (shipData, x, y) => {
onIconHover: (shipData, _x, _y) => {
if (shipData) {
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
} else {
useAreaSearchStore.getState().setHighlightedVesselId(null);
}
},
onPathHover: (vesselId) => {
onPathHover: (vesselId: string | null) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: {
@ -156,17 +175,17 @@ export default function useAreaSearchLayer() {
startTimeRef.current = sTime;
tripsDataRef.current = tracks
.filter((t) => t.geometry.length >= 2)
.map((track) => ({
.filter((t: ProcessedTrack) => t.geometry.length >= 2)
.map((track: ProcessedTrack) => ({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - sTime),
timestamps: track.timestampsMs.map((t: number) => t - sTime),
}));
}, [queryCompleted, tracks]);
/**
* currentTime (zustand.subscribe React )
* currentTime (zustand.subscribe -- React )
* : ~10fps (RENDER_INTERVAL_MS)
* seek/정지: 즉시 ( )
*/
@ -176,7 +195,7 @@ export default function useAreaSearchLayer() {
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
let pendingRafId: number | null = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,

파일 보기

@ -15,6 +15,9 @@ import { useStsStore } from '../stores/stsStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { STS_LAYER_IDS } from '../types/sts.types';
import { getContactRiskColor } from '../types/sts.types';
import type { StsGroupedContact, StsIndicators } from '../types/sts.types';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { VesselPosition } from '../types/areaSearch.types';
import {
registerStsLayers,
unregisterStsLayers,
@ -25,11 +28,39 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100;
export default function useStsLayer() {
const tripsDataRef = useRef([]);
interface TripsDataItem {
vesselId: string;
shipKindCode: string;
path: number[][];
timestamps: number[];
}
interface EnabledContact {
contactCenterPoint?: number[];
indicators?: StsIndicators;
_groupIdx: number;
[key: string]: unknown;
}
interface ContactLayerCacheDeps {
groupedContacts: StsGroupedContact[];
disabledGroupIndices: Set<number>;
highlightedGroupIndex: number | null;
}
interface StaticLayerCacheDeps {
tracks: ProcessedTrack[];
disabledGroupIndices: Set<number>;
highlightedGroupIndex: number | null;
}
export default function useStsLayer(): void {
const tripsDataRef = useRef<TripsDataItem[]>([]);
const startTimeRef = useRef(0);
const staticLayerCacheRef = useRef({ layers: [], deps: null });
const contactLayerCacheRef = useRef({ layers: [], deps: null });
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null });
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const contactLayerCacheRef = useRef<{ layers: any[]; deps: ContactLayerCacheDeps | null }>({ layers: [], deps: null });
// React 구독: 그룹 기반
const queryCompleted = useStsStore((s) => s.queryCompleted);
@ -52,11 +83,12 @@ export default function useStsLayer() {
if (!needsRebuild) return contactLayerCacheRef.current.layers;
const layers = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const layers: any[] = [];
// disabled가 아닌 그룹의 모든 하위 contacts를 flat
const enabledContacts = [];
groupedContacts.forEach((group, gIdx) => {
const enabledContacts: EnabledContact[] = [];
groupedContacts.forEach((group: StsGroupedContact, gIdx: number) => {
if (disabledGroupIndices.has(gIdx)) return;
group.contacts.forEach((c) => {
enabledContacts.push({
@ -79,9 +111,10 @@ export default function useStsLayer() {
new ScatterplotLayer({
id: STS_LAYER_IDS.CONTACT_POINT,
data: enabledContacts.filter((c) => c.contactCenterPoint),
getPosition: (d) => d.contactCenterPoint,
getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
getFillColor: (d) => getContactRiskColor(d.indicators),
// @ts-expect-error Deck.gl runtime accepts number[] for Position
getPosition: (d: EnabledContact) => d.contactCenterPoint as number[],
getRadius: (d: EnabledContact) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
getFillColor: (d: EnabledContact) => getContactRiskColor(d.indicators ?? null),
radiusMinPixels: 4,
radiusMaxPixels: 12,
pickable: true,
@ -107,11 +140,12 @@ export default function useStsLayer() {
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useStsStore.getState().getCurrentPositions(ct);
const layers = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용
const layers: any[] = [];
// 1. TripsLayer 궤적
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(allPositions.map((p) => p.vesselId));
const iconVesselIds = new Set(allPositions.map((p: VesselPosition) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
@ -121,8 +155,9 @@ export default function useStsLayer() {
new TripsLayer({
id: STS_LAYER_IDS.TRIPS_TRAIL,
data: filteredTripsData,
getPath: (d) => d.path,
getTimestamps: (d) => d.timestamps,
// @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry
getPath: (d: TripsDataItem) => d.path,
getTimestamps: (d: TripsDataItem) => d.timestamps,
getColor: [120, 120, 120, 180],
widthMinPixels: 2,
widthMaxPixels: 3,
@ -141,7 +176,7 @@ export default function useStsLayer() {
const disabledVesselIds = useStsStore.getState().getDisabledVesselIds();
// 접촉 쌍의 양쪽 선박 항적 하이라이트
let stsHighlightedVesselIds = null;
let stsHighlightedVesselIds: Set<string> | null = null;
if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) {
const g = groupedContacts[highlightedGroupIndex];
stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]);
@ -154,21 +189,21 @@ export default function useStsLayer() {
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
if (needsRebuild) {
const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId));
const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId));
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselIds: stsHighlightedVesselIds,
layerIds: { path: STS_LAYER_IDS.TRACK_PATH },
onPathHover: (vesselId) => {
onPathHover: (vesselId: string | null) => {
if (!vesselId) {
useStsStore.getState().setHighlightedGroupIndex(null);
return;
}
const groups = useStsStore.getState().groupedContacts;
const idx = groups.findIndex(
(g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId,
(g: StsGroupedContact) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId,
);
useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null);
},
@ -220,12 +255,12 @@ export default function useStsLayer() {
startTimeRef.current = sTime;
tripsDataRef.current = tracks
.filter((t) => t.geometry.length >= 2)
.map((track) => ({
.filter((t: ProcessedTrack) => t.geometry.length >= 2)
.map((track: ProcessedTrack) => ({
vesselId: track.vesselId,
shipKindCode: track.shipKindCode,
path: track.geometry,
timestamps: track.timestampsMs.map((t) => t - sTime),
timestamps: track.timestampsMs.map((t: number) => t - sTime),
}));
}, [queryCompleted, tracks]);
@ -238,7 +273,7 @@ export default function useStsLayer() {
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
let pendingRafId: number | null = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,

파일 보기

@ -15,15 +15,21 @@ import { createBox } from 'ol/interaction/Draw';
import { Style, Fill, Stroke } from 'ol/style';
import { transform } from 'ol/proj';
import { fromCircle } from 'ol/geom/Polygon';
import type OlMap from 'ol/Map';
import type { Coordinate } from 'ol/coordinate';
import type Feature from 'ol/Feature';
import type { Geometry, Circle as OlCircleGeom, Polygon } from 'ol/geom';
import type { DrawEvent } from 'ol/interaction/Draw';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import type { ZoneDrawType, Zone, CircleMeta } from '../types/areaSearch.types';
import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs';
/**
* 3857 4326
*/
function toWgs84Polygon(coords3857) {
function toWgs84Polygon(coords3857: Coordinate[]): number[][] {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
// 폐곡선 보장 (첫점 == 끝점)
if (coords4326.length > 0) {
@ -39,7 +45,7 @@ function toWgs84Polygon(coords3857) {
/**
* OL
*/
function createZoneStyle(index) {
function createZoneStyle(index: number): Style {
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
@ -47,10 +53,10 @@ function createZoneStyle(index) {
});
}
export default function useZoneDraw() {
export default function useZoneDraw(): void {
const map = useMapStore((s) => s.map);
const drawRef = useRef(null);
const mapRef = useRef(null);
const drawRef = useRef<Draw | null>(null);
const mapRef = useRef<OlMap | null>(null);
// map ref 동기화 (클린업에서 사용)
useEffect(() => {
@ -72,10 +78,10 @@ export default function useZoneDraw() {
// 기존 zones가 있으면 동기화
const { zones } = useAreaSearchStore.getState();
zones.forEach((zone) => {
zones.forEach((zone: Zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
source.addFeature(zone.olFeature as Feature<Geometry>);
});
return () => {
@ -93,15 +99,15 @@ export default function useZoneDraw() {
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
(zones: Zone[]) => {
const source = getZoneSource();
if (!source) return;
source.clear();
zones.forEach((zone) => {
zones.forEach((zone: Zone) => {
if (!zone.olFeature) return;
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
source.addFeature(zone.olFeature);
source.addFeature(zone.olFeature as Feature<Geometry>);
});
},
);
@ -112,7 +118,7 @@ export default function useZoneDraw() {
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.showZones,
(show) => {
(show: boolean) => {
const layer = getZoneLayer();
if (layer) layer.setVisible(show);
},
@ -121,7 +127,7 @@ export default function useZoneDraw() {
}, []);
// Draw 인터랙션 생성 함수
const setupDraw = useCallback((currentMap, drawType) => {
const setupDraw = useCallback((currentMap: OlMap, drawType: ZoneDrawType | null) => {
// 기존 인터랙션 제거
if (drawRef.current) {
currentMap.removeInteraction(drawRef.current);
@ -137,7 +143,7 @@ export default function useZoneDraw() {
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
let draw;
let draw: Draw;
if (drawType === ZONE_DRAW_TYPES.BOX) {
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
@ -146,22 +152,23 @@ export default function useZoneDraw() {
draw = new Draw({ type: 'Polygon' });
}
draw.on('drawend', (evt) => {
draw.on('drawend', (evt: DrawEvent) => {
const feature = evt.feature;
let geom = feature.getGeometry();
let geom = feature.getGeometry()!;
const typeName = drawType;
// Circle → Polygon 변환 (center/radius 보존)
let circleMeta = null;
let circleMeta: CircleMeta | null = null;
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
circleMeta = { center: geom.getCenter(), radius: geom.getRadius() };
const polyGeom = fromCircle(geom, 64);
const circleGeom = geom as OlCircleGeom;
circleMeta = { center: circleGeom.getCenter() as [number, number], radius: circleGeom.getRadius() };
const polyGeom = fromCircle(circleGeom, 64);
feature.setGeometry(polyGeom);
geom = polyGeom;
}
// EPSG:3857 → 4326 좌표 추출
const coords3857 = geom.getCoordinates()[0];
const coords3857 = (geom as Polygon).getCoordinates()[0];
const coordinates = toWgs84Polygon(coords3857);
// 최소 4점 확인
@ -187,7 +194,7 @@ export default function useZoneDraw() {
type: typeName,
source: 'draw',
coordinates,
olFeature: feature,
olFeature: feature as Feature<Geometry>,
circleMeta,
});
// addZone → activeDrawType: null → subscription → removeInteraction
@ -204,7 +211,7 @@ export default function useZoneDraw() {
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
(drawType: ZoneDrawType | null) => {
setupDraw(map, drawType);
},
);
@ -227,7 +234,7 @@ export default function useZoneDraw() {
// ESC 키로 그리기 취소
useEffect(() => {
const handleKeyDown = (e) => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
const { activeDrawType } = useAreaSearchStore.getState();
if (activeDrawType) {
@ -243,15 +250,15 @@ export default function useZoneDraw() {
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones, prevZones) => {
(zones: Zone[], prevZones: Zone[]) => {
if (!prevZones || zones.length >= prevZones.length) return;
const source = getZoneSource();
if (!source) return;
const currentIds = new Set(zones.map((z) => z.id));
prevZones.forEach((z) => {
const currentIds = new Set(zones.map((z: Zone) => z.id));
prevZones.forEach((z: Zone) => {
if (!currentIds.has(z.id) && z.olFeature) {
try { source.removeFeature(z.olFeature); } catch { /* already removed */ }
try { source.removeFeature(z.olFeature as Feature<Geometry>); } catch { /* already removed */ }
}
});
},

파일 보기

@ -14,15 +14,21 @@ import { Modify, Translate } from 'ol/interaction';
import Collection from 'ol/Collection';
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
import { transform } from 'ol/proj';
import type OlMap from 'ol/Map';
import type Feature from 'ol/Feature';
import type { Geometry, Polygon } from 'ol/geom';
import type { Coordinate } from 'ol/coordinate';
import type MapBrowserEvent from 'ol/MapBrowserEvent';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import type { Zone, CircleMeta } from '../types/areaSearch.types';
import { getZoneSource } from '../utils/zoneLayerRefs';
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
/** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */
function toWgs84Polygon(coords3857) {
function toWgs84Polygon(coords3857: Coordinate[]): number[][] {
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
if (coords4326.length > 0) {
const first = coords4326[0];
@ -35,7 +41,7 @@ function toWgs84Polygon(coords3857) {
}
/** 선택된 구역의 하이라이트 스타일 */
function createSelectedStyle(colorIndex) {
function createSelectedStyle(colorIndex: number): Style {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }),
@ -57,7 +63,7 @@ const MODIFY_STYLE = new Style({
});
/** 기본 구역 스타일 복원 */
function createNormalStyle(colorIndex) {
function createNormalStyle(colorIndex: number): Style {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
@ -66,7 +72,7 @@ function createNormalStyle(colorIndex) {
}
/** 호버 스타일 (스트로크 강조) */
function createHoverStyle(colorIndex) {
function createHoverStyle(colorIndex: number): Style {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
@ -78,7 +84,7 @@ function createHoverStyle(colorIndex) {
}
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
function pointToSegmentDist(p: number[], a: number[], b: number[]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
@ -91,8 +97,8 @@ function pointToSegmentDist(p, a, b) {
const HANDLE_TOLERANCE = 12;
/** Polygon 꼭짓점/변 근접 검사 */
function isNearPolygonHandle(map, pixel, feature) {
const coords = feature.getGeometry().getCoordinates()[0];
function isNearPolygonHandle(map: OlMap, pixel: number[], feature: Feature<Geometry>): boolean {
const coords = (feature.getGeometry() as Polygon).getCoordinates()[0];
const n = coords.length - 1;
for (let i = 0; i < n; i++) {
const vp = map.getPixelFromCoordinate(coords[i]);
@ -103,7 +109,7 @@ function isNearPolygonHandle(map, pixel, feature) {
for (let i = 0; i < n; i++) {
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]);
if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) {
if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < HANDLE_TOLERANCE) {
return true;
}
}
@ -111,12 +117,12 @@ function isNearPolygonHandle(map, pixel, feature) {
}
/** Feature에서 좌표를 추출하여 store에 동기화 */
function syncZoneToStore(zoneId, feature, zone) {
const geom = feature.getGeometry();
function syncZoneToStore(zoneId: string, feature: Feature<Geometry>, zone: Zone): void {
const geom = feature.getGeometry() as Polygon;
const coords3857 = geom.getCoordinates()[0];
const coords4326 = toWgs84Polygon(coords3857);
let circleMeta;
let circleMeta: CircleMeta | undefined;
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
const center = computeCentroid(coords3857);
@ -129,7 +135,7 @@ function syncZoneToStore(zoneId, feature, zone) {
}
/** 다각형 중심점 계산 */
function computeCentroid(coords) {
function computeCentroid(coords: Coordinate[]): [number, number] {
let sumX = 0, sumY = 0;
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
for (let i = 0; i < n; i++) {
@ -139,17 +145,17 @@ function computeCentroid(coords) {
return [sumX / n, sumY / n];
}
export default function useZoneEdit() {
export default function useZoneEdit(): void {
const map = useMapStore((s) => s.map);
const mapRef = useRef(null);
const modifyRef = useRef(null);
const translateRef = useRef(null);
const customResizeRef = useRef(null);
const selectedCollectionRef = useRef(new Collection());
const clickListenerRef = useRef(null);
const contextMenuRef = useRef(null);
const keydownRef = useRef(null);
const hoveredZoneIdRef = useRef(null);
const mapRef = useRef<OlMap | null>(null);
const modifyRef = useRef<Modify | null>(null);
const translateRef = useRef<Translate | null>(null);
const customResizeRef = useRef<BoxResizeInteraction | CircleResizeInteraction | null>(null);
const selectedCollectionRef = useRef(new Collection<Feature<Geometry>>());
const clickListenerRef = useRef<((evt: MapBrowserEvent<PointerEvent>) => void) | null>(null);
const contextMenuRef = useRef<((e: MouseEvent) => void) | null>(null);
const keydownRef = useRef<((e: KeyboardEvent) => void) | null>(null);
const hoveredZoneIdRef = useRef<string | null>(null);
useEffect(() => { mapRef.current = map; }, [map]);
@ -164,13 +170,13 @@ export default function useZoneEdit() {
}, []);
/** 선택된 구역에 대해 인터랙션 설정 */
const setupInteractions = useCallback((currentMap, zone) => {
const setupInteractions = useCallback((currentMap: OlMap, zone: Zone) => {
removeInteractions();
if (!zone || !zone.olFeature) return;
const feature = zone.olFeature;
const feature = zone.olFeature as Feature<Polygon>;
const collection = selectedCollectionRef.current;
collection.push(feature);
collection.push(feature as Feature<Geometry>);
// 선택 스타일 적용
feature.setStyle(createSelectedStyle(zone.colorIndex));
@ -180,11 +186,11 @@ export default function useZoneEdit() {
translate.on('translateend', () => {
// Circle의 경우 center 업데이트
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) {
const coords = feature.getGeometry().getCoordinates()[0];
const coords = feature.getGeometry()!.getCoordinates()[0];
const newCenter = computeCentroid(coords);
customResizeRef.current.setCenter(newCenter);
(customResizeRef.current as CircleResizeInteraction).setCenter(newCenter);
}
syncZoneToStore(zone.id, feature, zone);
syncZoneToStore(zone.id, feature as Feature<Geometry>, zone);
});
currentMap.addInteraction(translate);
translateRef.current = translate;
@ -197,7 +203,7 @@ export default function useZoneEdit() {
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
});
modify.on('modifyend', () => {
syncZoneToStore(zone.id, feature, zone);
syncZoneToStore(zone.id, feature as Feature<Geometry>, zone);
});
currentMap.addInteraction(modify);
modifyRef.current = modify;
@ -205,19 +211,19 @@ export default function useZoneEdit() {
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
const boxResize = new BoxResizeInteraction({
feature,
onResize: () => syncZoneToStore(zone.id, feature, zone),
onResize: () => syncZoneToStore(zone.id, feature as Feature<Geometry>, zone),
});
currentMap.addInteraction(boxResize);
customResizeRef.current = boxResize;
} else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) {
const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]);
const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry()!.getCoordinates()[0]);
const circleResize = new CircleResizeInteraction({
feature,
center,
onResize: (f) => {
onResize: (f: Feature<Polygon>) => {
// 리사이즈 후 circleMeta 업데이트
const coords = f.getGeometry().getCoordinates()[0];
const coords = f.getGeometry()!.getCoordinates()[0];
const newCenter = computeCentroid(coords);
const dx = coords[0][0] - newCenter[0];
const dy = coords[0][1] - newCenter[1];
@ -232,9 +238,9 @@ export default function useZoneEdit() {
}, [removeInteractions]);
/** 구역 선택 해제 시 스타일 복원 */
const restoreStyle = useCallback((zoneId) => {
const restoreStyle = useCallback((zoneId: string) => {
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
const zone = zones.find((z: Zone) => z.id === zoneId);
if (zone && zone.olFeature) {
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
}
@ -244,11 +250,11 @@ export default function useZoneEdit() {
useEffect(() => {
if (!map) return;
let prevSelectedId = null;
let prevSelectedId: string | null = null;
const unsub = useAreaSearchStore.subscribe(
(s) => s.selectedZoneId,
(zoneId) => {
(zoneId: string | null) => {
// 이전 선택 스타일 복원
if (prevSelectedId) restoreStyle(prevSelectedId);
prevSelectedId = zoneId;
@ -259,7 +265,7 @@ export default function useZoneEdit() {
}
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
const zone = zones.find((z: Zone) => z.id === zoneId);
if (zone) {
setupInteractions(map, zone);
}
@ -290,7 +296,7 @@ export default function useZoneEdit() {
useEffect(() => {
if (!map) return;
const handleClick = (evt) => {
const handleClick = (evt: MapBrowserEvent<PointerEvent>) => {
// Drawing 중이면 무시
if (useAreaSearchStore.getState().activeDrawType) return;
@ -301,24 +307,24 @@ export default function useZoneEdit() {
if (!source) return;
// 클릭 지점의 feature 탐색
let clickedZone = null;
let clickedZone: Zone | undefined;
const { zones } = useAreaSearchStore.getState();
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (clickedZone) return; // 이미 찾았으면 무시
const zone = zones.find(z => z.olFeature === feature);
const zone = zones.find((z: Zone) => z.olFeature === feature);
if (zone) clickedZone = zone;
}, { layerFilter: (layer) => layer.getSource() === source });
const { selectedZoneId } = useAreaSearchStore.getState();
if (clickedZone) {
if (clickedZone.id === selectedZoneId) return; // 이미 선택됨
if ((clickedZone as Zone).id === selectedZoneId) return; // 이미 선택됨
// 결과 표시 중이면 confirmAndClearResults
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
useAreaSearchStore.getState().selectZone(clickedZone.id);
useAreaSearchStore.getState().selectZone((clickedZone as Zone).id);
} else {
// 빈 영역 클릭 → 선택 해제
if (selectedZoneId) {
@ -340,17 +346,17 @@ export default function useZoneEdit() {
useEffect(() => {
if (!map) return;
const handleContextMenu = (e) => {
const handleContextMenu = (e: MouseEvent) => {
const { selectedZoneId, zones } = useAreaSearchStore.getState();
if (!selectedZoneId) return;
const zone = zones.find(z => z.id === selectedZoneId);
const zone = zones.find((z: Zone) => z.id === selectedZoneId);
if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return;
const feature = zone.olFeature;
if (!feature) return;
const geom = feature.getGeometry();
const geom = feature.getGeometry() as Polygon;
const coords = geom.getCoordinates()[0];
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
if (vertexCount <= 3) return; // 최소 삼각형 유지
@ -380,7 +386,7 @@ export default function useZoneEdit() {
newCoords[newCoords.length - 1] = [...newCoords[0]];
}
geom.setCoordinates([newCoords]);
syncZoneToStore(zone.id, feature, zone);
syncZoneToStore(zone.id, feature as Feature<Geometry>, zone);
};
const viewport = map.getViewport();
@ -395,7 +401,7 @@ export default function useZoneEdit() {
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
useEffect(() => {
const handleKeyDown = (e) => {
const handleKeyDown = (e: KeyboardEvent) => {
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
@ -423,7 +429,7 @@ export default function useZoneEdit() {
const viewport = map.getViewport();
const handlePointerMove = (evt) => {
const handlePointerMove = (evt: MapBrowserEvent<PointerEvent>) => {
if (evt.dragging) return;
// Drawing 중이면 호버 해제
@ -443,11 +449,11 @@ export default function useZoneEdit() {
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
if (selectedZoneId) {
const zone = zones.find(z => z.id === selectedZoneId);
const zone = zones.find((z: Zone) => z.id === selectedZoneId);
if (zone && zone.olFeature) {
// Box/Circle: isOverHandle
if (customResizeRef.current && customResizeRef.current.isOverHandle) {
const handle = customResizeRef.current.isOverHandle(map, evt.pixel);
const handle = customResizeRef.current.isOverHandle(map, evt.pixel as unknown as number[]);
if (handle) {
viewport.style.cursor = handle.cursor;
return;
@ -456,7 +462,7 @@ export default function useZoneEdit() {
// Polygon: 꼭짓점/변 근접
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) {
if (isNearPolygonHandle(map, evt.pixel as unknown as number[], zone.olFeature as Feature<Geometry>)) {
viewport.style.cursor = 'crosshair';
return;
}
@ -476,19 +482,20 @@ export default function useZoneEdit() {
}
// 2. 비선택 구역 호버
let hoveredZone = null;
let hoveredZone: Zone | undefined;
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (hoveredZone) return;
const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId);
const zone = zones.find((z: Zone) => z.olFeature === feature && z.id !== selectedZoneId);
if (zone) hoveredZone = zone;
}, { layerFilter: (l) => l.getSource() === source });
if (hoveredZone) {
const hz = hoveredZone as Zone;
viewport.style.cursor = 'pointer';
if (hoveredZoneIdRef.current !== hoveredZone.id) {
if (hoveredZoneIdRef.current !== hz.id) {
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = hoveredZone.id;
hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex));
hoveredZoneIdRef.current = hz.id;
hz.olFeature!.setStyle(createHoverStyle(hz.colorIndex));
}
} else {
viewport.style.cursor = '';

파일 보기

@ -6,12 +6,17 @@
* - 드래그: 반대쪽 , 1
*/
import PointerInteraction from 'ol/interaction/Pointer';
import type Feature from 'ol/Feature';
import type { Polygon } from 'ol/geom';
import type MapBrowserEvent from 'ol/MapBrowserEvent';
import type OlMap from 'ol/Map';
import type { Coordinate } from 'ol/coordinate';
const CORNER_TOLERANCE = 16;
const EDGE_TOLERANCE = 12;
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
function pointToSegmentDist(p: number[], a: number[], b: number[]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
@ -21,34 +26,57 @@ function pointToSegmentDist(p, a, b) {
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
interface BoxResizeInteractionOptions {
feature: Feature<Polygon>;
onResize?: (feature: Feature<Polygon>) => void;
}
interface HandleResult {
cursor: string;
}
interface BBox {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
export default class BoxResizeInteraction extends PointerInteraction {
constructor(options) {
private feature_: Feature<Polygon>;
private onResize_: ((feature: Feature<Polygon>) => void) | null;
// corner mode
private mode_: 'corner' | 'edge' | null;
private anchorCoord_: Coordinate | null;
// edge mode
private edgeIndex_: number | null;
private bbox_: BBox | null;
constructor(options: BoxResizeInteractionOptions) {
super({
handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt),
handleDownEvent: (evt: MapBrowserEvent<PointerEvent>) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: () => BoxResizeInteraction.prototype._handleUp.call(this),
});
this.feature_ = options.feature;
this.onResize_ = options.onResize || null;
// corner mode
this.mode_ = null; // 'corner' | 'edge'
this.draggedIndex_ = null;
this.anchorCoord_ = null;
// edge mode
this.edgeIndex_ = null;
this.bbox_ = null;
}
_handleDown(evt) {
const pixel = evt.pixel;
const coords = this.feature_.getGeometry().getCoordinates()[0];
private _handleDown(evt: MapBrowserEvent<PointerEvent>): boolean {
const pixel = evt.pixel as unknown as number[];
const coords = this.feature_.getGeometry()!.getCoordinates()[0];
// 1. 모서리 감지 (우선)
for (let i = 0; i < 4; i++) {
const vp = evt.map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
this.mode_ = 'corner';
this.draggedIndex_ = i;
this.anchorCoord_ = coords[(i + 2) % 4];
return true;
}
@ -59,11 +87,11 @@ export default class BoxResizeInteraction extends PointerInteraction {
const j = (i + 1) % 4;
const p1 = evt.map.getPixelFromCoordinate(coords[i]);
const p2 = evt.map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) {
this.mode_ = 'edge';
this.edgeIndex_ = i;
const xs = coords.slice(0, 4).map(c => c[0]);
const ys = coords.slice(0, 4).map(c => c[1]);
const xs = coords.slice(0, 4).map((c: Coordinate) => c[0]);
const ys = coords.slice(0, 4).map((c: Coordinate) => c[1]);
this.bbox_ = {
minX: Math.min(...xs), maxX: Math.max(...xs),
minY: Math.min(...ys), maxY: Math.max(...ys),
@ -75,21 +103,21 @@ export default class BoxResizeInteraction extends PointerInteraction {
return false;
}
_handleDrag(evt) {
private _handleDrag(evt: MapBrowserEvent<PointerEvent>): void {
const coord = evt.coordinate;
if (this.mode_ === 'corner') {
const anchor = this.anchorCoord_;
const anchor = this.anchorCoord_!;
const minX = Math.min(coord[0], anchor[0]);
const maxX = Math.max(coord[0], anchor[0]);
const minY = Math.min(coord[1], anchor[1]);
const maxY = Math.max(coord[1], anchor[1]);
this.feature_.getGeometry().setCoordinates([[
this.feature_.getGeometry()!.setCoordinates([[
[minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY],
]]);
} else if (this.mode_ === 'edge') {
let { minX, maxX, minY, maxY } = this.bbox_;
// Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL)
let { minX, maxX, minY, maxY } = this.bbox_!;
// Edge 0: top(TL->TR), 1: right(TR->BR), 2: bottom(BR->BL), 3: left(BL->TL)
switch (this.edgeIndex_) {
case 0: maxY = coord[1]; break;
case 1: maxX = coord[0]; break;
@ -98,16 +126,15 @@ export default class BoxResizeInteraction extends PointerInteraction {
}
const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX);
const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY);
this.feature_.getGeometry().setCoordinates([[
this.feature_.getGeometry()!.setCoordinates([[
[x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2],
]]);
}
}
_handleUp() {
private _handleUp(): boolean {
if (this.mode_) {
this.mode_ = null;
this.draggedIndex_ = null;
this.anchorCoord_ = null;
this.edgeIndex_ = null;
this.bbox_ = null;
@ -119,10 +146,9 @@ export default class BoxResizeInteraction extends PointerInteraction {
/**
* 감지: 픽셀이
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
const coords = this.feature_.getGeometry().getCoordinates()[0];
isOverHandle(map: OlMap, pixel: number[]): HandleResult | null {
const coords = this.feature_.getGeometry()!.getCoordinates()[0];
// 모서리 감지
const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize'];
@ -139,7 +165,7 @@ export default class BoxResizeInteraction extends PointerInteraction {
const j = (i + 1) % 4;
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) {
return { cursor: edgeCursors[i] };
}
}

파일 보기

@ -10,16 +10,35 @@
import PointerInteraction from 'ol/interaction/Pointer';
import { fromCircle } from 'ol/geom/Polygon';
import OlCircle from 'ol/geom/Circle';
import type Feature from 'ol/Feature';
import type { Polygon } from 'ol/geom';
import type MapBrowserEvent from 'ol/MapBrowserEvent';
import type OlMap from 'ol/Map';
const PIXEL_TOLERANCE = 16;
const MIN_RADIUS = 100; // 최소 반지름 (미터)
interface CircleResizeInteractionOptions {
feature: Feature<Polygon>;
center: [number, number]; // EPSG:3857 [x, y]
onResize?: (feature: Feature<Polygon>) => void;
}
interface HandleResult {
cursor: string;
}
export default class CircleResizeInteraction extends PointerInteraction {
constructor(options) {
private feature_: Feature<Polygon>;
private center_: [number, number];
private onResize_: ((feature: Feature<Polygon>) => void) | null;
private dragging_: boolean;
constructor(options: CircleResizeInteractionOptions) {
super({
handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt),
handleDownEvent: (evt: MapBrowserEvent<PointerEvent>) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt: MapBrowserEvent<PointerEvent>) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: () => CircleResizeInteraction.prototype._handleUp.call(this),
});
this.feature_ = options.feature;
this.center_ = options.center; // EPSG:3857 [x, y]
@ -28,9 +47,9 @@ export default class CircleResizeInteraction extends PointerInteraction {
}
/** 중심~포인터 픽셀 거리와 표시 반지름 비교 */
_isNearEdge(map, pixel) {
private _isNearEdge(map: OlMap, pixel: number[]): boolean {
const centerPixel = map.getPixelFromCoordinate(this.center_);
const coords = this.feature_.getGeometry().getCoordinates()[0];
const coords = this.feature_.getGeometry()!.getCoordinates()[0];
const edgePixel = map.getPixelFromCoordinate(coords[0]);
const radiusPixels = Math.hypot(
edgePixel[0] - centerPixel[0],
@ -43,15 +62,15 @@ export default class CircleResizeInteraction extends PointerInteraction {
return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE;
}
_handleDown(evt) {
if (this._isNearEdge(evt.map, evt.pixel)) {
private _handleDown(evt: MapBrowserEvent<PointerEvent>): boolean {
if (this._isNearEdge(evt.map, evt.pixel as unknown as number[])) {
this.dragging_ = true;
return true;
}
return false;
}
_handleDrag(evt) {
private _handleDrag(evt: MapBrowserEvent<PointerEvent>): void {
if (!this.dragging_) return;
const coord = evt.coordinate;
const dx = coord[0] - this.center_[0];
@ -63,7 +82,7 @@ export default class CircleResizeInteraction extends PointerInteraction {
this.feature_.setGeometry(polyGeom);
}
_handleUp() {
private _handleUp(): boolean {
if (this.dragging_) {
this.dragging_ = false;
if (this.onResize_) this.onResize_(this.feature_);
@ -73,15 +92,14 @@ export default class CircleResizeInteraction extends PointerInteraction {
}
/** 외부에서 center 업데이트 (Translate 후) */
setCenter(center) {
setCenter(center: [number, number]): void {
this.center_ = center;
}
/**
* 감지: 픽셀이 ()
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
isOverHandle(map: OlMap, pixel: number[]): HandleResult | null {
if (this._isNearEdge(map, pixel)) {
return { cursor: 'nesw-resize' };
}

파일 보기

@ -6,14 +6,35 @@
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
import { fetchWithAuth } from '../../api/fetchWithAuth';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { HitDetail } from '../types/areaSearch.types';
const API_ENDPOINT = '/api/v2/tracks/area-search';
interface AreaSearchPolygon {
id: string;
name: string;
coordinates: number[][];
}
interface AreaSearchParams {
startTime: string;
endTime: string;
mode: string;
polygons: AreaSearchPolygon[];
}
interface AreaSearchResult {
tracks: ProcessedTrack[];
hitDetails: Record<string, HitDetail[]>;
summary: { totalVessels: number; processingTimeMs?: number } | null;
}
/**
* ( )
* track의 timestampsMs/geometry에서 targetTime [lon, lat]
*/
function interpolatePositionAtTime(track, targetTime) {
function interpolatePositionAtTime(track: ProcessedTrack, targetTime: number | null): number[] | null {
const { timestampsMs, geometry } = track;
if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null;
@ -47,15 +68,8 @@ function interpolatePositionAtTime(track, targetTime) {
/**
*
*
* @param {Object} params
* @param {string} params.startTime ISO 8601
* @param {string} params.endTime ISO 8601
* @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL'
* @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons
* @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>}
*/
export async function fetchAreaSearch(params) {
export async function fetchAreaSearch(params: AreaSearchParams): Promise<AreaSearchResult> {
const request = {
startTime: params.startTime,
endTime: params.endTime,
@ -76,20 +90,21 @@ export async function fetchAreaSearch(params) {
const result = await response.json();
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[];
// vesselId → track 빠른 조회용
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
const rawHitDetails = result.hitDetails || {};
const hitDetails = {};
const hitDetails: Record<string, HitDetail[]> = {};
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
const track = trackMap.get(vesselId);
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => {
const toMs = (ts) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit: any) => {
const toMs = (ts: string | number | null | undefined): number | null => {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10);
return num < 10000000000 ? num * 1000 : num;
};
const entryMs = toMs(hit.entryTimestamp);

파일 보기

@ -6,30 +6,45 @@
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
import { fetchWithAuth } from '../../api/fetchWithAuth';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { StsContact } from '../types/sts.types';
import type { StsSummary } from '../stores/stsStore';
const API_ENDPOINT = '/api/v2/tracks/vessel-contacts';
interface StsSearchPolygon {
id: string;
name: string;
coordinates: number[][];
}
interface StsSearchParams {
startTime: string;
endTime: string;
polygon: StsSearchPolygon;
minContactDurationMinutes: number;
maxContactDistanceMeters: number;
}
interface StsSearchResult {
contacts: StsContact[];
tracks: ProcessedTrack[];
summary: StsSummary | null;
}
/**
* Unix /
*/
function toMs(ts) {
function toMs(ts: string | number | null | undefined): number | null {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10);
return num < 10000000000 ? num * 1000 : num;
}
/**
* STS API
*
* @param {Object} params
* @param {string} params.startTime ISO 8601
* @param {string} params.endTime ISO 8601
* @param {{id: string, name: string, coordinates: number[][]}} params.polygon
* @param {number} params.minContactDurationMinutes 30~360
* @param {number} params.maxContactDistanceMeters 50~5000
* @returns {Promise<{contacts: Array, tracks: Array, summary: Object}>}
*/
export async function fetchVesselContacts(params) {
export async function fetchVesselContacts(params: StsSearchParams): Promise<StsSearchResult> {
const request = {
startTime: params.startTime,
endTime: params.endTime,
@ -52,11 +67,13 @@ export async function fetchVesselContacts(params) {
// tracks 변환
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[];
// contacts: timestamp 초→밀리초 변환
const rawContacts = Array.isArray(result.contacts) ? result.contacts : [];
const contacts = rawContacts.map((c) => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawContacts = Array.isArray(result.contacts) ? result.contacts : [] as any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const contacts: StsContact[] = rawContacts.map((c: any) => ({
...c,
contactStartTimestamp: toMs(c.contactStartTimestamp),
contactEndTimestamp: toMs(c.contactEndTimestamp),

파일 보기

@ -9,11 +9,28 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
let animationFrameId = null;
let lastFrameTime = null;
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => {
const animate = () => {
interface AreaSearchAnimationState {
isPlaying: boolean;
currentTime: number;
startTime: number;
endTime: number;
playbackSpeed: number;
play: () => void;
pause: () => void;
stop: () => void;
setCurrentTime: (time: number) => void;
setPlaybackSpeed: (speed: number) => void;
setTimeRange: (start: number, end: number) => void;
getProgress: () => number;
reset: () => void;
}
export const useAreaSearchAnimationStore = create<AreaSearchAnimationState>()(subscribeWithSelector((set, get) => {
const animate = (): void => {
const state = get();
if (!state.isPlaying) return;

파일 보기

@ -9,12 +9,50 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types';
import type {
AnalysisTab,
SearchMode,
ZoneDrawType,
Zone,
CircleMeta,
AreaSearchTooltip,
HitDetail,
VesselPosition,
} from '../types/areaSearch.types';
import { showLiveShips } from '../../utils/liveControl';
// ========== ProcessedTrack 인터페이스 (trackQueryApi에서 반환하는 형태) ==========
export interface ProcessedTrack {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
integrationTargetId?: string;
geometry: number[][];
timestampsMs: number[];
speeds: number[];
stats: {
totalDistance: number;
avgSpeed: number;
maxSpeed: number;
pointCount: number;
};
}
// ========== Summary 인터페이스 ==========
export interface AreaSearchSummary {
totalVessels: number;
processingTimeMs?: number;
}
/**
*
*/
function interpolatePosition(p1, p2, t1, t2, currentTime) {
function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] {
if (t1 === t2) return p1;
if (currentTime <= t1) return p1;
if (currentTime >= t2) return p2;
@ -25,7 +63,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) {
/**
* (heading)
*/
function calculateHeading(p1, p2) {
function calculateHeading(p1: number[], p2: number[]): number {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const dx = lon2 - lon1;
@ -39,9 +77,81 @@ let zoneIdCounter = 0;
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
// 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback
const positionCursors = new Map();
const positionCursors = new Map<string, number>();
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
interface AreaSearchState {
// 탭 상태
activeTab: AnalysisTab;
// 검색 조건
zones: Zone[];
searchMode: SearchMode;
// 검색 결과
tracks: ProcessedTrack[];
hitDetails: Record<string, HitDetail[]>;
summary: AreaSearchSummary | null;
// UI 상태
isLoading: boolean;
queryCompleted: boolean;
disabledVesselIds: Set<string>;
highlightedVesselId: string | null;
showZones: boolean;
activeDrawType: ZoneDrawType | null;
areaSearchTooltip: AreaSearchTooltip | null;
selectedZoneId: string | null;
_lastZoneAddedAt: number;
// 필터 상태
showPaths: boolean;
showTrail: boolean;
shipKindCodeFilter: Set<string>;
// 구역 관리
addZone: (zone: Omit<Zone, 'id' | 'name' | 'colorIndex'> & { circleMeta?: CircleMeta | null }) => void;
removeZone: (zoneId: string) => void;
clearZones: () => void;
reorderZones: (fromIndex: number, toIndex: number) => void;
// 탭 전환
setActiveTab: (tab: AnalysisTab) => void;
// 검색 조건
setSearchMode: (mode: SearchMode) => void;
setActiveDrawType: (type: ZoneDrawType | null) => void;
setShowZones: (show: boolean) => void;
// 구역 편집
selectZone: (zoneId: string) => void;
deselectZone: () => void;
updateZoneGeometry: (zoneId: string, coordinates4326: number[][], circleMeta?: CircleMeta) => void;
confirmAndClearResults: () => boolean;
// 검색 결과
setTracks: (tracks: ProcessedTrack[]) => void;
setHitDetails: (hitDetails: Record<string, HitDetail[]>) => void;
setSummary: (summary: AreaSearchSummary | null) => void;
setLoading: (loading: boolean) => void;
// 선박 토글
toggleVesselEnabled: (vesselId: string) => void;
setHighlightedVesselId: (vesselId: string | null) => void;
setAreaSearchTooltip: (tooltip: AreaSearchTooltip | null) => void;
// 필터 토글
setShowPaths: (show: boolean) => void;
setShowTrail: (show: boolean) => void;
toggleShipKindCode: (code: string) => void;
getEnabledTracks: () => ProcessedTrack[];
getCurrentPositions: (currentTime: number) => VesselPosition[];
// 초기화
clearResults: () => void;
reset: () => void;
}
export const useAreaSearchStore = create<AreaSearchState>()(subscribeWithSelector((set, get) => ({
// 탭 상태
activeTab: ANALYSIS_TABS.AREA,
@ -57,7 +167,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
// UI 상태
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,
@ -81,7 +191,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
let colorIndex = 0;
while (usedColors.has(colorIndex)) colorIndex++;
const newZone = {
const newZone: Zone = {
...zone,
id: `zone-${++zoneIdCounter}`,
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
@ -94,7 +204,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
removeZone: (zoneId) => {
const { zones, selectedZoneId } = get();
const filtered = zones.filter(z => z.id !== zoneId);
const updates = { zones: filtered };
const updates: Partial<AreaSearchState> = { zones: filtered };
if (selectedZoneId === zoneId) updates.selectedZoneId = null;
set(updates);
},
@ -130,7 +240,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
const { zones } = get();
const updated = zones.map(z => {
if (z.id !== zoneId) return z;
const patch = { ...z, coordinates: coordinates4326 };
const patch: Zone = { ...z, coordinates: coordinates4326 };
if (circleMeta !== undefined) patch.circleMeta = circleMeta;
return patch;
});
@ -140,7 +250,6 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
/**
*
* true , confirm
* @returns {boolean}
*/
confirmAndClearResults: () => {
const { queryCompleted } = get();
@ -209,7 +318,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
*/
getCurrentPositions: (currentTime) => {
const { tracks, disabledVesselIds } = get();
const positions = [];
const positions: VesselPosition[] = [];
tracks.forEach(track => {
if (disabledVesselIds.has(track.vesselId)) return;
@ -247,7 +356,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let position, heading, speed;
let position: number[], heading: number, speed: number;
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
position = geometry[idx1];
@ -281,7 +390,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
hitDetails: {},
summary: null,
queryCompleted: false,
disabledVesselIds: new Set(),
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
areaSearchTooltip: null,
showPaths: true,
@ -301,7 +410,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
summary: null,
isLoading: false,
queryCompleted: false,
disabledVesselIds: new Set(),
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
showZones: true,
activeDrawType: null,

파일 보기

@ -9,8 +9,11 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { STS_DEFAULTS } from '../types/sts.types';
import type { StsContact, StsGroupedContact } from '../types/sts.types';
import type { ProcessedTrack } from './areaSearchStore';
import type { VesselPosition } from '../types/areaSearch.types';
function interpolatePosition(p1, p2, t1, t2, currentTime) {
function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] {
if (t1 === t2) return p1;
if (currentTime <= t1) return p1;
if (currentTime >= t2) return p2;
@ -18,7 +21,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) {
return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio];
}
function calculateHeading(p1, p2) {
function calculateHeading(p1: number[], p2: number[]): number {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const dx = lon2 - lon1;
@ -28,11 +31,18 @@ function calculateHeading(p1, p2) {
return angle;
}
export interface StsSummary {
totalContactPairs: number;
totalVesselsInvolved: number;
totalVesselsInPolygon: number;
processingTimeMs?: number;
}
/**
* contacts
*/
function groupContactsByPair(contacts) {
const groupMap = new Map();
function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] {
const groupMap = new Map<string, StsGroupedContact>();
contacts.forEach((contact) => {
const v1Id = contact.vessel1.vesselId;
@ -45,13 +55,19 @@ function groupContactsByPair(contacts) {
vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2,
vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1,
contacts: [],
totalDurationMinutes: 0,
avgDistanceMeters: 0,
minDistanceMeters: 0,
maxDistanceMeters: 0,
totalContactPointCount: 0,
indicators: {},
});
}
groupMap.get(pairKey).contacts.push(contact);
groupMap.get(pairKey)!.contacts.push(contact);
});
return [...groupMap.values()].map((group) => {
group.contacts.sort((a, b) => a.contactStartTimestamp - b.contactStartTimestamp);
group.contacts.sort((a, b) => (a.contactStartTimestamp ?? 0) - (b.contactStartTimestamp ?? 0));
// 합산 통계
group.totalDurationMinutes = group.contacts.reduce(
@ -89,9 +105,55 @@ function groupContactsByPair(contacts) {
});
}
const positionCursors = new Map();
const positionCursors = new Map<string, number>();
export const useStsStore = create(subscribeWithSelector((set, get) => ({
interface StsState {
// STS 파라미터
minContactDurationMinutes: number;
maxContactDistanceMeters: number;
// 결과
contacts: StsContact[];
groupedContacts: StsGroupedContact[];
tracks: ProcessedTrack[];
summary: StsSummary | null;
// UI
isLoading: boolean;
queryCompleted: boolean;
highlightedGroupIndex: number | null;
disabledGroupIndices: Set<number>;
expandedGroupIndex: number | null;
// 필터 상태
showPaths: boolean;
showTrail: boolean;
// 파라미터 설정
setMinContactDuration: (val: number) => void;
setMaxContactDistance: (val: number) => void;
// 결과 설정
setResults: (result: { contacts: StsContact[]; tracks: ProcessedTrack[]; summary: StsSummary | null }) => void;
setLoading: (loading: boolean) => void;
// 그룹 UI
setHighlightedGroupIndex: (idx: number | null) => void;
setExpandedGroupIndex: (idx: number | null) => void;
toggleGroupEnabled: (idx: number) => void;
// 필터
setShowPaths: (show: boolean) => void;
setShowTrail: (show: boolean) => void;
getDisabledVesselIds: () => Set<string>;
getCurrentPositions: (currentTime: number) => VesselPosition[];
// 초기화
clearResults: () => void;
reset: () => void;
}
export const useStsStore = create<StsState>()(subscribeWithSelector((set, get) => ({
// STS 파라미터
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
@ -106,7 +168,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
isLoading: false,
queryCompleted: false,
highlightedGroupIndex: null,
disabledGroupIndices: new Set(),
disabledGroupIndices: new Set<number>(),
expandedGroupIndex: null,
// 필터 상태
@ -129,7 +191,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
tracks,
summary,
queryCompleted: true,
disabledGroupIndices: new Set(),
disabledGroupIndices: new Set<number>(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
});
@ -160,7 +222,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
getDisabledVesselIds: () => {
const { groupedContacts, disabledGroupIndices } = get();
const ids = new Set();
const ids = new Set<string>();
disabledGroupIndices.forEach((idx) => {
const g = groupedContacts[idx];
if (g) {
@ -174,7 +236,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
getCurrentPositions: (currentTime) => {
const { tracks } = get();
const disabledVesselIds = get().getDisabledVesselIds();
const positions = [];
const positions: VesselPosition[] = [];
tracks.forEach((track) => {
if (disabledVesselIds.has(track.vesselId)) return;
@ -209,7 +271,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let position, heading, speed;
let position: number[], heading: number, speed: number;
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
position = geometry[idx1];
@ -244,7 +306,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
tracks: [],
summary: null,
queryCompleted: false,
disabledGroupIndices: new Set(),
disabledGroupIndices: new Set<number>(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,
@ -263,7 +325,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({
summary: null,
isLoading: false,
queryCompleted: false,
disabledGroupIndices: new Set(),
disabledGroupIndices: new Set<number>(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,

파일 보기

@ -2,12 +2,17 @@
* ( )
*/
import type Feature from 'ol/Feature';
import type { Geometry } from 'ol/geom';
// ========== 분석 탭 ==========
export const ANALYSIS_TABS = {
AREA: 'area',
STS: 'sts',
};
} as const;
export type AnalysisTab = typeof ANALYSIS_TABS[keyof typeof ANALYSIS_TABS];
// ========== 검색 모드 ==========
@ -15,9 +20,11 @@ export const SEARCH_MODES = {
ANY: 'ANY',
ALL: 'ALL',
SEQUENTIAL: 'SEQUENTIAL',
};
} as const;
export const SEARCH_MODE_LABELS = {
export type SearchMode = typeof SEARCH_MODES[keyof typeof SEARCH_MODES];
export const SEARCH_MODE_LABELS: Record<SearchMode, string> = {
[SEARCH_MODES.ANY]: 'ANY (합집합)',
[SEARCH_MODES.ALL]: 'ALL (교집합)',
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
@ -31,11 +38,19 @@ export const ZONE_DRAW_TYPES = {
POLYGON: 'Polygon',
BOX: 'Box',
CIRCLE: 'Circle',
};
} as const;
export type ZoneDrawType = typeof ZONE_DRAW_TYPES[keyof typeof ZONE_DRAW_TYPES];
export const ZONE_NAMES = ['A', 'B', 'C'];
export const ZONE_COLORS = [
export interface ZoneColor {
fill: [number, number, number, number];
stroke: [number, number, number, number];
label: string;
}
export const ZONE_COLORS: ZoneColor[] = [
{ fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' },
{ fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' },
{ fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' },
@ -49,7 +64,7 @@ export const QUERY_MAX_DAYS = 7;
* (D-7 ~ D-1)
* ,
*/
export function getQueryDateRange() {
export function getQueryDateRange(): { startDate: Date; endDate: Date } {
const now = new Date();
const endDate = new Date(now);
@ -80,7 +95,7 @@ import {
SIGNAL_KIND_CODE_BUOY,
} from '../../types/constants';
export const ALL_SHIP_KIND_CODES = [
export const ALL_SHIP_KIND_CODES: string[] = [
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
@ -98,4 +113,58 @@ export const AREA_SEARCH_LAYER_IDS = {
TRIPS_TRAIL: 'area-search-trips-trail',
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer',
};
} as const;
// ========== Zone 인터페이스 ==========
export interface CircleMeta {
center: [number, number];
radius: number;
}
export interface Zone {
id: string;
name: string;
type: ZoneDrawType;
source: string;
coordinates: number[][];
colorIndex: number;
olFeature?: Feature<Geometry>;
circleMeta: CircleMeta | null;
}
// ========== 툴팁 ==========
export interface AreaSearchTooltip {
vesselId: string;
x: number;
y: number;
}
// ========== HitDetail ==========
export interface HitDetail {
polygonId: string;
polygonName?: string;
visitIndex: number;
entryTimestamp: number | null;
exitTimestamp: number | null;
entryPosition: number[] | null;
exitPosition: number[] | null;
}
// ========== VesselPosition ==========
export interface VesselPosition {
vesselId: string;
targetId: string;
sigSrcCd: string;
shipName: string;
shipKindCode: string;
nationalCode: string;
lon: number;
lat: number;
heading: number;
speed: number;
timestamp: number;
}

파일 보기

@ -8,14 +8,14 @@ import { getShipKindName } from '../../tracking/types/trackQuery.types';
export const STS_DEFAULTS = {
MIN_CONTACT_DURATION: 60,
MAX_CONTACT_DISTANCE: 500,
};
} as const;
export const STS_LIMITS = {
DURATION_MIN: 30,
DURATION_MAX: 360,
DISTANCE_MIN: 50,
DISTANCE_MAX: 5000,
};
} as const;
// ========== 레이어 ID ==========
@ -26,22 +26,71 @@ export const STS_LAYER_IDS = {
TRIPS_TRAIL: 'sts-trips-trail-layer',
VIRTUAL_SHIP: 'sts-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer',
};
} as const;
// ========== Indicator 라벨 ==========
export const INDICATOR_LABELS = {
export const INDICATOR_LABELS: Record<string, string> = {
lowSpeedContact: '저속',
differentVesselTypes: '이종',
differentNationalities: '외국적',
nightTimeContact: '야간',
};
// ========== STS Contact / Vessel 인터페이스 ==========
export interface StsVessel {
vesselId: string;
vesselName?: string;
shipKindCode?: string;
nationalCode?: string;
estimatedAvgSpeedKnots?: number | null;
insidePolygonDurationMinutes?: number;
insidePolygonStartTs?: number | null;
insidePolygonEndTs?: number | null;
}
export interface StsIndicators {
lowSpeedContact?: boolean;
differentVesselTypes?: boolean;
differentNationalities?: boolean;
nightTimeContact?: boolean;
[key: string]: boolean | undefined;
}
export interface StsContact {
vessel1: StsVessel;
vessel2: StsVessel;
contactStartTimestamp: number | null;
contactEndTimestamp: number | null;
contactDurationMinutes?: number;
contactPointCount?: number;
avgDistanceMeters?: number;
minDistanceMeters: number;
maxDistanceMeters: number;
contactCenterPoint?: number[];
indicators?: StsIndicators;
}
export interface StsGroupedContact {
pairKey: string;
vessel1: StsVessel;
vessel2: StsVessel;
contacts: StsContact[];
totalDurationMinutes: number;
avgDistanceMeters: number;
minDistanceMeters: number;
maxDistanceMeters: number;
contactCenterPoint?: number[];
totalContactPointCount: number;
indicators: StsIndicators;
}
/**
* indicator
* : "저속 1.2/0.8kn", "이종 어선↔화물선"
*/
export function getIndicatorDetail(key, contact) {
export function getIndicatorDetail(key: string, contact: StsContact): string {
const { vessel1, vessel2 } = contact;
switch (key) {
@ -73,7 +122,7 @@ export function getIndicatorDetail(key, contact) {
/**
* ( )
*/
export function formatDistance(meters) {
export function formatDistance(meters: number | null | undefined): string {
if (meters == null) return '-';
if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`;
return `${Math.round(meters)}m`;
@ -82,7 +131,7 @@ export function formatDistance(meters) {
/**
* ( )
*/
export function formatDuration(minutes) {
export function formatDuration(minutes: number | null | undefined): string {
if (minutes == null) return '-';
if (minutes < 60) return `${minutes}`;
const h = Math.floor(minutes / 60);
@ -96,7 +145,7 @@ export function formatDuration(minutes) {
* contact의 indicators
* 3+: , 2: 주황, 1: 노랑, 0: 회색
*/
export function getContactRiskColor(indicators) {
export function getContactRiskColor(indicators: StsIndicators | null | undefined): [number, number, number, number] {
if (!indicators) return [150, 150, 150, 200];
const count = Object.values(indicators).filter(Boolean).length;
if (count >= 3) return [231, 76, 60, 220];

파일 보기

@ -1,19 +1,21 @@
/**
*
* 참조: src/replay/utils/replayLayerRegistry.js
* 참조: src/replay/utils/replayLayerRegistry.ts
*
* useAreaSearchLayer
* useShipLayer의 handleBatchRender에서 deck.gl에
*/
export function registerAreaSearchLayers(layers) {
import type { Layer } from '@deck.gl/core';
export function registerAreaSearchLayers(layers: Layer[]): void {
window.__areaSearchLayers__ = layers;
}
export function getAreaSearchLayers() {
export function getAreaSearchLayers(): Layer[] {
return window.__areaSearchLayers__ || [];
}
export function unregisterAreaSearchLayers() {
export function unregisterAreaSearchLayers(): void {
window.__areaSearchLayers__ = [];
}

파일 보기

@ -4,15 +4,17 @@
*/
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils';
import type { ProcessedTrack } from '../stores/areaSearchStore';
import type { HitDetail, Zone } from '../types/areaSearch.types';
function formatTimestamp(ms) {
function formatTimestamp(ms: number | null): string {
if (!ms) return '';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatPosition(pos) {
function formatPosition(pos: number[] | null): string {
if (!pos || pos.length < 2) return '';
const lon = pos[0];
const lat = pos[1];
@ -21,7 +23,7 @@ function formatPosition(pos) {
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
}
function escapeCsvField(value) {
function escapeCsvField(value: string | number): string {
const str = String(value ?? '');
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
@ -32,16 +34,20 @@ function escapeCsvField(value) {
/**
* CSV로 ( )
*
* @param {Array} tracks ProcessedTrack
* @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
* @param {Array} zones
* @param tracks ProcessedTrack
* @param hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
* @param zones
*/
export function exportSearchResultToCSV(tracks, hitDetails, zones) {
export function exportSearchResultToCSV(
tracks: ProcessedTrack[],
hitDetails: Record<string, HitDetail[]>,
zones: Zone[],
): void {
// 구역별 최대 방문 횟수 계산
const maxVisitsPerZone = {};
const maxVisitsPerZone: Record<string, number> = {};
zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; });
Object.values(hitDetails).forEach((hits) => {
const countByZone = {};
const countByZone: Record<string, number> = {};
(Array.isArray(hits) ? hits : []).forEach((h) => {
countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1;
});
@ -56,7 +62,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
];
const zoneHeaders = [];
const zoneHeaders: string[] = [];
zones.forEach((zone) => {
const max = maxVisitsPerZone[zone.id] || 1;
if (max === 1) {
@ -78,7 +84,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
// 데이터 행 생성
const rows = tracks.map((track) => {
const baseRow = [
const baseRow: (string | number)[] = [
getSignalSourceName(track.sigSrcCd),
track.targetId || '',
track.shipName || '',
@ -91,7 +97,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
];
const hits = hitDetails[track.vesselId] || [];
const zoneData = [];
const zoneData: string[] = [];
zones.forEach((zone) => {
const max = maxVisitsPerZone[zone.id] || 1;
const zoneHits = hits
@ -129,7 +135,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
const url = URL.createObjectURL(blob);
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number): string => String(n).padStart(2, '0');
const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
const link = document.createElement('a');

파일 보기

@ -1,19 +1,21 @@
/**
* STS
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.js
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.ts
*
* useStsLayer
* useShipLayer의 handleBatchRender에서 deck.gl에
*/
export function registerStsLayers(layers) {
import type { Layer } from '@deck.gl/core';
export function registerStsLayers(layers: Layer[]): void {
window.__stsLayers__ = layers;
}
export function getStsLayers() {
export function getStsLayers(): Layer[] {
return window.__stsLayers__ || [];
}
export function unregisterStsLayers() {
export function unregisterStsLayers(): void {
window.__stsLayers__ = [];
}

파일 보기

@ -1,12 +0,0 @@
/**
* 구역 VectorSource/VectorLayer 모듈 스코프 참조
* useZoneDraw와 useZoneEdit 공유
*/
let _source = null;
let _layer = null;
export function setZoneSource(source) { _source = source; }
export function getZoneSource() { return _source; }
export function setZoneLayer(layer) { _layer = layer; }
export function getZoneLayer() { return _layer; }

파일 보기

@ -0,0 +1,17 @@
/**
* VectorSource/VectorLayer
* useZoneDraw와 useZoneEdit
*/
import type VectorSource from 'ol/source/Vector';
import type VectorLayer from 'ol/layer/Vector';
import type Feature from 'ol/Feature';
import type { Geometry } from 'ol/geom';
let _source: VectorSource | null = null;
let _layer: VectorLayer<Feature<Geometry>> | null = null;
export function setZoneSource(source: VectorSource | null): void { _source = source; }
export function getZoneSource(): VectorSource | null { return _source; }
export function setZoneLayer(layer: VectorLayer<Feature<Geometry>> | null): void { _layer = layer; }
export function getZoneLayer(): VectorLayer<Feature<Geometry>> | null { return _layer; }

파일 보기

@ -1,4 +1,4 @@
export const shipTypeMap = new Map();
export const shipTypeMap: Map<string, string> = new Map();
shipTypeMap.set('0', 'Not available');
shipTypeMap.set('1', 'Reserved for future use');

파일 보기

@ -3,9 +3,54 @@
* 참조: mda-react-front/src/common/stompClient.ts
* 참조: mda-react-front/src/map/MapUpdater.tsx
*/
import { Client } from '@stomp/stompjs';
import { Client, IFrame, StompSubscription } from '@stomp/stompjs';
import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants';
/** 선박 데이터 객체 (stompClient에서 파싱) */
export interface ShipObject {
featureId: string;
targetId: string;
originalTargetId: string;
signalSourceCode: string;
shipName: string;
shipType: string;
longitude: number;
latitude: number;
sog: number;
cog: number;
receivedTime: string;
signalKindCode: string;
lost: boolean;
integrate: boolean;
isPriority: boolean;
hazardousCategory: string;
nationalCode: string;
imo: string;
draught: string;
dimA: string;
dimB: string;
dimC: string;
dimD: string;
ais: string | undefined;
vpass: string | undefined;
enav: string | undefined;
vtsAis: string | undefined;
dMfHf: string | undefined;
vtsRadar: string | undefined;
_raw: string[];
}
/** STOMP 연결 콜백 */
interface StompCallbacks {
onConnect?: (frame: IFrame) => void;
onDisconnect?: () => void;
onError?: (frame: IFrame) => void;
}
/** 선박 카운트 메시지 (JSON) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ShipCountData = any;
/**
* STOMP
* 환경변수: VITE_SIGNAL_WS (: ws://10.26.252.39:9090/connect)
@ -17,7 +62,7 @@ export const signalStompClient = new Client({
brokerURL,
reconnectDelay: 10000,
connectionTimeout: 5000,
debug: (str) => {
debug: (str: string) => {
// STOMP 디버그 로그 (연결 관련 메시지만 출력)
if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) {
console.log('[STOMP Debug]', str);
@ -31,7 +76,7 @@ export const signalStompClient = new Client({
* @param {string} msgString -
* @returns {Array}
*/
export function parsePipeMessage(msgString) {
export function parsePipeMessage(msgString: string): string[] {
return msgString.split('|');
}
@ -40,9 +85,9 @@ export function parsePipeMessage(msgString) {
* 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget()
*
* @param {Array} row - (38 )
* @returns {Object}
* @returns {ShipObject}
*/
export function rowToShipObject(row) {
export function rowToShipObject(row: string[]): ShipObject {
const idx = SHIP_MSG_INDEX;
const targetId = row[idx.TARGET_ID] || '';
@ -122,10 +167,10 @@ export function rowToShipObject(row) {
* @param {Function} callbacks.onDisconnect -
* @param {Function} callbacks.onError -
*/
export function connectStomp(callbacks = {}) {
export function connectStomp(callbacks: StompCallbacks = {}): void {
const { onConnect, onDisconnect, onError } = callbacks;
signalStompClient.onConnect = (frame) => {
signalStompClient.onConnect = (frame: IFrame) => {
console.log('[STOMP] Connected');
onConnect?.(frame);
};
@ -135,7 +180,7 @@ export function connectStomp(callbacks = {}) {
onDisconnect?.();
};
signalStompClient.onStompError = (frame) => {
signalStompClient.onStompError = (frame: IFrame) => {
console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error');
onError?.(frame);
};
@ -146,7 +191,7 @@ export function connectStomp(callbacks = {}) {
/**
* STOMP
*/
export function disconnectStomp() {
export function disconnectStomp(): void {
if (signalStompClient.connected) {
signalStompClient.deactivate();
}
@ -157,9 +202,9 @@ export function disconnectStomp() {
* - : /topic/ship ()
* - : /topic/ship-throttled-60s ( )
* @param {Function} onMessage - ( )
* @returns {Object} (unsubscribe )
* @returns {StompSubscription} (unsubscribe )
*/
export function subscribeShips(onMessage) {
export function subscribeShips(onMessage: (ships: ShipObject[]) => void): StompSubscription {
// 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀)
const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
@ -172,9 +217,9 @@ export function subscribeShips(onMessage) {
return signalStompClient.subscribe(topic, (message) => {
try {
const body = message.body;
const lines = body.split('\n').filter(line => line.trim());
const lines = body.split('\n').filter((line: string) => line.trim());
const ships = lines.map(line => {
const ships = lines.map((line: string) => {
const row = parsePipeMessage(line);
return rowToShipObject(row);
});
@ -190,9 +235,9 @@ export function subscribeShips(onMessage) {
* (Raw , Worker용)
* - Web Worker에서
* @param {Function} onMessage - ( )
* @returns {Object} (unsubscribe )
* @returns {StompSubscription} (unsubscribe )
*/
export function subscribeShipsRaw(onMessage) {
export function subscribeShipsRaw(onMessage: (lines: string[]) => void): StompSubscription {
const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
const topic = throttleSeconds > 0
@ -205,7 +250,7 @@ export function subscribeShipsRaw(onMessage) {
try {
const body = message.body;
// 파싱 없이 줄 단위로 분리만 해서 전달
const lines = body.split('\n').filter(line => line.trim());
const lines = body.split('\n').filter((line: string) => line.trim());
onMessage(lines);
} catch (error) {
console.error('[STOMP] Ship message parse error:', error);
@ -216,9 +261,9 @@ export function subscribeShipsRaw(onMessage) {
/**
*
* @param {Function} onDelete - (featureId)
* @returns {Object}
* @returns {StompSubscription}
*/
export function subscribeShipDelete(onDelete) {
export function subscribeShipDelete(onDelete: (featureId: string) => void): StompSubscription {
console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`);
return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => {
@ -239,9 +284,9 @@ export function subscribeShipDelete(onDelete) {
/**
*
* @param {Function} onCount -
* @returns {Object}
* @returns {StompSubscription}
*/
export function subscribeShipCount(onCount) {
export function subscribeShipCount(onCount: (counts: ShipCountData) => void): StompSubscription {
return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => {
try {
const counts = JSON.parse(message.body);

파일 보기

@ -1,10 +1,14 @@
import { useEffect } from 'react';
import { useEffect, type ReactNode } from 'react';
import { useAuthStore } from '../../stores/authStore';
import './SessionGuard.scss';
const SKIP_AUTH = import.meta.env.VITE_DEV_SKIP_AUTH === 'true';
export default function SessionGuard({ children }) {
interface SessionGuardProps {
children: ReactNode;
}
export default function SessionGuard({ children }: SessionGuardProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isChecking = useAuthStore((s) => s.isChecking);

파일 보기

@ -2,19 +2,24 @@ import { useState } from 'react';
import { createPortal } from 'react-dom';
import './AlertModal.scss';
let showAlertFn = null;
interface AlertState {
message: string;
errorCode?: string;
}
export function showAlert(message, errorCode) {
let showAlertFn: ((message: string, errorCode?: string) => void) | null = null;
export function showAlert(message: string, errorCode?: string): void {
if (showAlertFn) {
showAlertFn(message, errorCode);
}
}
export function AlertModalContainer() {
const [alert, setAlert] = useState(null);
const [alert, setAlert] = useState<AlertState | null>(null);
useState(() => {
showAlertFn = (message, errorCode) => {
showAlertFn = (message: string, errorCode?: string) => {
setAlert({ message, errorCode });
};
@ -31,7 +36,7 @@ export function AlertModalContainer() {
return createPortal(
<div className="alert-modal-overlay" onClick={handleClose}>
<div className="alert-modal" onClick={(e) => e.stopPropagation()}>
<div className="alert-modal" onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div className="alert-modal__message">{alert.message}</div>
{alert.errorCode && (
<div className="alert-modal__error-code"> : {alert.errorCode}</div>

파일 보기

@ -7,7 +7,11 @@
import { createPortal } from 'react-dom';
import './LoadingOverlay.scss';
export default function LoadingOverlay({ message = '조회중...' }) {
interface LoadingOverlayProps {
message?: string;
}
export default function LoadingOverlay({ message = '조회중...' }: LoadingOverlayProps) {
return createPortal(
<div className="loading-overlay">
<div className="loading-content">

파일 보기

@ -6,10 +6,16 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import './Toast.scss';
// 토스트 메시지 표시 함수 (외부에서 호출용)
let showToastFn = null;
interface ToastData {
id: number;
message: string;
duration: number;
}
export function showToast(message, duration = 3000) {
// 토스트 메시지 표시 함수 (외부에서 호출용)
let showToastFn: ((message: string, duration: number) => void) | null = null;
export function showToast(message: string, duration: number = 3000): void {
if (showToastFn) {
showToastFn(message, duration);
}
@ -20,10 +26,10 @@ export function showToast(message, duration = 3000) {
* App
*/
export function ToastContainer() {
const [toasts, setToasts] = useState([]);
const [toasts, setToasts] = useState<ToastData[]>([]);
useEffect(() => {
showToastFn = (message, duration) => {
showToastFn = (message: string, duration: number) => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, duration }]);
};
@ -33,7 +39,7 @@ export function ToastContainer() {
};
}, []);
const removeToast = (id) => {
const removeToast = (id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
@ -52,12 +58,18 @@ export function ToastContainer() {
);
}
interface ToastItemProps {
message: string;
duration: number;
onClose: () => void;
}
/**
*
*/
function ToastItem({ message, duration, onClose }) {
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
function ToastItem({ message, duration, onClose }: ToastItemProps) {
const [isVisible, setIsVisible] = useState<boolean>(false);
const [isExiting, setIsExiting] = useState<boolean>(false);
useEffect(() => {
// 마운트 후 바로 표시

파일 보기

@ -16,9 +16,9 @@ const SAMPLE_ALERTS = [
*/
export default function Header() {
const user = useAuthStore((s) => s.user);
const alertIndexRef = useRef(0);
const alertIndexRef = useRef<number>(0);
const handleAlarmClick = (e) => {
const handleAlarmClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length];
alertIndexRef.current++;

파일 보기

@ -2,7 +2,14 @@
*
*/
const gnbList = [
interface GnbItem {
key: string;
className: string;
label: string;
path: string;
}
const gnbList: GnbItem[] = [
{ key: 'gnb1', className: 'gnb1', label: '선박', path: 'ship' },
{ key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' },
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
@ -10,7 +17,12 @@ const gnbList = [
{ key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
];
export default function SideNav({ activeKey, onChange }) {
interface SideNavProps {
activeKey: string | null;
onChange: (key: string) => void;
}
export default function SideNav({ activeKey, onChange }: SideNavProps) {
return (
<nav id="nav">
<ul className="gnb">
@ -33,7 +45,7 @@ export default function SideNav({ activeKey, onChange }) {
}
// 키-경로 매핑 export (Sidebar에서 사용)
export const keyToPath = {
export const keyToPath: Record<string, string> = {
gnb1: 'ship',
gnb4: 'analysis',
gnb5: 'timeline',
@ -41,6 +53,6 @@ export const keyToPath = {
gnb8: 'area-search',
};
export const pathToKey = Object.fromEntries(
export const pathToKey: Record<string, string> = Object.fromEntries(
Object.entries(keyToPath).map(([k, v]) => [v, k])
);

파일 보기

@ -20,10 +20,10 @@ export default function Sidebar() {
const path = location.pathname.split('/')[1];
return path ? pathToKey[path] : null;
})();
const [isPanelOpen, setIsPanelOpen] = useState(initialActiveKey !== null);
const [isPanelOpen, setIsPanelOpen] = useState<boolean>(initialActiveKey !== null);
// URL에서 활성 메뉴 키 추출 (루트 경로면 비활성)
const getActiveKey = () => {
const getActiveKey = (): string | null => {
const path = location.pathname.split('/')[1];
if (!path) return null; // 루트 경로면 비활성
return pathToKey[path] || null;
@ -31,7 +31,7 @@ export default function Sidebar() {
const activeKey = getActiveKey();
const handleMenuChange = (key) => {
const handleMenuChange = (key: string) => {
setIsPanelOpen(true);
const path = keyToPath[key] || 'ship';
navigate(`/${path}`);
@ -49,14 +49,14 @@ export default function Sidebar() {
// 활성 키에 따른 패널 컴포넌트 렌더링
const renderPanel = () => {
const panelMap = {
const panelMap: Record<string, React.ReactNode> = {
gnb1: <ShipFilterPanel {...panelProps} />,
gnb4: null, // TODO: 분석 패널
gnb5: null, // TODO: 타임라인 패널
gnb7: <ReplayPage {...panelProps} />,
gnb8: <AreaSearchPage {...panelProps} />,
};
return panelMap[activeKey] || null;
return activeKey ? panelMap[activeKey] || null : null;
};
return (

파일 보기

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useState } from 'react';
import { useMapStore } from '../../stores/mapStore';
import useShipStore from '../../stores/shipStore';
import type { LabelOptions } from '../../stores/shipStore';
import { downloadShipCsv } from '../../utils/csvDownload';
import { showLiveShips } from '../../utils/liveControl';
import useReplayStore from '../../replay/stores/replayStore';
@ -8,8 +9,15 @@ import useAnimationStore from '../../replay/stores/animationStore';
import { unregisterReplayLayers } from '../../replay/utils/replayLayerRegistry';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
type AreaShapeKey = 'Box' | 'Polygon' | 'Circle';
interface AreaShapeOption {
key: AreaShapeKey;
label: string;
}
// 면적 도형 옵션
const AREA_SHAPES = [
const AREA_SHAPES: AreaShapeOption[] = [
{ key: 'Box', label: '사각형' },
{ key: 'Polygon', label: '다각형' },
{ key: 'Circle', label: '원형' },
@ -23,8 +31,8 @@ const AREA_SHAPES = [
* - ,
*/
export default function ToolBar() {
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState(false);
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState(false);
const [isLabelPanelOpen, setIsLabelPanelOpen] = useState<boolean>(false);
const [isAreaPanelOpen, setIsAreaPanelOpen] = useState<boolean>(false);
const { zoom, zoomIn, zoomOut, activeMeasureTool, setMeasureTool, setAreaShape } = useMapStore();
const {
isIntegrate,
@ -38,7 +46,7 @@ export default function ToolBar() {
} = useShipStore();
// 선명표시 옵션 목록
const labelOptionList = [
const labelOptionList: { key: keyof LabelOptions; label: string }[] = [
{ key: 'showShipName', label: '선박명' },
{ key: 'showSpeedVector', label: '속도벡터' },
{ key: 'showShipSize', label: '선박크기' },
@ -185,7 +193,9 @@ export default function ToolBar() {
alert('다운로드할 선박 데이터가 없습니다.');
return;
}
await downloadShipCsv(ships);
// ShipFeature[]는 DownloadShip의 모든 필드를 포함 (getDownloadShips가 보장)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await downloadShipCsv(ships as any);
}}
></button>
</li>

파일 보기

@ -9,23 +9,30 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { toLonLat } from 'ol/proj';
import { useMapStore } from '../../stores/mapStore';
import useTrackingModeStore from '../../stores/trackingModeStore';
import useShipSearch from '../../hooks/useShipSearch';
import useShipSearch, { type SearchResult } from '../../hooks/useShipSearch';
import './TopBar.scss';
interface DmsResult {
degrees: number;
minutes: number;
seconds: string;
direction: string;
}
/**
* (DMS)
* @param {number} decimal -
* @param {boolean} isLongitude - (false면 )
* @returns {{ degrees: number, minutes: number, seconds: string, direction: string }}
*/
function decimalToDMS(decimal, isLongitude) {
function decimalToDMS(decimal: number, isLongitude: boolean): DmsResult {
const absolute = Math.abs(decimal);
const degrees = Math.floor(absolute);
const minutesFloat = (absolute - degrees) * 60;
const minutes = Math.floor(minutesFloat);
const seconds = ((minutesFloat - minutes) * 60).toFixed(3);
let direction;
let direction: string;
if (isLongitude) {
direction = decimal >= 0 ? 'E' : 'W';
} else {
@ -41,11 +48,17 @@ function decimalToDMS(decimal, isLongitude) {
* @param {boolean} isLongitude -
* @returns {string}
*/
function formatDecimalDegrees(decimal, isLongitude) {
function formatDecimalDegrees(decimal: number, isLongitude: boolean): string {
const direction = isLongitude
? (decimal >= 0 ? 'E' : 'W')
: (decimal >= 0 ? 'N' : 'S');
return `${Math.abs(decimal).toFixed(6)}° ${direction}`;
return `${Math.abs(decimal).toFixed(6)}\u00B0 ${direction}`;
}
interface TimeFormatResult {
dateStr: string;
timeStr: string;
dayOfWeek: string;
}
/**
@ -53,7 +66,7 @@ function formatDecimalDegrees(decimal, isLongitude) {
* @param {Date} date
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
*/
function formatKST(date) {
function formatKST(date: Date): TimeFormatResult {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@ -75,7 +88,7 @@ function formatKST(date) {
* @param {Date} date
* @returns {{ dateStr: string, timeStr: string, dayOfWeek: string }}
*/
function formatUTC(date) {
function formatUTC(date: Date): TimeFormatResult {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
@ -93,26 +106,26 @@ function formatUTC(date) {
}
export default function TopBar() {
const map = useMapStore((s) => s.map);
const map = useMapStore((s: { map: unknown }) => s.map);
// 추적 모드 상태
const mode = useTrackingModeStore((s) => s.mode);
const setMapMode = useTrackingModeStore((s) => s.setMapMode);
const setShipMode = useTrackingModeStore((s) => s.setShipMode);
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
const mode = useTrackingModeStore((s: { mode: string }) => s.mode);
const setMapMode = useTrackingModeStore((s: { setMapMode: () => void }) => s.setMapMode);
const setShipMode = useTrackingModeStore((s: { setShipMode: () => void }) => s.setShipMode);
const trackedShip = useTrackingModeStore((s: { trackedShip: { shipName?: string; originalTargetId?: string } | null }) => s.trackedShip);
const radiusNM = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM);
// 마우스 좌표 상태
const [coordinates, setCoordinates] = useState({ lon: null, lat: null });
const [coordinates, setCoordinates] = useState<{ lon: number | null; lat: number | null }>({ lon: null, lat: null });
// 현재 시간 상태
const [currentTime, setCurrentTime] = useState(new Date());
const [currentTime, setCurrentTime] = useState<Date>(new Date());
// 설정 상태
const [coordFormat, setCoordFormat] = useState('dms'); // 'dms' | 'decimal'
const [timeFormat, setTimeFormat] = useState('kst'); // 'kst' | 'utc'
const [showSettings, setShowSettings] = useState(false);
const settingsRef = useRef(null);
const [coordFormat, setCoordFormat] = useState<'dms' | 'decimal'>('dms');
const [timeFormat, setTimeFormat] = useState<'kst' | 'utc'>('kst');
const [showSettings, setShowSettings] = useState<boolean>(false);
const settingsRef = useRef<HTMLLIElement>(null);
// 검색 훅
const {
@ -126,17 +139,18 @@ export default function TopBar() {
} = useShipSearch();
// 검색창 포커스 상태
const [isSearchFocused, setIsSearchFocused] = useState(false);
const searchContainerRef = useRef(null);
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
const searchContainerRef = useRef<HTMLDivElement>(null);
// 좌표 업데이트 쓰로틀 ref
const throttleRef = useRef(null);
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 마우스 이동 시 좌표 업데이트
useEffect(() => {
if (!map) return;
const handlePointerMove = (evt) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePointerMove = (evt: any) => {
// 쓰로틀: 100ms
if (throttleRef.current) return;
@ -145,17 +159,20 @@ export default function TopBar() {
}, 100);
const pixel = evt.pixel;
const coord3857 = map.getCoordinateFromPixel(pixel);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const coord3857 = (map as any).getCoordinateFromPixel(pixel);
if (coord3857) {
const [lon, lat] = toLonLat(coord3857);
setCoordinates({ lon, lat });
}
};
map.on('pointermove', handlePointerMove);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(map as any).on('pointermove', handlePointerMove);
return () => {
map.un('pointermove', handlePointerMove);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(map as any).un('pointermove', handlePointerMove);
if (throttleRef.current) {
clearTimeout(throttleRef.current);
throttleRef.current = null;
@ -174,11 +191,11 @@ export default function TopBar() {
// 검색창/설정 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (e) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target)) {
const handleClickOutside = (e: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
setIsSearchFocused(false);
}
if (settingsRef.current && !settingsRef.current.contains(e.target)) {
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
setShowSettings(false);
}
};
@ -188,12 +205,12 @@ export default function TopBar() {
}, []);
// 검색어 변경 핸들러
const handleSearchChange = useCallback((e) => {
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, [setSearchValue]);
// 엔터키로 첫 번째 결과 선택
const handleKeyDown = useCallback((e) => {
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSelectFirst();
@ -204,7 +221,7 @@ export default function TopBar() {
}, [handleSelectFirst, clearSearch]);
// 검색 결과 클릭
const handleResultClick = useCallback((result) => {
const handleResultClick = useCallback((result: SearchResult) => {
handleClickResult(result);
setIsSearchFocused(false);
}, [handleClickResult]);
@ -217,20 +234,20 @@ export default function TopBar() {
/**
* ()
*/
const truncateString = (str, maxLength = 20) => {
const truncateString = (str: string | null | undefined, maxLength: number = 20): string => {
if (!str) return '-';
return str.length > maxLength ? str.slice(0, maxLength) + '...' : str;
};
// 좌표 포맷팅 (설정에 따라)
const renderCoordinate = (value, isLongitude) => {
const renderCoordinate = (value: number | null, isLongitude: boolean) => {
if (value === null) return <span>---</span>;
if (coordFormat === 'dms') {
const dms = decimalToDMS(value, isLongitude);
return (
<>
<span>{dms.degrees}°</span>
<span>{dms.degrees}&deg;</span>
<span>{dms.minutes}'</span>
<span>{dms.seconds}"</span>
<span>{dms.direction}</span>
@ -375,7 +392,7 @@ export default function TopBar() {
onClick={clearSearch}
title="검색어 지우기"
>
×
&times;
</button>
)}
</div>
@ -383,7 +400,7 @@ export default function TopBar() {
{/* 검색 결과 목록 */}
{isSearchFocused && searchValue && results.length > 0 && (
<ul className="search-results">
{results.map((result) => (
{results.map((result: SearchResult) => (
<li
key={result.featureId}
className="search-result-item"

파일 보기

@ -29,9 +29,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
/**
*
* ->
*/
const SHIP_KIND_ICONS = {
const SHIP_KIND_ICONS: Record<string, string> = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
@ -42,10 +42,15 @@ const SHIP_KIND_ICONS = {
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
};
interface LegendItemConfig {
code: string;
label: string;
}
/**
*
*/
const LEGEND_ITEMS = [
const LEGEND_ITEMS: LegendItemConfig[] = [
{ code: SIGNAL_KIND_CODE_FISHING, label: '어선' },
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
@ -56,10 +61,19 @@ const LEGEND_ITEMS = [
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
];
interface ReplayLegendItemProps {
code: string;
label: string;
count: number;
icon: string;
isVisible: boolean;
onToggle: (code: string) => void;
}
/**
*
*/
const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
const ReplayLegendItem = memo(function ReplayLegendItem({ code, label, count, icon, isVisible, onToggle }: ReplayLegendItemProps) {
const isBuoy = code === SIGNAL_KIND_CODE_BUOY;
return (
@ -82,17 +96,21 @@ const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle }
/**
*
*/
const ReplayLegend = memo(() => {
const ReplayLegend = memo(function ReplayLegend() {
const { replayShipCounts, replayTotalCount, shipKindCodeFilter } =
useReplayStore(
(state) => ({
(state: {
replayShipCounts: Record<string, number>;
replayTotalCount: number;
shipKindCodeFilter: Set<string>;
}) => ({
replayShipCounts: state.replayShipCounts,
replayTotalCount: state.replayTotalCount,
shipKindCodeFilter: state.shipKindCodeFilter,
}),
shallow
);
const toggleShipKindCode = useReplayStore((state) => state.toggleShipKindCode);
const toggleShipKindCode = useReplayStore((state: { toggleShipKindCode: (code: string) => void }) => state.toggleShipKindCode);
return (
<article className="ship-legend">

파일 보기

@ -13,15 +13,29 @@ import {
buildVesselListForQuery,
deduplicateVessels,
} from '../../tracking/services/trackQueryApi';
import type { VesselQueryTarget } from '../../tracking/services/trackQueryApi';
import type { ShipFeature } from '../../types/ship';
import './ShipContextMenu.scss';
interface ContextMenuData {
x: number;
y: number;
ships: ShipFeature[];
}
interface MenuItem {
key: string;
label: string;
hasSubmenu?: boolean;
}
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
function toKstISOString(date) {
const pad = (n) => String(n).padStart(2, '0');
function toKstISOString(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
const MENU_ITEMS = [
const MENU_ITEMS: MenuItem[] = [
{ key: 'track', label: '항적조회' },
// TODO: 임시 배포용 - 미구현 기능 숨김
// { key: 'analysis', label: '항적분석' },
@ -30,20 +44,20 @@ const MENU_ITEMS = [
];
export default function ShipContextMenu() {
const contextMenu = useShipStore((s) => s.contextMenu);
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
const setRadius = useTrackingModeStore((s) => s.setRadius);
const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
const currentRadius = useTrackingModeStore((s) => s.radiusNM);
const menuRef = useRef(null);
const [hoveredItem, setHoveredItem] = useState(null);
const contextMenu = useShipStore((s: { contextMenu: ContextMenuData | null }) => s.contextMenu);
const closeContextMenu = useShipStore((s: { closeContextMenu: () => void }) => s.closeContextMenu);
const setRadius = useTrackingModeStore((s: { setRadius: (r: number) => void }) => s.setRadius);
const selectTrackedShip = useTrackingModeStore((s: { selectTrackedShip: (id: string, ship: ShipFeature) => void }) => s.selectTrackedShip);
const currentRadius = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM);
const menuRef = useRef<HTMLDivElement>(null);
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
// 외부 클릭 시 닫기
useEffect(() => {
if (!contextMenu) return;
const handleClick = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeContextMenu();
}
};
@ -53,7 +67,7 @@ export default function ShipContextMenu() {
}, [contextMenu, closeContextMenu]);
// 반경 선택 핸들러
const handleRadiusSelect = useCallback((radius) => {
const handleRadiusSelect = useCallback((radius: number) => {
if (!contextMenu) return;
const { ships } = contextMenu;
@ -67,7 +81,7 @@ export default function ShipContextMenu() {
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
// 메뉴 항목 클릭
const handleAction = useCallback(async (key) => {
const handleAction = useCallback(async (key: string) => {
if (!contextMenu) return;
const { ships } = contextMenu;
@ -86,8 +100,8 @@ export default function ShipContextMenu() {
const { isIntegrate, features } = useShipStore.getState();
const allVessels = [];
const errors = [];
const allVessels: VesselQueryTarget[] = [];
const errors: string[] = [];
ships.forEach(ship => {
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
if (result.canQuery) allVessels.push(...result.vessels);
@ -134,7 +148,7 @@ export default function ShipContextMenu() {
const { isIntegrate, features } = useShipStore.getState();
const allVessels = [];
const allVessels: VesselQueryTarget[] = [];
ships.forEach(ship => {
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
if (result.canQuery) allVessels.push(...result.vessels);
@ -213,28 +227,28 @@ export default function ShipContextMenu() {
style={{ left: adjustedX, top: adjustedY }}
>
<div className="ship-context-menu__header">{title}</div>
{visibleMenuItems.map((item, index) => (
{visibleMenuItems.map((_item, _index) => (
<div
key={item.key}
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
onClick={() => handleAction(item.key)}
onMouseEnter={() => setHoveredItem(item.key)}
key={_item.key}
className={`ship-context-menu__item ${_item.hasSubmenu ? 'has-submenu' : ''}`}
onClick={() => handleAction(_item.key)}
onMouseEnter={() => setHoveredItem(_item.key)}
onMouseLeave={() => setHoveredItem(null)}
>
{item.label}
{item.hasSubmenu && <span className="submenu-arrow"></span>}
{_item.label}
{_item.hasSubmenu && <span className="submenu-arrow">&#9654;</span>}
{/* 반경설정 서브메뉴 */}
{item.key === 'radius' && hoveredItem === 'radius' && (
{_item.key === 'radius' && hoveredItem === 'radius' && (
<div
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
style={{ top: 0 }}
>
{RADIUS_OPTIONS.map((radius) => (
{RADIUS_OPTIONS.map((radius: number) => (
<div
key={radius}
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
handleRadiusSelect(radius);
}}

파일 보기

@ -7,8 +7,10 @@
* - ( )
* - ( ) 140px
*/
import { useRef, useState, useCallback, useEffect } from 'react';
import React, { useRef, useState, useCallback, useEffect } from 'react';
import useShipStore from '../../stores/shipStore';
import type { DetailModal } from '../../stores/shipStore';
import type { ShipFeature } from '../../types/ship';
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
import {
fetchVesselTracksV2,
@ -39,7 +41,7 @@ import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg';
import './ShipDetailModal.scss';
/** 선종코드 → 아이콘 매핑 */
const SHIP_KIND_ICONS = {
const SHIP_KIND_ICONS: Record<string, string> = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passengerIcon,
@ -49,7 +51,7 @@ const SHIP_KIND_ICONS = {
};
/** 선종 아이콘 URL 반환 */
function getShipKindIcon(signalKindCode) {
function getShipKindIcon(signalKindCode: string): string {
return SHIP_KIND_ICONS[signalKindCode] || etcIcon;
}
@ -57,10 +59,8 @@ function getShipKindIcon(signalKindCode) {
* URL ( API)
* 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag()
* Vite API
* @param {string} nationalCode - MID (: '440', '412')
* @returns {string} URL
*/
function getNationalFlagUrl(nationalCode) {
function getNationalFlagUrl(nationalCode: string | undefined): string | null {
if (!nationalCode) return null;
// 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달)
return `/ship/image/small/${nationalCode}.svg`;
@ -69,10 +69,8 @@ function getNationalFlagUrl(nationalCode) {
/**
* receivedTime YYYY-MM-DD HH:mm:ss
* : '20241123112300' '2024-11-23 11:23:00' '2024-11-23T11:23:00'
* @param {string} raw
* @returns {string}
*/
function formatDateTime(raw) {
function formatDateTime(raw: string | undefined): string {
if (!raw) return '-';
// 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환
@ -97,10 +95,21 @@ function formatDateTime(raw) {
return raw;
}
/** 시간 범위 */
interface TimeRange {
fromDate: string;
toDate: string;
}
/** SignalFlags Props */
interface SignalFlagsProps {
ship: ShipFeature;
}
/**
* AVETDR
*/
function SignalFlags({ ship }) {
function SignalFlags({ ship }: SignalFlagsProps) {
const isIntegrate = useShipStore((s) => s.isIntegrate);
// 통합선박 판별: 언더스코어 또는 integrate 플래그
const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
@ -113,7 +122,7 @@ function SignalFlags({ ship }) {
let isVisible = false;
if (useIntegratedMode) {
const val = ship[config.dataKey];
const val = ship[config.dataKey] as string | undefined;
if (val === '1') { isVisible = true; isActive = true; }
else if (val === '0') { isVisible = true; }
} else {
@ -139,11 +148,16 @@ function SignalFlags({ ship }) {
);
}
/** ShipGallery Props */
interface ShipGalleryProps {
imageUrlList: string[] | undefined;
}
/**
*
* (default-ship.png)
*/
function ShipGallery({ imageUrlList }) {
function ShipGallery({ imageUrlList }: ShipGalleryProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const hasImages = imageUrlList && imageUrlList.length > 0;
const images = hasImages ? imageUrlList : [defaultShipImg];
@ -158,7 +172,7 @@ function ShipGallery({ imageUrlList }) {
setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1));
}, [total]);
const handleIndicatorClick = useCallback((index) => {
const handleIndicatorClick = useCallback((index: number) => {
setCurrentIndex(index);
}, []);
@ -179,7 +193,7 @@ function ShipGallery({ imageUrlList }) {
className="galleryImg"
src={images[currentIndex]}
alt="선박 이미지"
onError={(e) => { e.target.src = defaultShipImg; }}
onError={(e) => { (e.target as HTMLImageElement).src = defaultShipImg; }}
/>
</div>
{canSlide && (
@ -199,11 +213,15 @@ function ShipGallery({ imageUrlList }) {
);
}
/** ShipDetailModal Props */
interface ShipDetailModalProps {
modal: DetailModal;
}
/**
*
* @param {Object} props.modal - { ship, id, initialPos }
*/
export default function ShipDetailModal({ modal }) {
export default function ShipDetailModal({ modal }: ShipDetailModalProps) {
const closeDetailModal = useShipStore((s) => s.closeDetailModal);
const updateModalPos = useShipStore((s) => s.updateModalPos);
const isIntegrateMode = useShipStore((s) => s.isIntegrate);
@ -211,11 +229,11 @@ export default function ShipDetailModal({ modal }) {
// 항적조회 패널 상태
const [showTrackPanel, setShowTrackPanel] = useState(false);
const [isQuerying, setIsQuerying] = useState(false);
const [timeRange, setTimeRange] = useState(() => {
const [timeRange, setTimeRange] = useState<TimeRange>(() => {
const now = new Date();
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전
const pad = (n) => String(n).padStart(2, '0');
const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const pad = (n: number) => String(n).padStart(2, '0');
const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
return { fromDate: toLocal(from), toDate: toLocal(now) };
});
@ -226,7 +244,7 @@ export default function ShipDetailModal({ modal }) {
const dragStart = useRef({ x: 0, y: 0 });
// 드래그 핸들러
const handleMouseDown = useCallback((e) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - position.x,
@ -236,7 +254,7 @@ export default function ShipDetailModal({ modal }) {
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const newPos = {
x: e.clientX - dragStart.current.x,
@ -263,13 +281,13 @@ export default function ShipDetailModal({ modal }) {
}, [modal.id, updateModalPos]);
// KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음)
const toKstISOString = useCallback((date) => {
const pad = (n, len = 2) => String(n).padStart(len, '0');
const toKstISOString = useCallback((date: Date): string => {
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}, []);
// 항적 조회 실행 (공용)
const executeTrackQuery = useCallback(async (fromDate, toDate) => {
const executeTrackQuery = useCallback(async (fromDate: string | Date, toDate: string | Date) => {
const { ship } = modal;
const startTime = new Date(fromDate);
const endTime = new Date(toDate);
@ -277,7 +295,6 @@ export default function ShipDetailModal({ modal }) {
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return;
if (startTime >= endTime) return;
const isIntegrated = isIntegratedTargetId(ship.targetId);
// 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회
// isIntegration API 파라미터는 항상 '0' (개별 항적 반환)
const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode);
@ -320,8 +337,8 @@ export default function ShipDetailModal({ modal }) {
// 즉시 3일 항적 조회
const now = new Date();
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const pad = (n) => String(n).padStart(2, '0');
const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const pad = (n: number) => String(n).padStart(2, '0');
const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const newTimeRange = { fromDate: toLocal(from), toDate: toLocal(now) };
setTimeRange(newTimeRange);
@ -366,9 +383,9 @@ export default function ShipDetailModal({ modal }) {
{ship.nationalCode && (
<span className="countryFlag">
<img
src={getNationalFlagUrl(ship.nationalCode)}
src={getNationalFlagUrl(ship.nationalCode) || ''}
alt="국기"
onError={(e) => { e.target.style.display = 'none'; }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</span>
)}
@ -384,7 +401,7 @@ export default function ShipDetailModal({ modal }) {
</div>
{/* gallery */}
<ShipGallery imageUrlList={ship.imageUrlList} />
<ShipGallery imageUrlList={ship.imageUrlList as string[] | undefined} />
{/* body */}
<div className="pmBody">

파일 보기

@ -3,15 +3,21 @@
* - /
* - DisplayComponent의
*/
import { useState, memo, useCallback } from 'react';
import { useState, memo, useCallback, ReactNode } from 'react';
import useShipStore from '../../stores/shipStore';
import { SHIP_KIND_LIST } from '../../types/constants';
interface SwitchGroupProps {
title: string;
children: ReactNode;
defaultOpen?: boolean;
}
/**
* +
*/
const SwitchGroup = memo(({ title, children, defaultOpen = true }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const SwitchGroup = memo(function SwitchGroup({ title, children, defaultOpen = true }: SwitchGroupProps) {
const [isOpen, setIsOpen] = useState<boolean>(defaultOpen);
return (
<div className="switchGroup">
@ -31,45 +37,66 @@ const SwitchGroup = memo(({ title, children, defaultOpen = true }) => {
);
});
interface ToggleSwitchProps {
label: string;
checked: boolean;
onChange: () => void;
}
/**
* (CSS common.css의 .switch )
*/
const ToggleSwitch = memo(({ label, checked, onChange }) => (
<li>
<span>{label}</span>
<label className="switch">
<input type="checkbox" checked={checked} onChange={onChange} />
<span className="slider" />
</label>
</li>
));
const ToggleSwitch = memo(function ToggleSwitch({ label, checked, onChange }: ToggleSwitchProps) {
return (
<li>
<span>{label}</span>
<label className="switch">
<input type="checkbox" checked={checked} onChange={onChange} />
<span className="slider" />
</label>
</li>
);
});
interface AllToggleProps {
label: string;
allChecked: boolean;
onToggleAll: () => void;
}
/**
* ON/OFF
*/
const AllToggle = memo(({ label, allChecked, onToggleAll }) => (
<li>
<span style={{ fontWeight: 'bold' }}>{label}</span>
<label className="switch">
<input type="checkbox" checked={allChecked} onChange={onToggleAll} />
<span className="slider" />
</label>
</li>
));
const AllToggle = memo(function AllToggle({ label, allChecked, onToggleAll }: AllToggleProps) {
return (
<li>
<span style={{ fontWeight: 'bold' }}>{label}</span>
<label className="switch">
<input type="checkbox" checked={allChecked} onChange={onToggleAll} />
<span className="slider" />
</label>
</li>
);
});
interface ShipFilterPanelProps {
isOpen: boolean;
onToggle: () => void;
}
/**
*
*/
export default function ShipFilterPanel({ isOpen, onToggle }) {
const kindVisibility = useShipStore((s) => s.kindVisibility);
const kindCounts = useShipStore((s) => s.kindCounts);
const toggleKindVisibility = useShipStore((s) => s.toggleKindVisibility);
export default function ShipFilterPanel({ isOpen, onToggle }: ShipFilterPanelProps) {
const kindVisibility = useShipStore((s: { kindVisibility: Record<string, boolean> }) => s.kindVisibility);
const kindCounts = useShipStore((s: { kindCounts: Record<string, number> }) => s.kindCounts);
const toggleKindVisibility = useShipStore((s: { toggleKindVisibility: (code: string) => void }) => s.toggleKindVisibility);
// 선종 전체 토글
const allKindVisible = Object.values(kindVisibility).every(Boolean);
const handleToggleAllKind = useCallback(() => {
const nextValue = !allKindVisible;
SHIP_KIND_LIST.forEach(({ code }) => {
SHIP_KIND_LIST.forEach(({ code }: { code: string }) => {
if (kindVisibility[code] !== nextValue) {
toggleKindVisibility(code);
}
@ -77,7 +104,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) {
}, [allKindVisible, kindVisibility, toggleKindVisibility]);
// 전체 선박 수
const totalCount = Object.values(kindCounts).reduce((sum, v) => sum + v, 0);
const totalCount = Object.values(kindCounts).reduce((sum: number, v: number) => sum + v, 0);
return (
<div className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
@ -103,7 +130,7 @@ export default function ShipFilterPanel({ isOpen, onToggle }) {
allChecked={allKindVisible}
onToggleAll={handleToggleAllKind}
/>
{SHIP_KIND_LIST.map(({ code, label }) => (
{SHIP_KIND_LIST.map(({ code, label }: { code: string; label: string }) => (
<ToggleSwitch
key={code}
label={`${label} (${(kindCounts[code] || 0).toLocaleString()})`}

파일 보기

@ -32,9 +32,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
/**
*
* ->
*/
const SHIP_KIND_ICONS = {
const SHIP_KIND_ICONS: Record<string, string> = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
@ -45,10 +45,15 @@ const SHIP_KIND_ICONS = {
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
};
interface LegendItemConfig {
code: string;
label: string;
}
/**
*
*/
const LEGEND_ITEMS = [
const LEGEND_ITEMS: LegendItemConfig[] = [
{ code: SIGNAL_KIND_CODE_FISHING, label: '어선' },
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
@ -59,10 +64,19 @@ const LEGEND_ITEMS = [
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
];
interface LegendItemProps {
code: string;
label: string;
count: number;
icon: string;
isVisible: boolean;
onToggle: (code: string) => void;
}
/**
*
*/
const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
const LegendItem = memo(function LegendItem({ code, label, count, icon, isVisible, onToggle }: LegendItemProps) {
// 부이는 회전하지 않음
const isBuoy = code === SIGNAL_KIND_CODE_BUOY;
@ -83,15 +97,31 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
);
});
interface AreaSearchCounts {
counts: Record<string, number>;
total: number;
}
interface AreaSearchTrack {
vesselId: string;
shipKindCode: string;
}
/**
*
*/
const ShipLegend = memo(() => {
const ShipLegend = memo(function ShipLegend() {
// 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더
// useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지
// useShipStore() 전체 구독 -> featuresVersion 변경마다 리렌더되는 문제 방지
const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } =
useShipStore(
(state) => ({
(state: {
kindCounts: Record<string, number>;
kindVisibility: Record<string, boolean>;
isShipVisible: boolean;
totalCount: number;
isConnected: boolean;
}) => ({
kindCounts: state.kindCounts,
kindVisibility: state.kindVisibility,
isShipVisible: state.isShipVisible,
@ -100,19 +130,19 @@ const ShipLegend = memo(() => {
}),
shallow
);
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
const toggleKindVisibility = useShipStore((state: { toggleKindVisibility: (code: string) => void }) => state.toggleKindVisibility);
const toggleShipVisible = useShipStore((state: { toggleShipVisible: () => void }) => state.toggleShipVisible);
// 항적분석 활성 시 결과 카운트
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
const areaSearchTracks = useAreaSearchStore((s) => s.tracks);
const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds);
const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode);
const areaSearchCompleted = useAreaSearchStore((s: { queryCompleted: boolean }) => s.queryCompleted);
const areaSearchTracks = useAreaSearchStore((s: { tracks: AreaSearchTrack[] }) => s.tracks);
const areaSearchDisabledIds = useAreaSearchStore((s: { disabledVesselIds: Set<string> }) => s.disabledVesselIds);
const areaSearchKindFilter = useAreaSearchStore((s: { shipKindCodeFilter: Set<string> }) => s.shipKindCodeFilter);
const toggleAreaSearchKind = useAreaSearchStore((s: { toggleShipKindCode: (code: string) => void }) => s.toggleShipKindCode);
const areaSearchCounts = useMemo(() => {
const areaSearchCounts = useMemo((): AreaSearchCounts | null => {
if (!areaSearchCompleted || areaSearchTracks.length === 0) return null;
const counts = {};
const counts: Record<string, number> = {};
let total = 0;
areaSearchTracks.forEach((track) => {
if (areaSearchDisabledIds.has(track.vesselId)) return;

파일 보기

@ -8,10 +8,24 @@ import './ShipTooltip.scss';
const OFFSET_X = 12;
const OFFSET_Y = -40;
export default function ShipTooltip({ ship, x, y }) {
interface ShipData {
signalKindCode?: string;
sog?: number | string;
cog?: number | string;
shipName?: string;
targetId?: string;
}
interface ShipTooltipProps {
ship: ShipData | null;
x: number;
y: number;
}
export default function ShipTooltip({ ship, x, y }: ShipTooltipProps) {
if (!ship) return null;
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타';
const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode as string] || '기타';
const sog = Number(ship.sog) || 0;
const cog = Number(ship.cog) || 0;
const isMoving = sog > SPEED_THRESHOLD;

파일 보기

@ -11,12 +11,21 @@
*
* / (Phase 2 )
*/
import { useRef, useState, useCallback, useEffect } from 'react';
import React, { useRef, useState, useCallback, useEffect } from 'react';
import useTrackStore, { getShipKindTrackColor } from '../../stores/trackStore';
import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore';
import type { ShipFeature } from '../../types/ship';
import { SHIP_KIND_LABELS, SIGNAL_FLAG_CONFIGS, SIGNAL_SOURCE_LABELS, TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../../types/constants';
import { showToast } from '../common/Toast';
import './TrackQueryModal.scss';
/** TrackModal 인터페이스 (trackStore 내부 정의와 동일) */
interface TrackModal {
ships: ShipFeature[];
id: string;
isIntegrated: boolean;
}
/** 기본 조회 기간 (일) */
const DEFAULT_QUERY_DAYS = TRACK_QUERY_DEFAULT_DAYS;
@ -27,31 +36,36 @@ const MAX_QUERY_DAYS = TRACK_QUERY_MAX_DAYS;
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
/** datetime-local 입력용 포맷 */
function toDateTimeLocal(date) {
const pad = (n) => String(n).padStart(2, '0');
function toDateTimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** MM-DD HH:mm 형식 */
function formatShortDateTime(ms) {
function formatShortDateTime(ms: number): string {
if (!ms) return '--/-- --:--';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** YYYY-MM-DD HH:mm:ss 형식 */
function formatDateTime(ms) {
function formatDateTime(ms: number): string {
if (!ms) return '-';
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, '0');
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** TrackQueryModal Props */
interface TrackQueryModalProps {
modal: TrackModal;
}
/**
*
*/
export default function TrackQueryModal({ modal }) {
export default function TrackQueryModal({ modal }: TrackQueryModalProps) {
const closeTrackModal = useTrackStore((s) => s.closeTrackModal);
// 스토어 상태 구독
@ -73,24 +87,24 @@ export default function TrackQueryModal({ modal }) {
const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date()));
// 시작일 변경 핸들러
const handleStartChange = useCallback((e) => {
const handleStartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setStartInput(e.target.value);
}, []);
// 종료일 변경 핸들러
const handleEndChange = useCallback((e) => {
const handleEndChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setEndInput(e.target.value);
}, []);
// 조회 기간 검증 및 자동 조정 (blur 시 실행)
const validateAndAdjustDates = useCallback((changedField) => {
const validateAndAdjustDates = useCallback((changedField: 'start' | 'end') => {
const startDate = new Date(startInput);
const endDate = new Date(endInput);
// 유효하지 않은 날짜면 무시
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
const diffDays = (endDate - startDate) / DAYS_TO_MS;
const diffDays = (endDate.getTime() - startDate.getTime()) / DAYS_TO_MS;
if (changedField === 'start') {
// 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정
@ -151,7 +165,7 @@ export default function TrackQueryModal({ modal }) {
: 0;
// 드래그 핸들러
const handleDragStart = useCallback((e) => {
const handleDragStart = useCallback((e: React.MouseEvent) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - position.x,
@ -161,7 +175,7 @@ export default function TrackQueryModal({ modal }) {
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
setPosition({
x: e.clientX - dragStart.current.x,
@ -197,7 +211,7 @@ export default function TrackQueryModal({ modal }) {
}, [startInput, endInput, modal.ships]);
// 프로그레스 바 클릭
const handleProgressClick = useCallback((e) => {
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
useTrackStore.getState().setProgressByRatio(ratio);
@ -392,18 +406,23 @@ export default function TrackQueryModal({ modal }) {
);
}
/** EquipmentFilter Props */
interface EquipmentFilterProps {
ship: ShipFeature;
}
/**
* ( AVETDR)
* 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts
*/
function EquipmentFilter({ ship }) {
function EquipmentFilter({ ship }: EquipmentFilterProps) {
const tracks = useTrackStore((s) => s.tracks);
const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds);
// 항적 데이터에 존재하는 장비만 표시
const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd));
const handleToggle = useCallback((sigSrcCd) => {
const handleToggle = useCallback((sigSrcCd: string) => {
useTrackStore.getState().toggleEquipment(sigSrcCd);
}, []);
@ -461,10 +480,15 @@ function EquipmentFilter({ ship }) {
);
}
/** VesselItem Props */
interface VesselItemProps {
track: ProcessedTrack;
}
/**
*
*/
function VesselItem({ track }) {
function VesselItem({ track }: VesselItemProps) {
const handleToggle = useCallback(() => {
useTrackStore.getState().toggleVesselEnabled(track.vesselId);
}, [track.vesselId]);

파일 보기

@ -5,6 +5,6 @@
* 비활성화: 내부망 API(/api/gis) ()
* TODO: 외부 API
*/
export default function useFavoriteData() {
export default function useFavoriteData(): void {
// noop — 내부망 /api/gis 의존 제거
}

파일 보기

@ -12,6 +12,44 @@ import useTrackingModeStore, {
NM_TO_METERS,
} from '../stores/trackingModeStore';
/** 위치 좌표를 가진 선박 (최소 인터페이스) */
interface ShipWithCoords {
longitude: number;
latitude: number;
}
/** 반경 중심 좌표 */
interface RadiusCenter {
lon: number;
lat: number;
}
/** Bounding Box */
interface BoundingBox {
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
}
/** useRadiusFilter 반환 타입 */
interface UseRadiusFilterReturn {
filterByRadius: <T extends ShipWithCoords>(ships: T[]) => T[];
filterFeaturesMapByRadius: <T extends ShipWithCoords>(featuresMap: Map<string, T>) => Map<string, T>;
isShipInRadius: (ship: ShipWithCoords) => boolean;
isRadiusFilterActive: boolean;
radiusCenter: RadiusCenter | null;
radiusNM: number;
boundingBox: BoundingBox | null;
}
/** 반경 필터 상태 (비훅 버전) */
interface RadiusFilterState {
isActive: boolean;
center: RadiusCenter | null;
radiusNM: number;
}
/**
* 1 ( )
* (35) 91km
@ -21,9 +59,9 @@ const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km
/**
*
* @returns {Object} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM }
* @returns {UseRadiusFilterReturn} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM }
*/
export default function useRadiusFilter() {
export default function useRadiusFilter(): UseRadiusFilterReturn {
const mode = useTrackingModeStore((s) => s.mode);
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
@ -32,7 +70,7 @@ export default function useRadiusFilter() {
const isRadiusFilterActive = mode === 'ship' && trackedShip !== null;
// 반경 중심 좌표
const radiusCenter = useMemo(() => {
const radiusCenter = useMemo((): RadiusCenter | null => {
if (!isRadiusFilterActive || !trackedShip) return null;
return {
lon: trackedShip.longitude,
@ -44,7 +82,7 @@ export default function useRadiusFilter() {
* Bounding Box ( )
*
*/
const boundingBox = useMemo(() => {
const boundingBox = useMemo((): BoundingBox | null => {
if (!radiusCenter) return null;
const radiusMeters = radiusNM * NM_TO_METERS;
@ -62,7 +100,7 @@ export default function useRadiusFilter() {
/**
* Bounding Box
*/
const isInBoundingBox = useCallback((ship) => {
const isInBoundingBox = useCallback((ship: ShipWithCoords): boolean => {
if (!boundingBox) return true;
if (!ship.longitude || !ship.latitude) return false;
@ -79,7 +117,7 @@ export default function useRadiusFilter() {
* @param {Array} ships -
* @returns {Array}
*/
const filterByRadius = useCallback((ships) => {
const filterByRadius = useCallback(<T extends ShipWithCoords>(ships: T[]): T[] => {
// 반경 필터 비활성화 시 전체 반환
if (!isRadiusFilterActive || !radiusCenter) {
return ships;
@ -99,7 +137,7 @@ export default function useRadiusFilter() {
* @param {Object} ship
* @returns {boolean}
*/
const isShipInRadius = useCallback((ship) => {
const isShipInRadius = useCallback((ship: ShipWithCoords): boolean => {
if (!isRadiusFilterActive || !radiusCenter) return true;
if (!isInBoundingBox(ship)) return false;
return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM);
@ -110,12 +148,12 @@ export default function useRadiusFilter() {
* @param {Map} featuresMap - featureId -> ship Map
* @returns {Map}
*/
const filterFeaturesMapByRadius = useCallback((featuresMap) => {
const filterFeaturesMapByRadius = useCallback(<T extends ShipWithCoords>(featuresMap: Map<string, T>): Map<string, T> => {
if (!isRadiusFilterActive || !radiusCenter) {
return featuresMap;
}
const filteredMap = new Map();
const filteredMap = new Map<string, T>();
featuresMap.forEach((ship, featureId) => {
if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
filteredMap.set(featureId, ship);
@ -140,7 +178,7 @@ export default function useRadiusFilter() {
* ( )
* shipStore나
*/
export function getRadiusFilterState() {
export function getRadiusFilterState(): RadiusFilterState {
const state = useTrackingModeStore.getState();
const { mode, trackedShip, radiusNM } = state;
@ -160,7 +198,7 @@ export function getRadiusFilterState() {
/**
* ( )
*/
export function checkShipInRadius(ship) {
export function checkShipInRadius(ship: ShipWithCoords): boolean {
const { isActive, center, radiusNM } = getRadiusFilterState();
if (!isActive || !center) return true;
return isWithinRadius(ship, center.lon, center.lat, radiusNM);

파일 보기

@ -3,18 +3,34 @@ import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
import Polygon from 'ol/geom/Polygon';
import type { Geometry } from 'ol/geom';
import { Style, Fill, Stroke, Text } from 'ol/style';
import useFavoriteStore from '../stores/favoriteStore';
import { useMapStore } from '../stores/mapStore';
/** 관심구역 데이터 (API 응답 형태) */
interface RealmData {
coordinates: number[] | number[][] | number[][][];
seaRelmNameYn?: string;
seaRelmName?: string;
fontColor?: string;
fontSize?: number;
fontKind?: string;
outlineColor?: string;
outlineWidth?: number | string;
outlineType?: string;
fillColor?: string;
seaRelmId?: string | number;
}
/**
* OpenLayers
* 참조: mda-react-front/src/services/commonService.ts - getRealmLayer()
*/
export default function useRealmLayer() {
export default function useRealmLayer(): void {
const map = useMapStore((s) => s.map);
const layerRef = useRef(null);
const sourceRef = useRef(null);
const layerRef = useRef<VectorLayer<Feature<Geometry>> | null>(null);
const sourceRef = useRef<VectorSource | null>(null);
useEffect(() => {
if (!map) return;
@ -34,7 +50,7 @@ export default function useRealmLayer() {
console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`);
layer.setVisible(isRealmVisible);
if (realmList.length > 0) {
renderRealms(source, realmList);
renderRealms(source, realmList as unknown as RealmData[]);
}
// realmList 변경 구독
@ -45,14 +61,14 @@ export default function useRealmLayer() {
if (newRealmList.length > 0) {
console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300));
}
renderRealms(source, newRealmList);
renderRealms(source, newRealmList as unknown as RealmData[]);
}
);
// isRealmVisible 변경 구독
const unsubVisible = useFavoriteStore.subscribe(
(state) => state.isRealmVisible,
(isVisible) => {
(isVisible: boolean) => {
console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`);
if (layerRef.current) {
layerRef.current.setVisible(isVisible);
@ -78,7 +94,8 @@ export default function useRealmLayer() {
* @param {Array} coordinates -
* @returns {Array} Polygon rings [[lon,lat], ...]
*/
function normalizeCoordinates(coordinates) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function normalizeCoordinates(coordinates: any): number[][] | null {
if (!Array.isArray(coordinates) || coordinates.length === 0) return null;
// coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...]
@ -99,7 +116,7 @@ function normalizeCoordinates(coordinates) {
* @param {VectorSource} source - OL VectorSource
* @param {Array} realmList -
*/
function renderRealms(source, realmList) {
function renderRealms(source: VectorSource, realmList: RealmData[]): void {
source.clear();
if (!realmList || realmList.length === 0) return;

파일 보기

@ -16,18 +16,31 @@ const INITIAL_LOAD_MINUTES = 60;
/** 증분 로드 기간 (분) */
const INCREMENT_MINUTES = 2; // 약간의 중복 허용으로 누락 방지
/** useShipData 옵션 */
interface UseShipDataOptions {
autoConnect?: boolean;
}
/** useShipData 반환 타입 */
interface UseShipDataReturn {
isConnected: boolean;
isLoading: boolean;
connect: () => Promise<void>;
disconnect: () => void;
}
/**
*
* @param {Object} options -
* @param {UseShipDataOptions} options -
* @param {boolean} options.autoConnect - (기본값: true)
* @returns {Object} { isConnected, isLoading, connect, disconnect }
* @returns {UseShipDataReturn} { isConnected, isLoading, connect, disconnect }
*/
export default function useShipData(options = {}) {
export default function useShipData(options: UseShipDataOptions = {}): UseShipDataReturn {
const { autoConnect = true } = options;
const pollingRef = useRef(null);
const initialLoadDoneRef = useRef(false);
const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const initialLoadDoneRef = useRef<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const mergeFeatures = useShipStore((s) => s.mergeFeatures);
const setConnected = useShipStore((s) => s.setConnected);
const isConnected = useShipStore((s) => s.isConnected);
@ -35,7 +48,7 @@ export default function useShipData(options = {}) {
/**
* AIS shipStore feature
*/
const loadAndMerge = useCallback(async (minutes) => {
const loadAndMerge = useCallback(async (minutes: number): Promise<number> => {
try {
const aisTargets = await searchAisTargets(minutes);
if (aisTargets.length > 0) {
@ -53,7 +66,7 @@ export default function useShipData(options = {}) {
/**
*
*/
const startPolling = useCallback(() => {
const startPolling = useCallback((): void => {
if (pollingRef.current) return;
pollingRef.current = setInterval(() => {
@ -66,7 +79,7 @@ export default function useShipData(options = {}) {
/**
*
*/
const stopPolling = useCallback(() => {
const stopPolling = useCallback((): void => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
@ -77,7 +90,7 @@ export default function useShipData(options = {}) {
/**
* ( + )
*/
const connect = useCallback(async () => {
const connect = useCallback(async (): Promise<void> => {
if (initialLoadDoneRef.current) {
startPolling();
setConnected(true);
@ -102,7 +115,7 @@ export default function useShipData(options = {}) {
/**
*
*/
const disconnect = useCallback(() => {
const disconnect = useCallback((): void => {
stopPolling();
setConnected(false);
}, [stopPolling, setConnected]);

파일 보기

@ -10,6 +10,7 @@
import { useEffect, useRef, useCallback } from 'react';
import { Deck } from '@deck.gl/core';
import { toLonLat } from 'ol/proj';
import type Map from 'ol/Map';
import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer';
import useShipStore from '../stores/shipStore';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
@ -22,27 +23,42 @@ import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
import useFavoriteStore from '../stores/favoriteStore';
/** 뷰포트 바운드 */
interface ViewportBounds {
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
}
/** useShipLayer 반환 타입 */
interface UseShipLayerReturn {
deckCanvas: HTMLCanvasElement | null;
deckRef: React.MutableRefObject<Deck | null>;
}
/**
*
* @param {Object} map - OpenLayers
* @returns {Object} { deckCanvas }
* @param {Map | null} map - OpenLayers
* @returns {UseShipLayerReturn} { deckCanvas }
*/
export default function useShipLayer(map) {
const deckRef = useRef(null);
const canvasRef = useRef(null);
const animationFrameRef = useRef(null);
const batchRendererInitialized = useRef(false);
export default function useShipLayer(map: Map | null): UseShipLayerReturn {
const deckRef = useRef<Deck | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationFrameRef = useRef<number | null>(null);
const batchRendererInitialized = useRef<boolean>(false);
const getSelectedShips = useShipStore((s) => s.getSelectedShips);
const isShipVisible = useShipStore((s) => s.isShipVisible);
// 마지막 선박 레이어: 캐시용
const lastShipLayersRef = useRef([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lastShipLayersRef = useRef<any[]>([]);
/**
* Deck.gl
*/
const initDeck = useCallback((container) => {
const initDeck = useCallback((container: HTMLElement): void => {
if (deckRef.current) return;
const canvas = document.createElement('canvas');
@ -63,7 +79,7 @@ export default function useShipLayer(map) {
layers: [],
useDevicePixels: true,
pickingRadius: 20,
onError: (error) => {
onError: (error: Error) => {
console.error('[Deck.gl] Error:', error);
},
});
@ -72,7 +88,7 @@ export default function useShipLayer(map) {
/**
* Deck.gl viewState를 OpenLayers
*/
const syncViewState = useCallback(() => {
const syncViewState = useCallback((): void => {
if (!map || !deckRef.current) return;
const view = map.getView();
@ -98,7 +114,7 @@ export default function useShipLayer(map) {
/**
*
*/
const getViewportBounds = useCallback(() => {
const getViewportBounds = useCallback((): ViewportBounds | null => {
if (!map) return null;
const view = map.getView();
@ -116,7 +132,8 @@ export default function useShipLayer(map) {
/**
* - +
*/
const handleBatchRender = useCallback((ships, trigger) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleBatchRender = useCallback((ships: any[], trigger: number): void => {
if (!deckRef.current || !map) return;
const view = map.getView();
@ -157,7 +174,7 @@ export default function useShipLayer(map) {
/**
* ( )
*/
const updateLayers = useCallback(() => {
const updateLayers = useCallback((): void => {
if (!deckRef.current || !map) return;
if (!isShipVisible) {
@ -184,7 +201,7 @@ export default function useShipLayer(map) {
/**
*
*/
const render = useCallback(() => {
const render = useCallback((): void => {
syncViewState();
updateLayers();
deckRef.current?.redraw();
@ -202,8 +219,8 @@ export default function useShipLayer(map) {
batchRendererInitialized.current = true;
}
const handleMoveEnd = () => { render(); };
const handlePostRender = () => {
const handleMoveEnd = (): void => { render(); };
const handlePostRender = (): void => {
syncViewState();
deckRef.current?.redraw();
};
@ -240,7 +257,8 @@ export default function useShipLayer(map) {
useEffect(() => {
const unsubscribe = useShipStore.subscribe(
(state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds],
(current, prev) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(current: any[], prev: any[]) => {
const filterChanged =
current[1] !== prev[1] ||
current[2] !== prev[2] ||
@ -261,7 +279,8 @@ export default function useShipLayer(map) {
updateLayers();
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) }
);
return () => { unsubscribe(); };
@ -278,7 +297,8 @@ export default function useShipLayer(map) {
shipBatchRenderer.requestRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) }
);
return () => unsubscribe();
@ -297,7 +317,8 @@ export default function useShipLayer(map) {
shipBatchRenderer.immediateRender();
}
},
{ equalityFn: (a, b) => a.every((v, i) => v === b[i]) }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) }
);
return () => unsubscribe();
@ -331,7 +352,8 @@ export default function useShipLayer(map) {
shipBatchRenderer.immediateRender();
}
},
{ equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ equalityFn: (a: any[], b: any[]) => a[0] === b[0] && a[1] === b[1] }
);
return () => unsubscribe();

파일 보기

@ -19,6 +19,7 @@ import { fromLonLat } from 'ol/proj';
import useShipStore from '../stores/shipStore';
import { useMapStore } from '../stores/mapStore';
import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore';
import type { ShipFeature } from '../types/ship';
// 레이더 신호원 코드
const SIGNAL_SOURCE_CODE_RADAR = '000005';
@ -32,12 +33,47 @@ const DEBOUNCE_MS = 200;
// 한글 정규식
const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/;
/** 검색 결과 항목 */
export interface SearchResult {
featureId: string;
targetId: string;
originalTargetId: string;
shipName: string;
signalSourceCode: string;
longitude: number;
latitude: number;
ship: ShipFeature;
}
/** 반경 바운딩 박스 */
interface RadiusBoundingBox {
minLon: number;
maxLon: number;
minLat: number;
maxLat: number;
}
/** 반경 중심 좌표 */
interface RadiusCenter {
lon: number;
lat: number;
}
/** useShipSearch 반환 타입 */
interface UseShipSearchReturn {
searchValue: string;
setSearchValue: (keyword: string) => void;
results: SearchResult[];
handleClickResult: (result: SearchResult) => void;
handleSelectFirst: () => void;
clearSearch: () => void;
isIntegrate: boolean;
}
/**
*
* @param {string} text
* @returns {boolean}
*/
function containsKorean(text) {
function containsKorean(text: string): boolean {
return KOREAN_REGEX.test(text);
}
@ -46,14 +82,12 @@ function containsKorean(text) {
* -
* - (, , )
* -
* @param {string} text
* @returns {string}
*/
function normalizeSearchText(text) {
function normalizeSearchText(text: string): string {
if (!text) return '';
return text
.toLowerCase()
.replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '')
.replace(/[\s\-_.,:;!@#$%^&*()+=[\]{}|\\/<>?'"]/g, '')
.trim();
}
@ -61,11 +95,8 @@ function normalizeSearchText(text) {
*
* - 포함: 최소 2
* - /숫자만: 최소 3
* @param {string} originalText -
* @param {string} normalizedText -
* @returns {boolean}
*/
function meetsMinLength(originalText, normalizedText) {
function meetsMinLength(originalText: string, normalizedText: string): boolean {
if (!normalizedText) return false;
const hasKorean = containsKorean(originalText);
@ -76,11 +107,10 @@ function meetsMinLength(originalText, normalizedText) {
/**
*
* @returns {Object} { searchValue, setSearchValue, results, handleSearch, handleClickResult, clearSearch }
*/
export default function useShipSearch() {
export default function useShipSearch(): UseShipSearchReturn {
const [searchValue, setSearchValueState] = useState('');
const [results, setResults] = useState([]);
const [results, setResults] = useState<SearchResult[]>([]);
const map = useMapStore((s) => s.map);
const features = useShipStore((s) => s.features);
@ -95,7 +125,7 @@ export default function useShipSearch() {
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
// 디바운스 타이머 ref
const debounceTimerRef = useRef(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 컴포넌트 언마운트 시 타이머 정리
useEffect(() => {
@ -108,9 +138,8 @@ export default function useShipSearch() {
/**
* ( )
* @param {string} keyword -
*/
const executeSearch = useCallback((keyword) => {
const executeSearch = useCallback((keyword: string) => {
const normalizedKeyword = normalizeSearchText(keyword);
// 최소 길이 미달 시 결과 초기화
@ -121,8 +150,8 @@ export default function useShipSearch() {
// 반경 필터 상태 확인
const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null;
let radiusBoundingBox = null;
let radiusCenter = null;
let radiusBoundingBox: RadiusBoundingBox | null = null;
let radiusCenter: RadiusCenter | null = null;
if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) {
radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude };
@ -140,7 +169,7 @@ export default function useShipSearch() {
}
const hasKorean = containsKorean(keyword);
const matchedShips = [];
const matchedShips: SearchResult[] = [];
// Map을 배열로 변환하여 for...of로 조기 종료 가능하게
const featuresArray = Array.from(features.entries());
@ -226,9 +255,8 @@ export default function useShipSearch() {
/**
* ( )
* @param {string} keyword -
*/
const setSearchValue = useCallback((keyword) => {
const setSearchValue = useCallback((keyword: string) => {
setSearchValueState(keyword);
// 빈 입력 시 즉시 결과 초기화
@ -257,9 +285,8 @@ export default function useShipSearch() {
* - ()
* -
* -
* @param {Object} result -
*/
const handleClickResult = useCallback((result) => {
const handleClickResult = useCallback((result: SearchResult) => {
if (!map || !result) return;
const { featureId, longitude, latitude, ship } = result;

Some files were not shown because too many files have changed in this diff Show More