| import type { CableAdvisory, RepairShip, UnderseaCable } from '@/types'; |
| import { getRpcBaseUrl } from '@/services/rpc-client'; |
| import type { NavigationalWarning } from '@/generated/client/worldmonitor/maritime/v1/service_client'; |
| import { MaritimeServiceClient } from '@/services/generated-rpc-clients'; |
|
|
| |
| |
| |
| let cablesData: UnderseaCable[] = []; |
| let cablesDataPromise: Promise<void> | null = null; |
| async function ensureCablesData(): Promise<void> { |
| if (cablesData.length > 0) return; |
| if (!cablesDataPromise) { |
| cablesDataPromise = import('@/config/geo-map') |
| .then(({ UNDERSEA_CABLES }) => { |
| cablesData = UNDERSEA_CABLES; |
| }) |
| .catch((error) => { |
| cablesDataPromise = null; |
| throw error; |
| }); |
| } |
| try { |
| await cablesDataPromise; |
| } catch { |
| |
| } |
| } |
|
|
| const maritimeClient = new MaritimeServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); |
|
|
| interface CableActivity { |
| advisories: CableAdvisory[]; |
| repairShips: RepairShip[]; |
| } |
|
|
| interface NgaWarning { |
| msgYear: number; |
| msgNumber: number; |
| navArea: string; |
| subregion: string; |
| text: string; |
| status: string; |
| issueDate: string; |
| authority: string; |
| } |
|
|
| const CABLE_KEYWORDS = [ |
| 'CABLE', |
| 'CABLESHIP', |
| 'CABLE SHIP', |
| 'CABLE LAYING', |
| 'CABLE OPERATIONS', |
| 'SUBMARINE CABLE', |
| 'UNDERSEA CABLE', |
| 'FIBER OPTIC', |
| 'TELECOMMUNICATIONS CABLE', |
| ]; |
|
|
| const CABLESHIP_PATTERNS = [ |
| /CABLESHIP\s+([A-Z][A-Z0-9\s\-']+)/i, |
| /CABLE\s+SHIP\s+([A-Z][A-Z0-9\s\-']+)/i, |
| /CS\s+([A-Z][A-Z0-9\s\-']+)/i, |
| /M\/V\s+([A-Z][A-Z0-9\s\-']+)/i, |
| /VESSEL\s+([A-Z][A-Z0-9\s\-']+)/i, |
| ]; |
|
|
| function isCableRelated(text: string): boolean { |
| const upper = text.toUpperCase(); |
| return CABLE_KEYWORDS.some(kw => upper.includes(kw)); |
| } |
|
|
| function parseCoordinates(text: string): { lat: number; lon: number }[] { |
| const coords: { lat: number; lon: number }[] = []; |
|
|
| |
| const dmsPattern = /(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([NS])\s+(\d{1,3})-(\d{1,2}(?:\.\d+)?)\s*([EW])/gi; |
| let match; |
|
|
| while ((match = dmsPattern.exec(text)) !== null) { |
| if (!match[1] || !match[2] || !match[3] || !match[4] || !match[5] || !match[6]) continue; |
|
|
| const latDeg = parseInt(match[1], 10); |
| const latMin = parseFloat(match[2]); |
| const latDir = match[3].toUpperCase(); |
| const lonDeg = parseInt(match[4], 10); |
| const lonMin = parseFloat(match[5]); |
| const lonDir = match[6].toUpperCase(); |
|
|
| let lat = latDeg + latMin / 60; |
| let lon = lonDeg + lonMin / 60; |
|
|
| if (latDir === 'S') lat = -lat; |
| if (lonDir === 'W') lon = -lon; |
|
|
| if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { |
| coords.push({ lat, lon }); |
| } |
| } |
|
|
| |
| const decPattern = /(\d{1,3}\.\d+)\s*([NS])\s+(\d{1,3}\.\d+)\s*([EW])/gi; |
| while ((match = decPattern.exec(text)) !== null) { |
| if (!match[1] || !match[2] || !match[3] || !match[4]) continue; |
|
|
| let lat = parseFloat(match[1]); |
| let lon = parseFloat(match[3]); |
|
|
| if (match[2].toUpperCase() === 'S') lat = -lat; |
| if (match[4].toUpperCase() === 'W') lon = -lon; |
|
|
| if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { |
| coords.push({ lat, lon }); |
| } |
| } |
|
|
| return coords; |
| } |
|
|
| function extractCableshipName(text: string): string | null { |
| for (const pattern of CABLESHIP_PATTERNS) { |
| const match = text.match(pattern); |
| if (match?.[1]) { |
| const name = match[1].trim().replace(/\s+/g, ' '); |
| |
| if (name.length > 2 && !/^(THE|AND|FOR|WITH)$/i.test(name)) { |
| return name; |
| } |
| } |
| } |
| return null; |
| } |
|
|
| function findNearestCable(lat: number, lon: number): UnderseaCable | null { |
| let nearest: UnderseaCable | null = null; |
| let minDist = Infinity; |
|
|
| for (const cable of cablesData) { |
| for (const point of cable.points) { |
| const [cableLon, cableLat] = point; |
| const dist = Math.sqrt((lat - cableLat) ** 2 + (lon - cableLon) ** 2); |
| if (dist < minDist && dist < 5) { |
| minDist = dist; |
| nearest = cable; |
| } |
| } |
| } |
|
|
| return nearest; |
| } |
|
|
| function parseIssueDate(dateStr: string): Date { |
| |
| const match = dateStr.match(/(\d{2})(\d{4})Z\s+([A-Z]{3})\s+(\d{4})/i); |
| if (match?.[1] && match[2] && match[3] && match[4]) { |
| const day = parseInt(match[1], 10); |
| const time = match[2]; |
| const monthStr = match[3].toUpperCase(); |
| const year = parseInt(match[4], 10); |
|
|
| const months: Record<string, number> = { |
| JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, |
| JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11, |
| }; |
|
|
| const month = months[monthStr] ?? 0; |
| const hours = parseInt(time.slice(0, 2), 10); |
| const minutes = parseInt(time.slice(2, 4), 10); |
|
|
| return new Date(Date.UTC(year, month, day, hours, minutes)); |
| } |
| return new Date(); |
| } |
|
|
| function determineSeverity(text: string): 'fault' | 'degraded' { |
| const faultKeywords = /FAULT|BREAK|CUT|DAMAGE|SEVERED|RUPTURE|OUTAGE|FAILURE/i; |
| return faultKeywords.test(text) ? 'fault' : 'degraded'; |
| } |
|
|
| function determineShipStatus(text: string): 'enroute' | 'on-station' { |
| const onStationKeywords = /ON STATION|OPERATIONS IN PROGRESS|LAYING|REPAIRING|WORKING|COMMENCED/i; |
| return onStationKeywords.test(text) ? 'on-station' : 'enroute'; |
| } |
|
|
| function slugify(text: string): string { |
| return text |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, '-') |
| .replace(/(^-|-$)+/g, '') |
| .slice(0, 40); |
| } |
|
|
| function processWarnings(warnings: NgaWarning[]): CableActivity { |
| const advisories: CableAdvisory[] = []; |
| const repairShips: RepairShip[] = []; |
| const seenIds = new Set<string>(); |
|
|
| const cableWarnings = warnings.filter(w => isCableRelated(w.text)); |
|
|
| for (const warning of cableWarnings) { |
| const coords = parseCoordinates(warning.text); |
| const shipName = extractCableshipName(warning.text); |
| const issueDate = parseIssueDate(warning.issueDate); |
|
|
| |
| let lat = 0; |
| let lon = 0; |
| let matchedCable: UnderseaCable | null = null; |
|
|
| if (coords.length > 0) { |
| |
| lat = coords.reduce((sum, c) => sum + c.lat, 0) / coords.length; |
| lon = coords.reduce((sum, c) => sum + c.lon, 0) / coords.length; |
| matchedCable = findNearestCable(lat, lon); |
| } |
|
|
| |
| if (lat === 0 && lon === 0) continue; |
|
|
| const warningId = `nga-${warning.navArea}-${warning.msgYear}-${warning.msgNumber}`; |
|
|
| |
| if (shipName) { |
| const shipId = `ship-${warningId}-${slugify(shipName)}`; |
| if (!seenIds.has(shipId)) { |
| seenIds.add(shipId); |
| repairShips.push({ |
| id: shipId, |
| name: shipName, |
| cableId: matchedCable?.id || 'unknown', |
| status: determineShipStatus(warning.text), |
| lat, |
| lon, |
| eta: determineShipStatus(warning.text) === 'on-station' ? 'On station' : 'TBD', |
| operator: warning.authority || undefined, |
| note: warning.text.slice(0, 200) + (warning.text.length > 200 ? '...' : ''), |
| }); |
| } |
| } |
|
|
| |
| const advisoryId = `advisory-${warningId}`; |
| if (!seenIds.has(advisoryId)) { |
| seenIds.add(advisoryId); |
|
|
| const isOperation = /OPERATIONS|LAYING|REPAIR|SURVEY/i.test(warning.text); |
| const title = shipName |
| ? `${isOperation ? 'Cable Operations' : 'Cable Activity'}: ${shipName}` |
| : `NAVAREA ${warning.navArea} Cable Warning`; |
|
|
| advisories.push({ |
| id: advisoryId, |
| cableId: matchedCable?.id || 'unknown', |
| title, |
| severity: determineSeverity(warning.text), |
| description: warning.text.slice(0, 300) + (warning.text.length > 300 ? '...' : ''), |
| reported: issueDate, |
| lat, |
| lon, |
| impact: isOperation |
| ? 'Cable operations in progress. Vessels requested to give wide berth.' |
| : matchedCable |
| ? `Potential impact to ${matchedCable.name} cable route.` |
| : 'Navigation warning in effect for cable infrastructure.', |
| repairEta: undefined, |
| }); |
| } |
| } |
|
|
| return { advisories, repairShips }; |
| } |
|
|
| function protoToNgaWarning(w: NavigationalWarning): NgaWarning { |
| |
| const idParts = w.id.split('-'); |
| const navArea = idParts.length >= 3 ? idParts.slice(0, -2).join('-') : (idParts[0] || ''); |
| const msgYear = idParts.length >= 2 ? Number(idParts[idParts.length - 2]) || 0 : 0; |
| const msgNumber = idParts.length >= 1 ? Number(idParts[idParts.length - 1]) || 0 : 0; |
|
|
| |
| const areaParts = w.area.split(' '); |
| const subregion = areaParts.length > 1 ? areaParts.slice(1).join(' ') : ''; |
|
|
| return { |
| msgYear, |
| msgNumber, |
| navArea, |
| subregion, |
| text: w.text, |
| status: 'A', |
| issueDate: w.issuedAt ? formatNgaDate(w.issuedAt) : '', |
| authority: w.authority, |
| }; |
| } |
|
|
| function formatNgaDate(epochMs: number): string { |
| if (!epochMs) return ''; |
| const d = new Date(epochMs); |
| const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; |
| const day = String(d.getUTCDate()).padStart(2, '0'); |
| const hours = String(d.getUTCHours()).padStart(2, '0'); |
| const minutes = String(d.getUTCMinutes()).padStart(2, '0'); |
| const month = months[d.getUTCMonth()] || 'JAN'; |
| const year = d.getUTCFullYear(); |
| return `${day}${hours}${minutes}Z ${month} ${year}`; |
| } |
|
|
| export async function fetchCableActivity(): Promise<CableActivity> { |
| try { |
| await ensureCablesData(); |
| const response = await maritimeClient.listNavigationalWarnings({ area: '', pageSize: 0, cursor: '' }); |
| const warnings: NgaWarning[] = response.warnings.map(protoToNgaWarning); |
|
|
| const activity = processWarnings(warnings); |
|
|
| return activity; |
| } catch (error) { |
| console.error('[CableActivity] Failed to fetch NGA warnings:', error); |
| return { advisories: [], repairShips: [] }; |
| } |
| } |
|
|