- 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>
327 lines
9.0 KiB
TypeScript
Executable File
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,
|
|
},
|
|
]
|
|
}
|