wing-ops/frontend/src/tabs/incidents/services/vesselService.ts
htlee f099ff29b1 refactor(frontend): 탭 단위 패키지 구조 전환 (tabs/)
- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:08:34 +09:00

327 lines
9.0 KiB
TypeScript
Executable File

import axios from 'axios'
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
const AIS_API_KEY = import.meta.env.VITE_AIS_API_KEY || ''
const AIS_BASE_URL = import.meta.env.VITE_AIS_API_URL || 'https://ais-api.spgi.kr/api/v1'
// 선박 위치 데이터 타입
export interface VesselPosition {
mmsi: string // Maritime Mobile Service Identity
imo?: string // International Maritime Organization number
shipName: string // 선박명
callSign?: string // 호출부호
shipType: number // 선박 유형 코드
shipTypeText?: string // 선박 유형 (한글)
latitude: number // 위도
longitude: number // 경도
speed: number // 속도 (노트)
course: number // 침로 (0-359도)
heading: number // 선수방위
navStatus: number // 항해 상태 코드
navStatusText?: string // 항해 상태 (한글)
timestamp: string // 데이터 수신 시각
destination?: string // 목적지
eta?: string // 예정 도착 시각
draught?: number // 흘수 (미터)
length?: number // 전장 (미터)
width?: number // 폭 (미터)
}
// 영역 내 선박 조회 파라미터
export interface BoundingBox {
minLat: number // 최소 위도
maxLat: number // 최대 위도
minLng: number // 최소 경도
maxLng: number // 최대 경도
}
// 선박 유형 코드 → 텍스트 변환
export function getShipTypeText(code: number): string {
const types: { [key: number]: string } = {
0: '미상',
30: '어선',
31: '예인선',
32: '예인선',
33: '준설선',
34: '잠수작업선',
35: '군사작전선',
36: '범선',
37: '레저선',
50: '도선선',
51: '구조선',
52: '예인선',
53: '항만선',
54: '오염방지선',
55: '법집행선',
60: '여객선',
61: '여객선',
62: '여객선',
63: '여객선',
64: '여객선',
65: '여객선',
66: '여객선',
67: '여객선',
68: '여객선',
69: '여객선',
70: '화물선',
71: '화물선',
72: '화물선',
73: '화물선',
74: '화물선',
75: '화물선',
76: '화물선',
77: '화물선',
78: '화물선',
79: '화물선',
80: '유조선',
81: '유조선',
82: '유조선',
83: '유조선',
84: '유조선',
85: '유조선',
86: '유조선',
87: '유조선',
88: '유조선',
89: '유조선',
90: '기타선박',
}
return types[code] || `선박(${code})`
}
// 항해 상태 코드 → 텍스트 변환
export function getNavStatusText(code: number): string {
const statuses: { [key: number]: string } = {
0: '기관 사용 항해중',
1: '정박중',
2: '조종 불능',
3: '조종 제한',
4: '흘수 제약',
5: '계류중',
6: '좌초',
7: '어로중',
8: '범주 항해중',
9: '예약',
10: '예약',
11: '예약',
12: '예약',
13: '예약',
14: '예약',
15: '미정의',
}
return statuses[code] || '알 수 없음'
}
// 1. 특정 선박 위치 조회 (MMSI 기반)
export async function getVesselByMMSI(mmsi: string): Promise<VesselPosition | null> {
try {
const response = await axios.get(`${AIS_BASE_URL}/vessel/${mmsi}`, {
headers: {
'Authorization': `Bearer ${AIS_API_KEY}`,
'Content-Type': 'application/json',
},
})
const data = response.data
return {
...data,
shipTypeText: getShipTypeText(data.shipType),
navStatusText: getNavStatusText(data.navStatus),
}
} catch (error) {
console.error(`선박 조회 오류 (MMSI: ${mmsi}):`, error)
return null
}
}
// 2. 영역 내 모든 선박 조회 (Bounding Box)
export async function getVesselsInArea(bbox: BoundingBox): Promise<VesselPosition[]> {
try {
const response = await axios.get(`${AIS_BASE_URL}/vessels/area`, {
headers: {
'Authorization': `Bearer ${AIS_API_KEY}`,
'Content-Type': 'application/json',
},
params: {
minLat: bbox.minLat,
maxLat: bbox.maxLat,
minLng: bbox.minLng,
maxLng: bbox.maxLng,
},
})
return response.data.map((vessel: Record<string, unknown>) => ({
...vessel,
shipTypeText: getShipTypeText(vessel.shipType as number),
navStatusText: getNavStatusText(vessel.navStatus as number),
}))
} catch (error) {
console.error('영역 내 선박 조회 오류:', error)
return []
}
}
// 3. 중심점 기반 반경 내 선박 조회
export async function getVesselsNearby(
lat: number,
lng: number,
radiusKm: number = 10
): Promise<VesselPosition[]> {
// 대략적인 BBox 계산 (1도 ≈ 111km)
const latDelta = radiusKm / 111
const lngDelta = radiusKm / (111 * Math.cos(lat * Math.PI / 180))
const bbox: BoundingBox = {
minLat: lat - latDelta,
maxLat: lat + latDelta,
minLng: lng - lngDelta,
maxLng: lng + lngDelta,
}
return getVesselsInArea(bbox)
}
// 4. 선박 이동 경로 조회 (Track)
export async function getVesselTrack(
mmsi: string,
startTime: string,
endTime: string
): Promise<VesselPosition[]> {
try {
const response = await axios.get(`${AIS_BASE_URL}/vessel/${mmsi}/track`, {
headers: {
'Authorization': `Bearer ${AIS_API_KEY}`,
'Content-Type': 'application/json',
},
params: {
start: startTime, // ISO 8601 format: '2025-02-10T00:00:00Z'
end: endTime,
},
})
return response.data.map((position: Record<string, unknown>) => ({
...position,
shipTypeText: getShipTypeText(position.shipType as number),
navStatusText: getNavStatusText(position.navStatus as number),
}))
} catch (error) {
console.error(`선박 경로 조회 오류 (MMSI: ${mmsi}):`, error)
return []
}
}
// 5. 선박 유형별 필터링
export async function getVesselsByType(
bbox: BoundingBox,
shipTypes: number[]
): Promise<VesselPosition[]> {
const allVessels = await getVesselsInArea(bbox)
return allVessels.filter(v => shipTypes.includes(v.shipType))
}
// 6. 위험 선박 필터링 (정박/좌초/조종불능)
export async function getDangerousVessels(bbox: BoundingBox): Promise<VesselPosition[]> {
const allVessels = await getVesselsInArea(bbox)
const dangerStatuses = [2, 3, 6] // 조종 불능, 조종 제한, 좌초
return allVessels.filter(v => dangerStatuses.includes(v.navStatus))
}
// 7. 유조선만 필터링 (유출 사고 대비)
export async function getTankers(bbox: BoundingBox): Promise<VesselPosition[]> {
const tankerTypes = [80, 81, 82, 83, 84, 85, 86, 87, 88, 89]
return getVesselsByType(bbox, tankerTypes)
}
// 8. 선박 속도로 필터링 (정박 중 판단)
export function filterBySpeed(
vessels: VesselPosition[],
minSpeed: number = 0,
maxSpeed: number = 100
): VesselPosition[] {
return vessels.filter(v => v.speed >= minSpeed && v.speed <= maxSpeed)
}
// 9. 거리 계산 (Haversine formula)
export function calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371 // 지구 반경 (km)
const dLat = (lat2 - lat1) * Math.PI / 180
const dLng = (lng2 - lng1) * Math.PI / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
// 10. 가장 가까운 선박 찾기
export function findNearestVessel(
targetLat: number,
targetLng: number,
vessels: VesselPosition[]
): VesselPosition | null {
if (vessels.length === 0) return null
let nearest = vessels[0]
let minDistance = calculateDistance(targetLat, targetLng, nearest.latitude, nearest.longitude)
vessels.forEach(vessel => {
const distance = calculateDistance(targetLat, targetLng, vessel.latitude, vessel.longitude)
if (distance < minDistance) {
minDistance = distance
nearest = vessel
}
})
return nearest
}
// 11. Mock 데이터 (테스트용)
export function getMockVessels(): VesselPosition[] {
return [
{
mmsi: '440123456',
imo: 'IMO9123456',
shipName: '씨프린스호',
callSign: 'HLCS',
shipType: 80,
shipTypeText: '유조선',
latitude: 34.5,
longitude: 127.8,
speed: 0.2,
course: 135,
heading: 140,
navStatus: 1,
navStatusText: '정박중',
timestamp: new Date().toISOString(),
destination: 'YEOSU',
draught: 12.5,
length: 180,
width: 32,
},
{
mmsi: '440234567',
shipName: '여수경비정',
shipType: 55,
shipTypeText: '법집행선',
latitude: 34.52,
longitude: 127.82,
speed: 12.5,
course: 270,
heading: 268,
navStatus: 0,
navStatusText: '기관 사용 항해중',
timestamp: new Date().toISOString(),
destination: 'PATROL',
length: 45,
width: 8,
},
]
}