release: 2026-03-20 (특정어업수역 폴리곤 수역 분류) #98

병합
htlee develop 에서 main 로 5 commits 를 머지했습니다 2026-03-20 12:53:16 +09:00
13개의 변경된 파일223개의 추가작업 그리고 30개의 파일을 삭제

파일 보기

@ -4,34 +4,18 @@
## [Unreleased]
## [2026-03-20]
### 추가
- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가
- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단
- 어구/어망 카테고리 신설 + 모선 연결선 시각화
- 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본)
- 이란 주변국 시설 레이어 (MEFacilityLayer 35개소)
- 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영
- 한국 해군부대 10개소, 항만, 풍력발전단지, 북한 발사대/미사일 이벤트 레이어
- 정부기관 건물 레이어 (GovBuildingLayer)
- CCTV 프록시 컨트롤러
- 특정어업수역 ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon)
### 변경
- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망
- 오른쪽 패널 접기/펼치기 기능
- 센서차트 기본 숨김
- CCTV 레이어 리팩토링
## [2026-03-19.2]
## [2026-03-19]
### 추가
- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)
### 변경
- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304)
## [2026-03-19]
### 변경
- 인라인 CSS 정리 — 공통 클래스 추출 + Tailwind 전환
### 수정

파일 보기

@ -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]]]]}}]}