335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
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<number, LegacyVesselInfo> {
|
|
const hits = new Map<number, LegacyVesselInfo>();
|
|
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<number, LegacyVesselInfo>;
|
|
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<VesselTypeCode, boolean>): DerivedLegacyVessel[] {
|
|
return vessels.filter((v) => enabled[v.shipCode]);
|
|
}
|
|
|
|
export function computeCountsByType(vessels: DerivedLegacyVessel[]) {
|
|
const counts: Record<VesselTypeCode, number> = { 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<string, DerivedLegacyVessel>();
|
|
for (const v of vessels) byPermit.set(v.permitNo, v);
|
|
|
|
const seen = new Set<string>();
|
|
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<string, DerivedLegacyVessel[]>();
|
|
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<LegacyAlarm["kind"], number> = {
|
|
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;
|
|
}
|