gc-wing/apps/web/src/features/legacyDashboard/model/derive.ts

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;
}