feat(incidents): 해양 오염물질 배출규정 구역 판별 기능 추가
- GeoJSON 기반 영해기선 거리 계산 및 구역(3/12/25/50해리) 판별 - point-in-polygon 및 point-to-segment 거리 알고리즘 적용 - 해양환경관리법 제22조 기반 배출 규정 표출 - 서해 NLL 경로 좌표 추가 (백령도 부근까지 연장)
This commit is contained in:
부모
a511c0280e
커밋
7cdbc8664f
@ -219,9 +219,9 @@ export async function createAnalysis(input: {
|
|||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
ANALYST_NM, EXEC_STTS_CD
|
ANALYST_NM, EXEC_STTS_CD
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4::numeric, $5::numeric,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END,
|
||||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END,
|
||||||
$6, $7, $8, $9, $10, $11,
|
$6, $7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15, $16,
|
$12, $13, $14, $15, $16,
|
||||||
$17, 'PENDING'
|
$17, 'PENDING'
|
||||||
|
|||||||
1
frontend/public/data/TB_ZN_TRTSEA_multipolygon.geojson
Normal file
1
frontend/public/data/TB_ZN_TRTSEA_multipolygon.geojson
Normal file
File diff suppressed because one or more lines are too long
13547
frontend/public/data/TB_ZN_TRTSEA_multipolygon_12nm_buffer.geojson
Normal file
13547
frontend/public/data/TB_ZN_TRTSEA_multipolygon_12nm_buffer.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
8497
frontend/public/data/TB_ZN_TRTSEA_multipolygon_25nm_buffer.geojson
Normal file
8497
frontend/public/data/TB_ZN_TRTSEA_multipolygon_25nm_buffer.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
21887
frontend/public/data/TB_ZN_TRTSEA_multipolygon_3nm_buffer.geojson
Normal file
21887
frontend/public/data/TB_ZN_TRTSEA_multipolygon_3nm_buffer.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5465
frontend/public/data/TB_ZN_TRTSEA_multipolygon_50nm_buffer.geojson
Normal file
5465
frontend/public/data/TB_ZN_TRTSEA_multipolygon_50nm_buffer.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1
frontend/public/data/territorial-baseline.json
Normal file
1
frontend/public/data/territorial-baseline.json
Normal file
File diff suppressed because one or more lines are too long
@ -777,10 +777,12 @@ export function MapView({
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
||||||
|
const imageUrl = canvas.toDataURL('image/png');
|
||||||
result.push(
|
result.push(
|
||||||
new BitmapLayer({
|
new BitmapLayer({
|
||||||
id: 'hns-dispersion-bitmap',
|
id: 'hns-dispersion-bitmap',
|
||||||
image: canvas,
|
image: imageUrl,
|
||||||
bounds: [minLon, minLat, maxLon, maxLat],
|
bounds: [minLon, minLat, maxLon, maxLat],
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
|
|||||||
@ -14,41 +14,34 @@ type Status = 'forbidden' | 'allowed' | 'conditional'
|
|||||||
interface DischargeRule {
|
interface DischargeRule {
|
||||||
category: string
|
category: string
|
||||||
item: string
|
item: string
|
||||||
zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
zones: [Status, Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25~50NM, 50NM+]
|
||||||
condition?: string
|
condition?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const RULES: DischargeRule[] = [
|
const RULES: DischargeRule[] = [
|
||||||
// 폐기물
|
|
||||||
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
|
||||||
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
|
||||||
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
|
||||||
// 화물잔류물
|
|
||||||
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] },
|
|
||||||
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
|
||||||
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
|
|
||||||
// 음식물 찌꺼기
|
|
||||||
{ category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
|
||||||
{ category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' },
|
|
||||||
// 분뇨
|
// 분뇨
|
||||||
{ category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
|
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
|
||||||
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
|
{ category: '분뇨', item: '분뇨저장탱크', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
|
||||||
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
|
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
|
||||||
// 중수
|
// 음식물찌꺼기
|
||||||
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
|
{ category: '음식물찌꺼기', item: '미분쇄 음식물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'] },
|
||||||
// 수산동식물
|
{ category: '음식물찌꺼기', item: '분쇄·연마 음식물 (25mm 이하)', zones: ['forbidden', 'conditional', 'allowed', 'allowed', 'allowed'], condition: '25mm 이하 개구 스크린 통과 가능시' },
|
||||||
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
|
// 화물잔류물
|
||||||
|
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||||
|
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'] },
|
||||||
|
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
|
||||||
|
// 화물유
|
||||||
|
{ category: '화물유', item: '화물유 섞인 평형수·세정수·선저폐수', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'conditional'], condition: '항해 중, 순간배출률 1해리당 30L 이하, 기름오염방지설비 작동 중' },
|
||||||
|
// 유해액체물질
|
||||||
|
{ category: '유해액체물질', item: '유해액체물질 섞인 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '자항선 7노트/비자항선 4노트 이상, 수심 25m 이상, 수면하 배출구 사용' },
|
||||||
|
// 폐기물
|
||||||
|
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||||
|
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||||
|
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+']
|
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+']
|
||||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
|
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b']
|
||||||
|
|
||||||
function getZoneIndex(distanceNm: number): number {
|
|
||||||
if (distanceNm < 3) return 0
|
|
||||||
if (distanceNm < 12) return 1
|
|
||||||
if (distanceNm < 25) return 2
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: Status }) {
|
function StatusBadge({ status }: { status: Status }) {
|
||||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}>배출불가</span>
|
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}>배출불가</span>
|
||||||
@ -60,11 +53,12 @@ interface DischargeZonePanelProps {
|
|||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
distanceNm: number
|
distanceNm: number
|
||||||
|
zoneIndex: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
export function DischargeZonePanel({ lat, lon, distanceNm, zoneIndex, onClose }: DischargeZonePanelProps) {
|
||||||
const zoneIdx = getZoneIndex(distanceNm)
|
const zoneIdx = zoneIndex
|
||||||
const [expandedCat, setExpandedCat] = useState<string | null>(null)
|
const [expandedCat, setExpandedCat] = useState<string | null>(null)
|
||||||
|
|
||||||
const categories = [...new Set(RULES.map(r => r.category))]
|
const categories = [...new Set(RULES.map(r => r.category))]
|
||||||
@ -104,7 +98,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
<span className="text-[9px] text-fg font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
<span className="text-[9px] text-fg font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[9px] text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
<span className="text-[9px] text-fg-sub font-korean">영해기선 거리</span>
|
||||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||||
{distanceNm.toFixed(1)} NM
|
{distanceNm.toFixed(1)} NM
|
||||||
</span>
|
</span>
|
||||||
@ -194,7 +188,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}>
|
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}>
|
||||||
<div className="text-[7px] text-fg-sub font-korean leading-relaxed">
|
<div className="text-[7px] text-fg-sub font-korean leading-relaxed">
|
||||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있습니다.
|
※ 거리는 영해기선 폴리곤 기준입니다. 구역은 버퍼 폴리곤 포함 여부로 판별됩니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import type { IncidentCompat } from '../services/incidentsApi'
|
|||||||
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'
|
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'
|
||||||
import type { TrajectoryResponse, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
import type { TrajectoryResponse, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||||
import { DischargeZonePanel } from './DischargeZonePanel'
|
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 { useMapStore } from '@common/store/mapStore'
|
||||||
import { useMeasureTool } from '@common/hooks/useMeasureTool'
|
import { useMeasureTool } from '@common/hooks/useMeasureTool'
|
||||||
import { buildMeasureLayers } from '@common/components/map/measureLayers'
|
import { buildMeasureLayers } from '@common/components/map/measureLayers'
|
||||||
@ -127,7 +127,8 @@ export function IncidentsView() {
|
|||||||
|
|
||||||
// Discharge zone mode
|
// Discharge zone mode
|
||||||
const [dischargeMode, setDischargeMode] = useState(false)
|
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
|
// Map style & toggles
|
||||||
const currentMapStyle = useBaseMapStyle(true)
|
const currentMapStyle = useBaseMapStyle(true)
|
||||||
@ -153,6 +154,7 @@ export function IncidentsView() {
|
|||||||
fetchIncidents().then(data => {
|
fetchIncidents().then(data => {
|
||||||
setIncidents(data)
|
setIncidents(data)
|
||||||
})
|
})
|
||||||
|
Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 사고 전환 시 지도 레이어 즉시 초기화
|
// 사고 전환 시 지도 레이어 즉시 초기화
|
||||||
@ -318,7 +320,7 @@ export function IncidentsView() {
|
|||||||
|
|
||||||
// ── 배출 구역 경계선 레이어 ──
|
// ── 배출 구역 경계선 레이어 ──
|
||||||
const dischargeZoneLayers = useMemo(() => {
|
const dischargeZoneLayers = useMemo(() => {
|
||||||
if (!dischargeMode) return []
|
if (!dischargeMode || !baselineLoaded) return []
|
||||||
const zoneLines = getDischargeZoneLines()
|
const zoneLines = getDischargeZoneLines()
|
||||||
return zoneLines.map((line, i) =>
|
return zoneLines.map((line, i) =>
|
||||||
new PathLayer({
|
new PathLayer({
|
||||||
@ -334,7 +336,7 @@ export function IncidentsView() {
|
|||||||
pickable: false,
|
pickable: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}, [dischargeMode])
|
}, [dischargeMode, baselineLoaded])
|
||||||
|
|
||||||
const measureDeckLayers = useMemo(
|
const measureDeckLayers = useMemo(
|
||||||
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
|
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
|
||||||
@ -615,7 +617,8 @@ export function IncidentsView() {
|
|||||||
const lat = e.lngLat.lat
|
const lat = e.lngLat.lat
|
||||||
const lon = e.lngLat.lng
|
const lon = e.lngLat.lng
|
||||||
const distanceNm = estimateDistanceFromCoast(lat, lon)
|
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}
|
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
|
||||||
@ -755,6 +758,7 @@ export function IncidentsView() {
|
|||||||
lat={dischargeInfo.lat}
|
lat={dischargeInfo.lat}
|
||||||
lon={dischargeInfo.lon}
|
lon={dischargeInfo.lon}
|
||||||
distanceNm={dischargeInfo.distanceNm}
|
distanceNm={dischargeInfo.distanceNm}
|
||||||
|
zoneIndex={dischargeInfo.zoneIndex}
|
||||||
onClose={() => setDischargeInfo(null)}
|
onClose={() => setDischargeInfo(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
* 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조
|
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||||
|
*
|
||||||
|
* 구역 경계선: 국립해양조사원 영해기선(TB_ZN_TRTSEA) 버퍼 GeoJSON
|
||||||
|
* 영해기선 데이터: 국립해양조사원 TB_ZN_TRTSEA (EPSG:5179 → WGS84 변환)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
// ── GeoJSON 타입 ──
|
||||||
// [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],
|
|
||||||
]
|
|
||||||
|
|
||||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
interface GeoJSONFeature {
|
||||||
const JEJU_POINTS: [number, number][] = [
|
geometry: {
|
||||||
[33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
|
type: string;
|
||||||
[33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
|
coordinates: number[][][][] | number[][][];
|
||||||
[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) */
|
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 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<ZoneGeoJSON[]> | null = null;
|
||||||
|
|
||||||
|
const ZONE_FILES = [
|
||||||
|
{ nm: 3, file: '/data/TB_ZN_TRTSEA_multipolygon_3nm_buffer.geojson' },
|
||||||
|
{ nm: 12, file: '/data/TB_ZN_TRTSEA_multipolygon_12nm_buffer.geojson' },
|
||||||
|
{ nm: 25, file: '/data/TB_ZN_TRTSEA_multipolygon_25nm_buffer.geojson' },
|
||||||
|
{ nm: 50, file: '/data/TB_ZN_TRTSEA_multipolygon_50nm_buffer.geojson' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function loadZoneGeoJSON(): Promise<ZoneGeoJSON[]> {
|
||||||
|
if (cachedZones) return cachedZones;
|
||||||
|
if (zoneLoadingPromise) return zoneLoadingPromise;
|
||||||
|
|
||||||
|
zoneLoadingPromise = Promise.all(
|
||||||
|
ZONE_FILES.map(async ({ nm, file }) => {
|
||||||
|
const res = await fetch(file);
|
||||||
|
const geojson = await res.json();
|
||||||
|
return { nm, rings: extractOuterRings(geojson) };
|
||||||
|
}),
|
||||||
|
).then((zones) => {
|
||||||
|
cachedZones = zones;
|
||||||
|
return zones;
|
||||||
|
});
|
||||||
|
|
||||||
|
return zoneLoadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedZones(): ZoneGeoJSON[] | null {
|
||||||
|
return cachedZones;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 거리 계산 ──
|
||||||
|
|
||||||
|
/** 두 좌표 간 해리(NM) 계산 (Haversine) */
|
||||||
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const R = 3440.065
|
const R = 3440.065;
|
||||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
const dLon = (lon2 - lon1) * 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
|
const a =
|
||||||
return 2 * R * Math.asin(Math.sqrt(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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
|
/** 점 P에서 선분 AB까지의 최단거리 (NM) — 위도 보정 평면 투영 */
|
||||||
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
function pointToSegmentNm(
|
||||||
let minDist = Infinity
|
pLat: number,
|
||||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
pLon: number,
|
||||||
const dist = haversineNm(lat, lon, cLat, cLon)
|
aLon: number,
|
||||||
if (dist < minDist) minDist = dist
|
aLat: number,
|
||||||
|
bLon: number,
|
||||||
|
bLat: number,
|
||||||
|
): number {
|
||||||
|
const cosLat = Math.cos((pLat * Math.PI) / 180);
|
||||||
|
const ax = (aLon - pLon) * cosLat;
|
||||||
|
const ay = aLat - pLat;
|
||||||
|
const bx = (bLon - pLon) * cosLat;
|
||||||
|
const by = bLat - pLat;
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
let closeLon: number;
|
||||||
|
let closeLat: number;
|
||||||
|
|
||||||
|
if (lenSq < 1e-20) {
|
||||||
|
closeLon = aLon;
|
||||||
|
closeLat = aLat;
|
||||||
|
} else {
|
||||||
|
const t = Math.max(0, Math.min(1, ((-ax) * dx + (-ay) * dy) / lenSq));
|
||||||
|
closeLon = aLon + (bLon - aLon) * t;
|
||||||
|
closeLat = aLat + (bLat - aLat) * t;
|
||||||
}
|
}
|
||||||
return minDist
|
|
||||||
|
return haversineNm(pLat, pLon, closeLat, closeLon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클릭 지점에서 영해기선 폴리곤까지의 최단거리 (NM) — 폴리곤 내부이면 0 */
|
||||||
|
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
||||||
|
if (!cachedBaselineRings) return 0;
|
||||||
|
|
||||||
|
// 영해기선 폴리곤 내부이면 거리 0
|
||||||
|
if (cachedBaselineRings.some((ring) => pointInRing(lon, lat, ring))) return 0;
|
||||||
|
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const ring of cachedBaselineRings) {
|
||||||
|
for (let i = 0; i < ring.length - 1; i++) {
|
||||||
|
const dist = pointToSegmentNm(lat, lon, ring[i][0], ring[i][1], ring[i + 1][0], ring[i + 1][1]);
|
||||||
|
if (dist < minDist) minDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 구역 판별 (Point-in-Polygon) ──
|
||||||
|
|
||||||
|
/** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
|
||||||
|
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
const xi = ring[i][0];
|
||||||
|
const yi = ring[i][1];
|
||||||
|
const xj = ring[j][0];
|
||||||
|
const yj = ring[j][1];
|
||||||
|
|
||||||
|
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 점이 MultiPolygon의 어느 폴리곤에든 포함되는지 */
|
||||||
|
function pointInZone(lon: number, lat: number, rings: [number, number][][]): boolean {
|
||||||
|
return rings.some((ring) => pointInRing(lon, lat, ring));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
* 클릭 위치가 어느 구역에 포함되는지 판별
|
||||||
|
* @returns 0=~3해리, 1=3~12해리, 2=12~25해리, 3=25~50해리, 4=50해리+
|
||||||
*/
|
*/
|
||||||
function offsetCoastline(points: [number, number][], distanceNm: number, outwardSign: number = 1): [number, number][] {
|
export function determineZone(lat: number, lon: number): number {
|
||||||
const degPerNm = 1 / 60
|
if (!cachedZones) return 4;
|
||||||
const result: [number, number][] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
// 작은 구역부터 검사 (3 → 12 → 25 → 50)
|
||||||
const prev = points[(i - 1 + points.length) % points.length]
|
const sortedZones = [...cachedZones].sort((a, b) => a.nm - b.nm);
|
||||||
const curr = points[i]
|
|
||||||
const next = points[(i + 1) % points.length]
|
|
||||||
|
|
||||||
const cosLat = Math.cos(curr[0] * Math.PI / 180)
|
for (let i = 0; i < sortedZones.length; i++) {
|
||||||
const dx0 = (curr[1] - prev[1]) * cosLat
|
if (pointInZone(lon, lat, sortedZones[i].rings)) {
|
||||||
const dy0 = curr[0] - prev[0]
|
return i; // 0=3nm 내, 1=12nm 내, 2=25nm 내, 3=50nm 내
|
||||||
const dx1 = (next[1] - curr[1]) * cosLat
|
}
|
||||||
const dy1 = next[0] - curr[0]
|
}
|
||||||
|
return 4; // 50해리+
|
||||||
let nx = -(dy0 + dy1) / 2
|
|
||||||
let ny = (dx0 + dx1) / 2
|
|
||||||
const len = Math.sqrt(nx * nx + ny * ny) || 1
|
|
||||||
nx /= len
|
|
||||||
ny /= len
|
|
||||||
|
|
||||||
const latOff = outwardSign * nx * distanceNm * degPerNm
|
|
||||||
const lonOff = outwardSign * ny * distanceNm * degPerNm / cosLat
|
|
||||||
|
|
||||||
result.push([curr[0] + latOff, curr[1] + lonOff])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
// ── 구역 경계선 생성 ──
|
||||||
}
|
|
||||||
|
|
||||||
export interface ZoneLine {
|
export interface ZoneLine {
|
||||||
path: [number, number][]
|
path: [number, number][]; // [lon, lat]
|
||||||
color: [number, number, number, number]
|
color: [number, number, number, number];
|
||||||
label: string
|
label: string;
|
||||||
distanceNm: number
|
distanceNm: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZONE_STYLES: { nm: number; color: [number, number, number, number]; label: string }[] = [
|
||||||
|
{ nm: 3, color: [239, 68, 68, 180], label: '3해리' },
|
||||||
|
{ nm: 12, color: [249, 115, 22, 160], label: '12해리' },
|
||||||
|
{ nm: 25, color: [234, 179, 8, 140], label: '25해리' },
|
||||||
|
{ nm: 50, color: [100, 116, 139, 120], label: '50해리' },
|
||||||
|
];
|
||||||
|
|
||||||
export function getDischargeZoneLines(): ZoneLine[] {
|
export function getDischargeZoneLines(): ZoneLine[] {
|
||||||
const zones = [
|
if (!cachedZones) return [];
|
||||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
|
||||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
|
||||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const lines: ZoneLine[] = []
|
const lines: ZoneLine[] = [];
|
||||||
for (const zone of zones) {
|
for (const zone of cachedZones) {
|
||||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
|
const style = ZONE_STYLES.find((s) => s.nm === zone.nm);
|
||||||
|
if (!style) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < zone.rings.length; i++) {
|
||||||
lines.push({
|
lines.push({
|
||||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
path: zone.rings[i],
|
||||||
color: zone.color,
|
color: style.color,
|
||||||
label: zone.label,
|
label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
|
||||||
distanceNm: zone.nm,
|
distanceNm: style.nm,
|
||||||
})
|
});
|
||||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1)
|
|
||||||
lines.push({
|
|
||||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
|
||||||
color: zone.color,
|
|
||||||
label: `${zone.label} (제주)`,
|
|
||||||
distanceNm: zone.nm,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return lines
|
}
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user