import type { AisTarget } from "../../../entities/aisTarget/model/types"; import type { LegacyVesselIndex } from "../../../entities/legacyVessel/lib"; import { matchLegacyVessel } from "../../../entities/legacyVessel/lib"; import type { LegacyVesselInfo } from "../../../entities/legacyVessel/model/types"; import type { ZonesGeoJson } from "../../../entities/zone/api/useZones"; import type { ZoneId } from "../../../entities/zone/model/meta"; import { ZONE_IDS } from "../../../entities/zone/model/meta"; import { VESSEL_TYPES } from "../../../entities/vessel/model/meta"; import type { VesselTypeCode } from "../../../entities/vessel/model/types"; import { haversineNm } from "../../../shared/lib/geo/haversineNm"; import { pointInMultiPolygon } from "../../../shared/lib/geo/pointInPolygon"; import type { DerivedLegacyVessel, DerivedVesselState, FcLink, FleetCircle, LegacyAlarm, PairLink } from "./types"; function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } export function deriveVesselState(shipCode: VesselTypeCode, sogRaw: unknown): DerivedVesselState { const sog = isFiniteNumber(sogRaw) ? sogRaw : null; if (sog === null) return { label: "미상", isFishing: false, isTransit: false }; if (sog < 0.5) return { label: "정지", isFishing: false, isTransit: false }; const meta = VESSEL_TYPES[shipCode]; const inPrimary = meta.speedProfile.some((s) => s.primary && sog >= s.range[0] && sog <= s.range[1]); if (inPrimary) return { label: "조업", isFishing: true, isTransit: false }; if (sog >= 5) return { label: "항해", isFishing: false, isTransit: true }; return { label: "저속", isFishing: false, isTransit: false }; } export function buildLegacyHitMap(targets: AisTarget[], legacyIndex: LegacyVesselIndex | null): Map { const hits = new Map(); if (!legacyIndex) return hits; for (const t of targets) { if (typeof t.mmsi !== "number") continue; const hit = matchLegacyVessel(t, legacyIndex); if (!hit) continue; hits.set(t.mmsi, hit); } return hits; } export function findZoneId(lon: number, lat: number, zones: ZonesGeoJson | null): ZoneId | null { if (!zones) return null; for (const f of zones.features) { const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined; if (!zoneId) continue; if (!ZONE_IDS.includes(zoneId)) continue; const geom = f.geometry; if (!geom || geom.type !== "MultiPolygon") continue; if (pointInMultiPolygon(lon, lat, geom.coordinates as unknown as [number, number][][][])) return zoneId; } return null; } export function deriveLegacyVessels(args: { targets: AisTarget[]; legacyHits: Map; zones: ZonesGeoJson | null; }): DerivedLegacyVessel[] { const out: DerivedLegacyVessel[] = []; for (const t of args.targets) { if (typeof t.mmsi !== "number") continue; const legacy = args.legacyHits.get(t.mmsi); if (!legacy) continue; const lat = isFiniteNumber(t.lat) ? t.lat : null; const lon = isFiniteNumber(t.lon) ? t.lon : null; if (lat === null || lon === null) continue; const code = legacy.shipCode as VesselTypeCode; if (!code || !(code in VESSEL_TYPES)) continue; const ownerKey = (legacy.ownerRoman || legacy.ownerCn || "").trim() || null; out.push({ mmsi: t.mmsi, name: (t.name || "").trim() || legacy.shipNameCn || legacy.shipNameRoman || "(no name)", callsign: (t.callsign || "").trim() || null, lat, lon, sog: isFiniteNumber(t.sog) ? t.sog : null, cog: isFiniteNumber(t.cog) ? t.cog : null, heading: isFiniteNumber(t.heading) ? t.heading : null, messageTimestamp: t.messageTimestamp ?? null, receivedDate: t.receivedDate ?? null, ais: t, legacy, shipCode: code, permitNo: legacy.permitNo, ownerKey, ownerCn: legacy.ownerCn ?? null, ownerRoman: legacy.ownerRoman ?? null, workSeaArea: legacy.workSeaArea ?? null, pairPermitNo: legacy.pairPermitNo ?? null, zoneId: findZoneId(lon, lat, args.zones), state: deriveVesselState(code, t.sog), }); } return out; } export function filterByShipCode(vessels: DerivedLegacyVessel[], selected: VesselTypeCode | null): DerivedLegacyVessel[] { if (!selected) return vessels; if (selected === "PT" || selected === "PT-S") return vessels.filter((v) => v.shipCode === "PT" || v.shipCode === "PT-S"); return vessels.filter((v) => v.shipCode === selected); } export function filterByShipCodes(vessels: DerivedLegacyVessel[], enabled: Record): DerivedLegacyVessel[] { return vessels.filter((v) => enabled[v.shipCode]); } export function computeCountsByType(vessels: DerivedLegacyVessel[]) { const counts: Record = { PT: 0, "PT-S": 0, GN: 0, OT: 0, PS: 0, FC: 0 }; for (const v of vessels) counts[v.shipCode] += 1; return counts; } export function computePairLinks(vessels: DerivedLegacyVessel[]): PairLink[] { const byPermit = new Map(); for (const v of vessels) byPermit.set(v.permitNo, v); const seen = new Set(); const links: PairLink[] = []; for (const v of vessels) { if (!v.pairPermitNo) continue; const pair = byPermit.get(v.pairPermitNo); if (!pair) continue; const a = v.mmsi < pair.mmsi ? v : pair; const b = v.mmsi < pair.mmsi ? pair : v; const key = `${a.mmsi}-${b.mmsi}`; if (seen.has(key)) continue; seen.add(key); const d = haversineNm(a.lat, a.lon, b.lat, b.lon); links.push({ aMmsi: a.mmsi, bMmsi: b.mmsi, from: [a.lon, a.lat], to: [b.lon, b.lat], distanceNm: d, warn: d > 3, }); } return links; } export function computeFcLinks(vessels: DerivedLegacyVessel[]): FcLink[] { const others = vessels.filter((v) => v.shipCode !== "FC"); const fcs = vessels.filter((v) => v.shipCode === "FC"); const links: FcLink[] = []; for (const fc of fcs) { let best: DerivedLegacyVessel | null = null; let bestD = Infinity; for (const o of others) { const d = haversineNm(fc.lat, fc.lon, o.lat, o.lon); if (d < bestD) { bestD = d; best = o; } } if (!best || !Number.isFinite(bestD)) continue; if (bestD > 5) continue; links.push({ fcMmsi: fc.mmsi, otherMmsi: best.mmsi, from: [fc.lon, fc.lat], to: [best.lon, best.lat], distanceNm: bestD, suspicious: bestD < 0.5, }); } return links; } export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle[] { const groups = new Map(); for (const v of vessels) { if (!v.ownerKey) continue; const list = groups.get(v.ownerKey); if (list) list.push(v); else groups.set(v.ownerKey, [v]); } const out: FleetCircle[] = []; for (const [ownerKey, vs] of groups.entries()) { if (vs.length < 3) continue; const ownerLabel = vs.find((v) => v.ownerCn)?.ownerCn ?? vs.find((v) => v.ownerRoman)?.ownerRoman ?? ownerKey; const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; let radiusNm = 0; for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon)); out.push({ ownerKey, ownerLabel, center: [lon, lat], radiusNm: Math.max(0.2, radiusNm), count: vs.length, vesselMmsis: vs.map((v) => v.mmsi), }); } // Show largest fleets first. out.sort((a, b) => b.count - a.count); return out.slice(0, 30); } function fmtAgoLabel(nowMs: number, iso: string | null): string { if (!iso) return "-"; const t = Date.parse(iso); if (!Number.isFinite(t)) return "-"; const diffMin = Math.max(0, Math.round((nowMs - t) / 60_000)); if (diffMin <= 0) return "방금"; return `-${diffMin}분`; } export function computeLegacyAlarms(args: { vessels: DerivedLegacyVessel[]; pairLinks: PairLink[]; fcLinks: FcLink[]; now?: Date; }): LegacyAlarm[] { const nowMs = (args.now ?? new Date()).getTime(); const month = new Date(nowMs).getMonth(); // 0-11 const alarms: LegacyAlarm[] = []; for (const p of args.pairLinks) { if (!p.warn) continue; const a = args.vessels.find((v) => v.mmsi === p.aMmsi); const b = args.vessels.find((v) => v.mmsi === p.bMmsi); const ts = a?.messageTimestamp ?? b?.messageTimestamp ?? null; alarms.push({ severity: "hi", kind: "pair_separation", timeLabel: fmtAgoLabel(nowMs, ts), text: `${a?.permitNo ?? p.aMmsi}↔${b?.permitNo ?? p.bMmsi} 쌍분리 ${p.distanceNm.toFixed(1)}NM`, relatedMmsi: [p.aMmsi, p.bMmsi], }); } for (const l of args.fcLinks) { if (!l.suspicious) continue; const fc = args.vessels.find((v) => v.mmsi === l.fcMmsi); const o = args.vessels.find((v) => v.mmsi === l.otherMmsi); const ts = fc?.messageTimestamp ?? o?.messageTimestamp ?? null; alarms.push({ severity: "hi", kind: "transshipment", timeLabel: fmtAgoLabel(nowMs, ts), text: `${fc?.permitNo ?? l.fcMmsi}→${o?.permitNo ?? l.otherMmsi} 환적의심 ${l.distanceNm.toFixed(2)}NM`, relatedMmsi: [l.fcMmsi, l.otherMmsi], }); } for (const v of args.vessels) { const meta = VESSEL_TYPES[v.shipCode]; if (meta.monthlyIntensity[month] !== 0) continue; if (!v.state.isFishing) continue; alarms.push({ severity: "cr", kind: "closed_season", timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp), text: `${v.permitNo} [${v.shipCode}] 휴어기 조업 의심 ${v.sog ?? "?"}kt`, relatedMmsi: [v.mmsi], }); } // AIS stale for (const v of args.vessels) { const ts = Date.parse(v.messageTimestamp || ""); if (!Number.isFinite(ts)) continue; const diffMin = (nowMs - ts) / 60_000; if (diffMin < 45) continue; alarms.push({ severity: "cr", kind: "ais_stale", timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp), text: `${v.permitNo} [${v.shipCode}] AIS 지연 ${Math.round(diffMin)}분`, relatedMmsi: [v.mmsi], }); } // Zone violations (only when we can detect zone). for (const v of args.vessels) { if (!v.zoneId) continue; const allowed = VESSEL_TYPES[v.shipCode].allowedZones; if (allowed.includes(v.zoneId)) continue; alarms.push({ severity: "hi", kind: "zone_violation", timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp), text: `${v.permitNo} [${v.shipCode}] 수역 이탈 (${v.zoneId})`, relatedMmsi: [v.mmsi], }); } // Fixed category priority (independent of severity): // 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연 // Within each category: most recent first (smaller N in "-N분" is more recent). const kindPriority: Record = { zone_violation: 0, pair_separation: 1, transshipment: 2, closed_season: 3, ais_stale: 4, }; const parseAgeMin = (label: string) => { if (label === "방금") return 0; const m = /-(\\d+)분/.exec(label); if (m) return Number(m[1]); return Number.POSITIVE_INFINITY; }; alarms.sort((a, b) => { const ak = kindPriority[a.kind] ?? 999; const bk = kindPriority[b.kind] ?? 999; if (ak !== bk) return ak - bk; const am = parseAgeMin(a.timeLabel); const bm = parseAgeMin(b.timeLabel); if (am !== bm) return am - bm; // Stable tie-break. return a.text.localeCompare(b.text); }); return alarms; }