feat(map): HNS ���� ���� ���� ? SR �ΰ��ڿ� ��������, ����Ʈ���� ����, ���̾� ���� ���� #160

병합
jhkang feature/hns 에서 develop 로 10 commits 를 머지했습니다 2026-04-06 22:38:38 +09:00
11개의 변경된 파일49649개의 추가작업 그리고 179개의 파일을 삭제
Showing only changes of commit 7cdbc8664f - Show all commits

파일 보기

@ -219,9 +219,9 @@ export async function createAnalysis(input: {
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
ANALYST_NM, EXEC_STTS_CD
) VALUES (
$1, $2, $3, $4, $5,
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 $4 || ' + ' || $5 END,
$1, $2, $3, $4::numeric, $5::numeric,
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::text || ' + ' || $5::text END,
$6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16,
$17, 'PENDING'

File diff suppressed because one or more lines are too long

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

File diff suppressed because one or more lines are too long

파일 보기

@ -777,10 +777,12 @@ export function MapView({
ctx.fill();
}
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
const imageUrl = canvas.toDataURL('image/png');
result.push(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: canvas,
image: imageUrl,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,

파일 보기

@ -14,41 +14,34 @@ type Status = 'forbidden' | 'allowed' | 'conditional'
interface DischargeRule {
category: 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
}
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'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
// 중수
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
// 수산동식물
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
{ category: '분뇨', item: '분뇨저장탱크', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', '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: ['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_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
function getZoneIndex(distanceNm: number): number {
if (distanceNm < 3) return 0
if (distanceNm < 12) return 1
if (distanceNm < 25) return 2
return 3
}
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+']
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b']
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>
@ -60,11 +53,12 @@ interface DischargeZonePanelProps {
lat: number
lon: number
distanceNm: number
zoneIndex: number
onClose: () => void
}
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
const zoneIdx = getZoneIndex(distanceNm)
export function DischargeZonePanel({ lat, lon, distanceNm, zoneIndex, onClose }: DischargeZonePanelProps) {
const zoneIdx = zoneIndex
const [expandedCat, setExpandedCat] = useState<string | null>(null)
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>
</div>
<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] }}>
{distanceNm.toFixed(1)} NM
</span>
@ -194,7 +188,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
{/* Footer */}
<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>
</div>
</div>

파일 보기

@ -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)}
/>
)}

파일 보기

@ -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<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 {
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));
}
/** 점 P에서 선분 AB까지의 최단거리 (NM) — 위도 보정 평면 투영 */
function pointToSegmentNm(
pLat: number,
pLon: number,
aLon: number,
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 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][] {
const degPerNm = 1 / 60
const result: [number, number][] = []
export function determineZone(lat: number, lon: number): number {
if (!cachedZones) return 4;
for (let i = 0; i < points.length; i++) {
const prev = points[(i - 1 + points.length) % points.length]
const curr = points[i]
const next = points[(i + 1) % points.length]
// 작은 구역부터 검사 (3 → 12 → 25 → 50)
const sortedZones = [...cachedZones].sort((a, b) => a.nm - b.nm);
const cosLat = Math.cos(curr[0] * Math.PI / 180)
const dx0 = (curr[1] - prev[1]) * cosLat
const dy0 = curr[0] - prev[0]
const dx1 = (next[1] - curr[1]) * cosLat
const dy1 = next[0] - curr[0]
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])
for (let i = 0; i < sortedZones.length; i++) {
if (pointInZone(lon, lat, sortedZones[i].rings)) {
return i; // 0=3nm 내, 1=12nm 내, 2=25nm 내, 3=50nm 내
}
}
return result
return 4; // 50해리+
}
// ── 구역 경계선 생성 ──
export interface ZoneLine {
path: [number, number][]
color: [number, number, number, number]
label: string
distanceNm: number
path: [number, number][]; // [lon, lat]
color: [number, number, number, number];
label: string;
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[] {
const zones = [
{ 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해리' },
]
if (!cachedZones) return [];
const lines: ZoneLine[] = []
for (const zone of zones) {
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
lines.push({
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
color: zone.color,
label: zone.label,
distanceNm: zone.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,
})
const lines: ZoneLine[] = [];
for (const zone of cachedZones) {
const style = ZONE_STYLES.find((s) => s.nm === zone.nm);
if (!style) continue;
for (let i = 0; i < zone.rings.length; i++) {
lines.push({
path: zone.rings[i],
color: style.color,
label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
distanceNm: style.nm,
});
}
}
return lines
return lines;
}