293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
// ═══ 중국 어선 조업 분석 — GC-KCG-2026-001 보고서 기반 ═══
|
||
// 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류
|
||
|
||
import type { Ship } from '../types';
|
||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||
import { point, multiPolygon } from '@turf/helpers';
|
||
import type { Feature, MultiPolygon } from 'geojson';
|
||
|
||
import zone1Data from '../data/zones/특정어업수역Ⅰ.json';
|
||
import zone2Data from '../data/zones/특정어업수역Ⅱ.json';
|
||
import zone3Data from '../data/zones/특정어업수역Ⅲ.json';
|
||
import zone4Data from '../data/zones/특정어업수역Ⅳ.json';
|
||
|
||
/**
|
||
* 중국 허가 업종 코드 (허가번호 접두사)
|
||
* PT(C21): 2척식저인망 본선 323척 + 부속선(PT-S) 323척
|
||
* OT(C22): 1척식저인망 13척
|
||
* PS(C23): 위망(선망) 16척 (宁波海裕 단일법인)
|
||
* GN(C25): 유망(유자망) 200척
|
||
* FC: 운반선 31척
|
||
*/
|
||
export type FishingGearType = 'trawl_pair' | 'trawl_single' | 'gillnet' | 'stow_net' | 'purse_seine' | 'carrier' | 'unknown';
|
||
|
||
export interface FishingAnalysis {
|
||
gearType: FishingGearType;
|
||
gearTypeKo: string;
|
||
permitCode: string; // PT, OT, GN, PS, FC
|
||
gbCode: string; // GB/T 5147-2003 코드
|
||
isOperating: boolean;
|
||
operatingStatusKo: string;
|
||
confidence: number;
|
||
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||
riskReason: string;
|
||
}
|
||
|
||
/** 업종별 메타데이터 */
|
||
const GEAR_META: Record<FishingGearType, {
|
||
ko: string; icon: string; color: string;
|
||
permitCode: string; gbCode: string;
|
||
speedRange: [number, number]; aiConfidence: string;
|
||
}> = {
|
||
trawl_pair: { ko: '2척식 저인망(PT)', icon: '🔻', color: '#ef4444', permitCode: 'PT', gbCode: 'TDS', speedRange: [2, 5], aiConfidence: '89~95%' },
|
||
trawl_single: { ko: '1척식 저인망(OT)', icon: '🔻', color: '#dc2626', permitCode: 'OT', gbCode: 'TDD', speedRange: [2, 5], aiConfidence: '89~92%' },
|
||
gillnet: { ko: '유자망(GN)', icon: '🔲', color: '#f97316', permitCode: 'GN', gbCode: 'CLD', speedRange: [0, 2], aiConfidence: '74~80%' },
|
||
stow_net: { ko: '안강망(Z)', icon: '🪤', color: '#eab308', permitCode: '-', gbCode: 'ZD', speedRange: [0, 1], aiConfidence: '70~78%' },
|
||
purse_seine: { ko: '위망/선망(PS)', icon: '🔄', color: '#3b82f6', permitCode: 'PS', gbCode: 'WDD', speedRange: [3, 10], aiConfidence: '94~97%' },
|
||
carrier: { ko: '운반선(FC)', icon: '🚢', color: '#6b7280', permitCode: 'FC', gbCode: '-', speedRange: [0, 12], aiConfidence: '-' },
|
||
unknown: { ko: '미분류', icon: '🐟', color: '#9ca3af', permitCode: '-', gbCode: '-', speedRange: [0, 0], aiConfidence: '-' },
|
||
};
|
||
|
||
export { GEAR_META as GEAR_LABELS };
|
||
|
||
/**
|
||
* EPSG:3857 → WGS84 좌표 변환
|
||
*/
|
||
function epsg3857ToWgs84(x: number, y: number): [number, number] {
|
||
const lon = (x / (Math.PI * 6378137)) * 180;
|
||
const lat = Math.atan(Math.exp(y / 6378137)) * (360 / Math.PI) - 90;
|
||
return [lon, lat]; // GeoJSON [lng, lat] 순서
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function convertZoneToWgs84(data: any): Feature<MultiPolygon> {
|
||
const feat = data.features[0];
|
||
const multiCoords: number[][][][] = feat.geometry.coordinates.map(
|
||
(poly: number[][][]) => poly.map(
|
||
(ring: number[][]) => ring.map(([x, y]: number[]) => epsg3857ToWgs84(x, y)),
|
||
),
|
||
);
|
||
return multiPolygon(multiCoords).geometry
|
||
? { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multiCoords } }
|
||
: multiPolygon(multiCoords) as unknown as Feature<MultiPolygon>;
|
||
}
|
||
|
||
export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE';
|
||
|
||
export interface FishingZoneInfo {
|
||
zone: FishingZoneId;
|
||
name: string;
|
||
allowed: string[];
|
||
}
|
||
|
||
/**
|
||
* 특정어업수역 Ⅰ~Ⅳ 폴리곤 (WGS84 변환 캐시)
|
||
*/
|
||
const ZONE_POLYGONS: { id: FishingZoneId; name: string; allowed: string[]; geojson: Feature<MultiPolygon> }[] = [
|
||
{ id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) },
|
||
{ id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) },
|
||
{ id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) },
|
||
{ id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) },
|
||
];
|
||
|
||
/**
|
||
* 특정어업수역 폴리곤 기반 수역 분류
|
||
*/
|
||
export function classifyFishingZone(lat: number, lng: number): FishingZoneInfo {
|
||
const pt = point([lng, lat]);
|
||
for (const z of ZONE_POLYGONS) {
|
||
if (booleanPointInPolygon(pt, z.geojson)) {
|
||
return { zone: z.id, name: z.name, allowed: z.allowed };
|
||
}
|
||
}
|
||
return { zone: 'OUTSIDE', name: '수역 외', allowed: [] };
|
||
}
|
||
|
||
/**
|
||
* 업종별 허가 기간 (월/일)
|
||
*/
|
||
const PERMIT_PERIODS: Record<string, { periods: [number, number, number, number][]; label: string }> = {
|
||
PT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
|
||
OT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
|
||
GN: { periods: [[2,1, 6,1], [9,1, 12,31]], label: '2/1~6/1, 9/1~12/31' },
|
||
PS: { periods: [[1,1, 12,31]], label: '연중' },
|
||
FC: { periods: [[1,1, 12,31]], label: '연중' },
|
||
};
|
||
|
||
function isInPermitPeriod(permitCode: string, date: Date): boolean {
|
||
const pp = PERMIT_PERIODS[permitCode];
|
||
if (!pp) return false;
|
||
const m = date.getMonth() + 1;
|
||
const d = date.getDate();
|
||
const dayOfYear = m * 100 + d;
|
||
return pp.periods.some(([m1, d1, m2, d2]) => dayOfYear >= m1 * 100 + d1 && dayOfYear <= m2 * 100 + d2);
|
||
}
|
||
|
||
/**
|
||
* AIS 신호 기반 중국 어선 조업 분석 (보고서 4장 기반)
|
||
*
|
||
* 트롤(PT/OT): 2~5kn, Lawn-mowing 지그재그, 방향변화 <30°
|
||
* 자망(GN): 0~2kn 정지·재방문, AIS OFF 빈번
|
||
* 선망(PS): 8~10kn→3kn 급전환, 원형 궤적
|
||
* 운반선(FC): 환적 패턴 (0.5NM 접근 + 2kn이하 + 30분)
|
||
*/
|
||
export function analyzeFishing(ship: Ship): FishingAnalysis {
|
||
const speed = ship.speed;
|
||
const status = ship.status?.toLowerCase() || '';
|
||
const isAnchored = status.includes('anchor') || status.includes('moor');
|
||
|
||
// 방향 변화율 (trail 기반)
|
||
let headingVariance = 0;
|
||
if (ship.trail && ship.trail.length >= 3) {
|
||
const pts = ship.trail.slice(-6);
|
||
const headings: number[] = [];
|
||
for (let i = 1; i < pts.length; i++) {
|
||
const dlng = pts[i][1] - pts[i - 1][1];
|
||
const dlat = pts[i][0] - pts[i - 1][0];
|
||
headings.push(Math.atan2(dlng, dlat) * 180 / Math.PI);
|
||
}
|
||
if (headings.length >= 2) {
|
||
const diffs = headings.slice(1).map((h, i) => {
|
||
let d = Math.abs(h - headings[i]);
|
||
if (d > 180) d = 360 - d;
|
||
return d;
|
||
});
|
||
headingVariance = diffs.reduce((a, b) => a + b, 0) / diffs.length;
|
||
}
|
||
}
|
||
|
||
let gearType: FishingGearType = 'unknown';
|
||
let isOperating = false;
|
||
let operatingStatusKo = '이동 중';
|
||
let confidence = 0.3;
|
||
|
||
// === 패턴 매칭 (보고서 4.1~4.3 기준) ===
|
||
|
||
if (isAnchored || speed > 12) {
|
||
// 정박 또는 고속 이동 → 비조업
|
||
gearType = 'unknown';
|
||
isOperating = false;
|
||
operatingStatusKo = isAnchored ? '정박 중' : '고속 이동';
|
||
confidence = 0.8;
|
||
|
||
} else if (speed >= 8 && speed <= 10 && headingVariance > 30) {
|
||
// 선망(PS) 포위 단계: 8~10kn + 원형 궤적
|
||
gearType = 'purse_seine';
|
||
isOperating = true;
|
||
operatingStatusKo = '선망 포위 중 (8~10kn 원형)';
|
||
confidence = 0.94;
|
||
|
||
} else if (speed <= 3 && headingVariance > 40) {
|
||
// 선망(PS) 죔줄 단계: 3kn 이하 + 이전 고속→저속 급전환
|
||
gearType = 'purse_seine';
|
||
isOperating = true;
|
||
operatingStatusKo = '선망 죔줄 조임 중 (<3kn)';
|
||
confidence = 0.85;
|
||
|
||
} else if (speed >= 2 && speed <= 5 && headingVariance < 30) {
|
||
// 트롤(PT/OT): 2~5kn + 완만한 방향 (Lawn-mowing)
|
||
gearType = headingVariance < 15 ? 'trawl_pair' : 'trawl_single';
|
||
isOperating = true;
|
||
operatingStatusKo = `트롤 예인 중 (${speed.toFixed(1)}kn 지그재그)`;
|
||
confidence = headingVariance < 15 ? 0.92 : 0.89;
|
||
|
||
} else if (speed < 2) {
|
||
// 0~2kn 극저속 → 조업 중 or 정박 대기
|
||
if (isAnchored || speed < 0.3) {
|
||
// 완전 정지/정박 → 비조업 (대기 중)
|
||
gearType = 'unknown';
|
||
isOperating = false;
|
||
operatingStatusKo = speed < 0.3 ? '정지/대기 중' : '정박 중';
|
||
confidence = 0.7;
|
||
} else if (speed >= 0.3 && speed < 1 && headingVariance < 8) {
|
||
// 극저속 + 방향 변화 거의 없음 → 안강망 (조류 이용 수동 어구)
|
||
gearType = 'stow_net';
|
||
isOperating = true;
|
||
operatingStatusKo = '안강망 조업 추정 (조류 이용)';
|
||
confidence = 0.65;
|
||
} else if (speed >= 0.3 && speed < 2) {
|
||
// 저속 이동 → 자망 투망/양망
|
||
gearType = 'gillnet';
|
||
isOperating = true;
|
||
operatingStatusKo = speed < 1 ? '자망 투하/대기 중' : '자망 양망 중 (극저속)';
|
||
confidence = 0.72;
|
||
}
|
||
|
||
} else if (speed >= 5 && speed < 8) {
|
||
// 중속 이동 → 조업지 이동 또는 운반선
|
||
gearType = 'carrier';
|
||
isOperating = false;
|
||
operatingStatusKo = '이동 중 (조업지 이동 추정)';
|
||
confidence = 0.5;
|
||
}
|
||
|
||
// === 위반 위험도 판별 (보고서 5장 기반) ===
|
||
const now = new Date();
|
||
const meta = GEAR_META[gearType];
|
||
let riskLevel: FishingAnalysis['riskLevel'] = 'LOW';
|
||
let riskReason = '정상 범위';
|
||
|
||
// 휴어기 체크
|
||
if (meta.permitCode !== '-' && !isInPermitPeriod(meta.permitCode, now)) {
|
||
riskLevel = 'CRITICAL';
|
||
riskReason = `휴어기 조업 의심 (${meta.permitCode} 허가기간: ${PERMIT_PERIODS[meta.permitCode]?.label})`;
|
||
}
|
||
|
||
// AIS 신호 오래된 경우 (다크베셀 의심)
|
||
const aisAge = Date.now() - ship.lastSeen;
|
||
if (aisAge > 6 * 3600_000) {
|
||
riskLevel = 'HIGH';
|
||
riskReason = `AIS 공백 ${Math.round(aisAge / 3600_000)}시간 — 다크베셀 의심`;
|
||
}
|
||
|
||
// 자망 + 조업 중 → AIS 차단 가능성 높음
|
||
if (gearType === 'gillnet' && isOperating) {
|
||
if (riskLevel === 'LOW') {
|
||
riskLevel = 'MEDIUM';
|
||
riskReason = '유자망 조업 중 — AIS 차단 주의 업종';
|
||
}
|
||
}
|
||
|
||
return {
|
||
gearType,
|
||
gearTypeKo: meta.ko,
|
||
permitCode: meta.permitCode,
|
||
gbCode: meta.gbCode,
|
||
isOperating,
|
||
operatingStatusKo,
|
||
confidence,
|
||
riskLevel,
|
||
riskReason,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 중국 어선 조업 통계 집계
|
||
*/
|
||
export function aggregateFishingStats(ships: Ship[]) {
|
||
// 중국 어선(fishing 카테고리)만 대상
|
||
const chineseFishing = ships.filter(s => s.flag === 'CN' && s.category === 'fishing');
|
||
const results = chineseFishing.map(s => ({ ship: s, analysis: analyzeFishing(s) }));
|
||
|
||
const operating = results.filter(r => r.analysis.isOperating);
|
||
const byGear: Record<FishingGearType, number> = {
|
||
trawl_pair: 0, trawl_single: 0, gillnet: 0, stow_net: 0, purse_seine: 0, carrier: 0, unknown: 0,
|
||
};
|
||
for (const r of operating) {
|
||
byGear[r.analysis.gearType]++;
|
||
}
|
||
|
||
const critical = results.filter(r => r.analysis.riskLevel === 'CRITICAL').length;
|
||
const high = results.filter(r => r.analysis.riskLevel === 'HIGH').length;
|
||
|
||
return {
|
||
total: chineseFishing.length,
|
||
operating: operating.length,
|
||
idle: chineseFishing.length - operating.length,
|
||
byGear,
|
||
critical,
|
||
high,
|
||
details: results,
|
||
};
|
||
}
|