-
영해기선 거리 (추정)
+
영해기선 거리
{distanceNm.toFixed(1)} NM
@@ -194,7 +188,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
{/* Footer */}
- ※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있습니다.
+ ※ 거리는 영해기선 폴리곤 기준입니다. 구역은 버퍼 폴리곤 포함 여부로 판별됩니다.
diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx
index b0f7f73..2b7a080 100755
--- a/frontend/src/tabs/incidents/components/IncidentsView.tsx
+++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx
@@ -14,7 +14,7 @@ import type { IncidentCompat } from '../services/incidentsApi'
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'
import type { TrajectoryResponse, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
import { DischargeZonePanel } from './DischargeZonePanel'
-import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
+import { estimateDistanceFromCoast, determineZone, getDischargeZoneLines, loadTerritorialBaseline, getCachedBaseline, loadZoneGeoJSON, getCachedZones } from '../utils/dischargeZoneData'
import { useMapStore } from '@common/store/mapStore'
import { useMeasureTool } from '@common/hooks/useMeasureTool'
import { buildMeasureLayers } from '@common/components/map/measureLayers'
@@ -127,7 +127,8 @@ export function IncidentsView() {
// Discharge zone mode
const [dischargeMode, setDischargeMode] = useState(false)
- const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
+ const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number; zoneIndex: number } | null>(null)
+ const [baselineLoaded, setBaselineLoaded] = useState(() => getCachedBaseline() !== null && getCachedZones() !== null)
// Map style & toggles
const currentMapStyle = useBaseMapStyle(true)
@@ -153,6 +154,7 @@ export function IncidentsView() {
fetchIncidents().then(data => {
setIncidents(data)
})
+ Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true))
}, [])
// 사고 전환 시 지도 레이어 즉시 초기화
@@ -318,7 +320,7 @@ export function IncidentsView() {
// ── 배출 구역 경계선 레이어 ──
const dischargeZoneLayers = useMemo(() => {
- if (!dischargeMode) return []
+ if (!dischargeMode || !baselineLoaded) return []
const zoneLines = getDischargeZoneLines()
return zoneLines.map((line, i) =>
new PathLayer({
@@ -334,7 +336,7 @@ export function IncidentsView() {
pickable: false,
})
)
- }, [dischargeMode])
+ }, [dischargeMode, baselineLoaded])
const measureDeckLayers = useMemo(
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
@@ -615,7 +617,8 @@ export function IncidentsView() {
const lat = e.lngLat.lat
const lon = e.lngLat.lng
const distanceNm = estimateDistanceFromCoast(lat, lon)
- setDischargeInfo({ lat, lon, distanceNm })
+ const zoneIndex = determineZone(lat, lon)
+ setDischargeInfo({ lat, lon, distanceNm, zoneIndex })
}
}}
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
@@ -755,6 +758,7 @@ export function IncidentsView() {
lat={dischargeInfo.lat}
lon={dischargeInfo.lon}
distanceNm={dischargeInfo.distanceNm}
+ zoneIndex={dischargeInfo.zoneIndex}
onClose={() => setDischargeInfo(null)}
/>
)}
diff --git a/frontend/src/tabs/incidents/utils/dischargeZoneData.ts b/frontend/src/tabs/incidents/utils/dischargeZoneData.ts
index 558fe3d..ba0b94c 100644
--- a/frontend/src/tabs/incidents/utils/dischargeZoneData.ts
+++ b/frontend/src/tabs/incidents/utils/dischargeZoneData.ts
@@ -4,163 +4,235 @@
* 법률 근거:
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
+ *
+ * 구역 경계선: 국립해양조사원 영해기선(TB_ZN_TRTSEA) 버퍼 GeoJSON
+ * 영해기선 데이터: 국립해양조사원 TB_ZN_TRTSEA (EPSG:5179 → WGS84 변환)
*/
-// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
-// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
-const COASTLINE_POINTS: [number, number][] = [
- // 동해안 (북→남)
- [38.6177, 128.6560], [38.5504, 128.4092], [38.4032, 128.7767],
- [38.1904, 128.8902], [38.0681, 128.9977], [37.9726, 129.0715],
- [37.8794, 129.1721], [37.8179, 129.2397], [37.6258, 129.3669],
- [37.5053, 129.4577], [37.3617, 129.5700], [37.1579, 129.6538],
- [37.0087, 129.6706], [36.6618, 129.7210], [36.3944, 129.6827],
- [36.2052, 129.7641], [35.9397, 129.8124], [35.6272, 129.7121],
- [35.4732, 129.6908], [35.2843, 129.5924], [35.1410, 129.4656],
- [35.0829, 129.2125],
- // 남해안 (부산→여수→목포)
- [34.8950, 129.0658], [34.2050, 128.3063], [35.0220, 128.0362],
- [34.9663, 127.8732], [34.9547, 127.7148], [34.8434, 127.6625],
- [34.7826, 127.7422], [34.6902, 127.6324], [34.8401, 127.5236],
- [34.8230, 127.4043], [34.6882, 127.4234], [34.6252, 127.4791],
- [34.5525, 127.4012], [34.4633, 127.3246], [34.5461, 127.1734],
- [34.6617, 127.2605], [34.7551, 127.2471], [34.6069, 127.0308],
- [34.4389, 126.8975], [34.4511, 126.8263], [34.4949, 126.7965],
- [34.5119, 126.7548], [34.4035, 126.6108], [34.3175, 126.5844],
- [34.3143, 126.5314], [34.3506, 126.5083], [34.4284, 126.5064],
- [34.4939, 126.4817], [34.5896, 126.3326], [34.6732, 126.2645],
- // 서해안 (목포→인천)
- [34.7200, 126.3011], [34.6946, 126.4256], [34.6979, 126.5245],
- [34.7787, 126.5386], [34.8244, 126.5934], [34.8104, 126.4785],
- [34.8234, 126.4207], [34.9328, 126.3979], [35.0451, 126.3274],
- [35.1542, 126.2911], [35.2169, 126.3605], [35.3144, 126.3959],
- [35.4556, 126.4604], [35.5013, 126.4928], [35.5345, 126.5822],
- [35.5710, 126.6141], [35.5897, 126.5649], [35.6063, 126.4865],
- [35.6471, 126.4885], [35.6693, 126.5419], [35.7142, 126.6016],
- [35.7688, 126.7174], [35.8720, 126.7530], [35.8979, 126.7196],
- [35.9225, 126.6475], [35.9745, 126.6637], [36.0142, 126.6935],
- [36.0379, 126.6823], [36.1050, 126.5971], [36.1662, 126.5404],
- [36.2358, 126.5572], [36.3412, 126.5442], [36.4297, 126.5520],
- [36.4776, 126.5482], [36.5856, 126.5066], [36.6938, 126.4877],
- [36.6780, 126.4330], [36.6512, 126.3888], [36.6893, 126.2307],
- [36.6916, 126.1809], [36.7719, 126.1605], [36.8709, 126.2172],
- [36.9582, 126.3516], [36.9690, 126.4287], [37.0075, 126.4870],
- [37.0196, 126.5777], [36.9604, 126.6867], [36.9484, 126.7845],
- [36.8461, 126.8388], [36.8245, 126.8721], [36.8621, 126.8791],
- [36.9062, 126.9580], [36.9394, 126.9769], [36.9576, 126.9598],
- [36.9757, 126.8689], [37.1027, 126.7874], [37.1582, 126.7761],
- [37.1936, 126.7464], [37.2949, 126.7905], [37.4107, 126.6962],
- [37.4471, 126.6503], [37.5512, 126.6568], [37.6174, 126.6076],
- [37.6538, 126.5802], [37.7165, 126.5634], [37.7447, 126.5777],
- [37.7555, 126.6207], [37.7818, 126.6339], [37.8007, 126.6646],
- [37.8279, 126.6665], [37.9172, 126.6668], [37.9790, 126.7543],
- // DMZ (간소화)
- [38.1066, 126.8789], [38.1756, 126.9400], [38.2405, 127.0097],
- [38.2839, 127.0903], [38.3045, 127.1695], [38.3133, 127.2940],
- [38.3244, 127.5469], [38.3353, 127.7299], [38.3469, 127.7858],
- [38.3066, 127.8207], [38.3250, 127.9001], [38.3150, 128.0083],
- [38.3107, 128.0314], [38.3189, 128.0887], [38.3317, 128.1269],
- [38.3481, 128.1606], [38.3748, 128.2054], [38.4032, 128.2347],
- [38.4797, 128.3064], [38.5339, 128.6952], [38.6177, 128.6560],
-]
+// ── GeoJSON 타입 ──
-// 제주도 — OpenStreetMap 기반 (26 points)
-const JEJU_POINTS: [number, number][] = [
- [33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
- [33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
- [33.0280, 126.0492], [33.0159, 126.4783], [33.0115, 126.5186],
- [33.0143, 126.5572], [33.0231, 126.5970], [33.0182, 126.6432],
- [33.0201, 126.7129], [33.0458, 126.7847], [33.0662, 126.8169],
- [33.0979, 126.8512], [33.1192, 126.9292], [33.1445, 126.9783],
- [33.1683, 127.0129], [33.1974, 127.0430], [33.2226, 127.0634],
- [33.2436, 127.0723], [33.4646, 127.2106], [33.5440, 126.0355],
- [33.5808, 126.0814], [33.5168, 126.0128],
-]
-
-const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]
-
-/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
-function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
- const R = 3440.065
- const dLat = (lat2 - lat1) * Math.PI / 180
- const dLon = (lon2 - lon1) * Math.PI / 180
- const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
- return 2 * R * Math.asin(Math.sqrt(a))
+interface GeoJSONFeature {
+ geometry: {
+ type: string;
+ coordinates: number[][][][] | number[][][];
+ };
}
-/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
-export function estimateDistanceFromCoast(lat: number, lon: number): number {
- let minDist = Infinity
- for (const [cLat, cLon] of ALL_COASTLINE) {
- const dist = haversineNm(lat, lon, cLat, cLon)
- if (dist < minDist) minDist = dist
+// ── 영해기선 폴리곤 (거리 계산용) ──
+
+let cachedBaselineRings: [number, number][][] | null = null;
+let baselineLoadingPromise: Promise<[number, number][][]> | null = null;
+
+function extractOuterRings(geojson: { features: GeoJSONFeature[] }): [number, number][][] {
+ const rings: [number, number][][] = [];
+ for (const feature of geojson.features) {
+ const geom = feature.geometry;
+ if (geom.type === 'MultiPolygon') {
+ const polygons = geom.coordinates as [number, number][][][];
+ for (const polygon of polygons) {
+ rings.push(polygon[0]);
+ }
+ } else if (geom.type === 'Polygon') {
+ const polygon = geom.coordinates as [number, number][][];
+ rings.push(polygon[0]);
+ }
}
- return minDist
+ return rings;
+}
+
+export async function loadTerritorialBaseline(): Promise<[number, number][][]> {
+ if (cachedBaselineRings) return cachedBaselineRings;
+ if (baselineLoadingPromise) return baselineLoadingPromise;
+
+ baselineLoadingPromise = fetch('/data/TB_ZN_TRTSEA_multipolygon.geojson')
+ .then((res) => res.json())
+ .then((data: { features: GeoJSONFeature[] }) => {
+ cachedBaselineRings = extractOuterRings(data);
+ return cachedBaselineRings;
+ });
+
+ return baselineLoadingPromise;
+}
+
+export function getCachedBaseline(): [number, number][][] | null {
+ return cachedBaselineRings;
+}
+
+// ── 구역 경계선 GeoJSON (런타임 로드) ──
+
+interface ZoneGeoJSON {
+ nm: number;
+ rings: [number, number][][];
+}
+
+let cachedZones: ZoneGeoJSON[] | null = null;
+let zoneLoadingPromise: Promise