feat: 특정어업수역 폴리곤 기반 수역 분류 + 연결선 성능 수정 #96

병합
htlee fix/fishing-overlay-perf 에서 develop 로 2 commits 를 머지했습니다 2026-03-20 12:50:04 +09:00
13개의 변경된 파일223개의 추가작업 그리고 10개의 파일을 삭제

파일 보기

@ -5,6 +5,7 @@
## [Unreleased]
### 추가
- 특정어업수역 ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon)
- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가
- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단
- 어구/어망 카테고리 신설 + 모선 연결선 시각화
@ -15,6 +16,9 @@
- 정부기관 건물 레이어 (GovBuildingLayer)
- CCTV 프록시 컨트롤러
### 수정
- 중국어선감시 연결선 폭발 수정: 부분매칭 제거 + 거리제한 10NM + 마커 상한 200개
### 변경
- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망
- 오른쪽 패널 접기/펼치기 기능

파일 보기

@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",
@ -1628,6 +1630,49 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@turf/boolean-point-in-polygon": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz",
"integrity": "sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.4",
"@turf/invariant": "7.3.4",
"@types/geojson": "^7946.0.10",
"point-in-polygon-hao": "^1.1.0",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
"integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/invariant": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.4.tgz",
"integrity": "sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.4",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"dev": true,
@ -3579,6 +3624,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/point-in-polygon-hao": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz",
"integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"funding": [
@ -3805,6 +3859,12 @@
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"license": "MIT",
@ -4043,6 +4103,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,

파일 보기

@ -11,6 +11,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"@turf/boolean-point-in-polygon": "^7.3.4",
"@turf/helpers": "^7.3.4",
"@types/leaflet": "^1.9.21",
"date-fns": "^4.1.0",
"hls.js": "^1.6.15",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1 @@
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}

파일 보기

@ -2,6 +2,14 @@
// 한중어업협정 허가현황 (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';
/**
* ( )
@ -43,14 +51,57 @@ const GEAR_META: Record<FishingGearType, {
export { GEAR_META as GEAR_LABELS };
/**
* ()
* EPSG:3857 WGS84
*/
const _FISHING_ZONES = {
I: { name: '수역Ⅰ(동해)', lngMin: 128.86, lngMax: 131.67, latMin: 35.65, latMax: 38.25, allowed: ['PS', 'FC'] },
II: { name: '수역Ⅱ(남해)', lngMin: 126.00, lngMax: 128.89, latMin: 32.18, latMax: 34.34, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
III:{ name: '수역Ⅲ(서남해)', lngMin: 124.01, lngMax: 126.08, latMin: 32.18, latMax: 35.00, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
IV: { name: '수역Ⅳ(서해)', lngMin: 124.13, lngMax: 125.85, latMin: 35.00, latMax: 37.00, allowed: ['GN', 'PS', 'FC'] },
};
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: [] };
}
/**
* (/)

파일 보기

@ -10,6 +10,7 @@ TERRITORIAL_SEA_NM = 12.0
CONTIGUOUS_ZONE_NM = 24.0
_baseline_points: Optional[List[Tuple[float, float]]] = None
_zone_polygons: Optional[list] = None
def _load_baseline() -> List[Tuple[float, float]]:
@ -46,10 +47,91 @@ def dist_to_baseline(vessel_lat: float, vessel_lon: float,
return min_dist
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
"""선박 위치 수역 분류."""
dist = dist_to_baseline(vessel_lat, vessel_lon)
def _epsg3857_to_wgs84(x: float, y: float) -> Tuple[float, float]:
"""EPSG:3857 (Web Mercator) → WGS84 변환."""
lon = x / (math.pi * 6378137) * 180
lat = math.atan(math.exp(y / 6378137)) * 360 / math.pi - 90
return lat, lon
def _load_zone_polygons() -> list:
"""특정어업수역 ~Ⅳ GeoJSON 로드 + EPSG:3857→WGS84 변환."""
global _zone_polygons
if _zone_polygons is not None:
return _zone_polygons
zone_dir = Path(__file__).parent.parent / 'data' / 'zones'
zones_meta = [
('ZONE_I', '수역Ⅰ(동해)', ['PS', 'FC'], '특정어업수역Ⅰ.json'),
('ZONE_II', '수역Ⅱ(남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅱ.json'),
('ZONE_III', '수역Ⅲ(서남해)', ['PT', 'OT', 'GN', 'PS', 'FC'], '특정어업수역Ⅲ.json'),
('ZONE_IV', '수역Ⅳ(서해)', ['GN', 'PS', 'FC'], '특정어업수역Ⅳ.json'),
]
result = []
for zone_id, name, allowed, filename in zones_meta:
filepath = zone_dir / filename
if not filepath.exists():
continue
with open(filepath, 'r') as f:
data = json.load(f)
multi_coords = data['features'][0]['geometry']['coordinates']
wgs84_polys = []
for poly in multi_coords:
wgs84_rings = []
for ring in poly:
wgs84_rings.append([_epsg3857_to_wgs84(x, y) for x, y in ring])
wgs84_polys.append(wgs84_rings)
result.append({
'id': zone_id, 'name': name, 'allowed': allowed,
'polygons': wgs84_polys,
})
_zone_polygons = result
return result
def _point_in_polygon(lat: float, lon: float, ring: list) -> bool:
"""Ray-casting point-in-polygon."""
n = len(ring)
inside = False
j = n - 1
for i in range(n):
yi, xi = ring[i]
yj, xj = ring[j]
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def _point_in_multipolygon(lat: float, lon: float, polygons: list) -> bool:
"""MultiPolygon 내 포함 여부 (외곽 링 in + 내곽 링 hole 제외)."""
for poly in polygons:
outer = poly[0]
if _point_in_polygon(lat, lon, outer):
for hole in poly[1:]:
if _point_in_polygon(lat, lon, hole):
return False
return True
return False
def classify_zone(vessel_lat: float, vessel_lon: float) -> dict:
"""선박 위치 수역 분류 — 특정어업수역 ~Ⅳ 폴리곤 기반."""
zones = _load_zone_polygons()
for z in zones:
if _point_in_multipolygon(vessel_lat, vessel_lon, z['polygons']):
dist = dist_to_baseline(vessel_lat, vessel_lon)
return {
'zone': z['id'],
'zone_name': z['name'],
'allowed_gears': z['allowed'],
'dist_from_baseline_nm': round(dist, 2),
'violation': False,
'alert_level': 'WATCH',
}
dist = dist_to_baseline(vessel_lat, vessel_lon)
if dist <= TERRITORIAL_SEA_NM:
return {
'zone': 'TERRITORIAL_SEA',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1 @@
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}