Merge pull request 'feat(map): HNS ���� ���� ���� ? SR �ΰ��ڿ� ��������, ����Ʈ���� ����, ���̾� ���� ����' (#160) from feature/hns into develop

This commit is contained in:
jhkang 2026-04-06 22:38:38 +09:00
커밋 4d71ca3a01
22개의 변경된 파일44288개의 추가작업 그리고 388개의 파일을 삭제

파일 보기

@ -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'

파일 보기

@ -54,6 +54,28 @@ router.get('/vworld/:z/:y/:x', async (req, res) => {
await proxyUpstream(tileUrl, res, 'image/jpeg'); await proxyUpstream(tileUrl, res, 'image/jpeg');
}); });
// ─── SR 민감자원 벡터타일 ───
// GET /api/tiles/sr/tilejson — SR TileJSON 프록시 (source-layer 메타데이터)
router.get('/sr/tilejson', async (_req, res) => {
await proxyUpstream(`${ENC_UPSTREAM}/sr`, res, 'application/json');
});
// GET /api/tiles/sr/style — SR 스타일 JSON 프록시 (레이어별 type/paint/layout 정의)
router.get('/sr/style', async (_req, res) => {
await proxyUpstream(`${ENC_UPSTREAM}/style/sr`, res, 'application/json');
});
// GET /api/tiles/sr/:z/:x/:y — SR(민감자원) 벡터타일 프록시
router.get('/sr/:z/:x/:y', async (req, res) => {
const { z, x, y } = req.params;
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
res.status(400).json({ error: '잘못된 타일 좌표' });
return;
}
await proxyUpstream(`${ENC_UPSTREAM}/sr/${z}/${x}/${y}`, res, 'application/x-protobuf');
});
// ─── S-57 전자해도 (ENC) ─── // ─── S-57 전자해도 (ENC) ───
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹 // tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹

파일 보기

@ -4,6 +4,16 @@
## [Unreleased] ## [Unreleased]
### 추가
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가
- 해양 오염물질 배출규정 구역 판별 기능 추가
### 변경
- 지도: 항상 라이트 모드로 고정 (앱 다크 모드와 무관)
- 지도: lightMode prop 제거, useThemeStore 기반 테마 전환 통합
- 레이어 색상 상태를 OilSpillView로 끌어올림
- 대한민국 해리 GeoJSON 데이터 갱신
## [2026-04-02] ## [2026-04-02]
### 변경 ### 변경

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

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

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

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

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

파일 보기

@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useMap } from '@vis.gl/react-maplibre'; import { useMap } from '@vis.gl/react-maplibre';
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
import { useThemeStore } from '@common/store/themeStore';
interface HydrParticleOverlayProps { interface HydrParticleOverlayProps {
hydrStep: HydrDataStep | null; hydrStep: HydrDataStep | null;
lightMode?: boolean;
} }
const PARTICLE_COUNT = 3000; const PARTICLE_COUNT = 3000;
@ -27,8 +27,8 @@ interface Particle {
export default function HydrParticleOverlay({ export default function HydrParticleOverlay({
hydrStep, hydrStep,
lightMode = false,
}: HydrParticleOverlayProps) { }: HydrParticleOverlayProps) {
const lightMode = useThemeStore((s) => s.theme) === 'light';
const { current: map } = useMap(); const { current: map } = useMap();
const animRef = useRef<number>(); const animRef = useRef<number>();

파일 보기

@ -17,6 +17,7 @@ import { MeasureOverlay } from './MeasureOverlay'
import { useMeasureTool } from '@common/hooks/useMeasureTool' import { useMeasureTool } from '@common/hooks/useMeasureTool'
import { hexToRgba } from './mapUtils' import { hexToRgba } from './mapUtils'
import { S57EncOverlay } from './S57EncOverlay' import { S57EncOverlay } from './S57EncOverlay'
import { SrOverlay } from './SrOverlay'
import { useMapStore } from '@common/store/mapStore' import { useMapStore } from '@common/store/mapStore'
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
@ -112,6 +113,7 @@ interface MapViewProps {
drawingPoints?: BoomLineCoord[] drawingPoints?: BoomLineCoord[]
layerOpacity?: number layerOpacity?: number
layerBrightness?: number layerBrightness?: number
layerColors?: Record<string, string>
backtrackReplay?: { backtrackReplay?: {
isActive: boolean isActive: boolean
ships: ReplayShip[] ships: ReplayShip[]
@ -142,8 +144,6 @@ interface MapViewProps {
analysisPolygonPoints?: Array<{ lat: number; lon: number }> analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number analysisCircleRadiusM?: number
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean showOverlays?: boolean
} }
@ -307,6 +307,7 @@ export function MapView({
drawingPoints = [], drawingPoints = [],
layerOpacity = 50, layerOpacity = 50,
layerBrightness = 50, layerBrightness = 50,
layerColors,
backtrackReplay, backtrackReplay,
sensitiveResources = [], sensitiveResources = [],
sensitiveResourceGeojson, sensitiveResourceGeojson,
@ -328,9 +329,9 @@ export function MapView({
analysisPolygonPoints = [], analysisPolygonPoints = [],
analysisCircleCenter, analysisCircleCenter,
analysisCircleRadiusM = 0, analysisCircleRadiusM = 0,
lightMode = false,
showOverlays = true, showOverlays = true,
}: MapViewProps) { }: MapViewProps) {
const lightMode = true
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore()
const { handleMeasureClick } = useMeasureTool() const { handleMeasureClick } = useMeasureTool()
const isControlled = externalCurrentTime !== undefined const isControlled = externalCurrentTime !== undefined
@ -777,10 +778,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,
@ -1100,8 +1103,8 @@ export function MapView({
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
]) ])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 // 3D 모드 / 테마에 따른 지도 스타일 전환
const currentMapStyle = useBaseMapStyle(lightMode) const currentMapStyle = useBaseMapStyle()
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
@ -1134,6 +1137,9 @@ export function MapView({
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */} {/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
<S57EncOverlay visible={mapToggles['s57'] ?? false} /> <S57EncOverlay visible={mapToggles['s57'] ?? false} />
{/* SR 민감자원 벡터타일 오버레이 */}
<SrOverlay enabledLayers={enabledLayers} opacity={layerOpacity} layerColors={layerColors} />
{/* WMS 레이어 */} {/* WMS 레이어 */}
{wmsLayers.map(layer => ( {wmsLayers.map(layer => (
<Source <Source
@ -1162,7 +1168,7 @@ export function MapView({
{/* 해류 파티클 오버레이 */} {/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && showCurrent && ( {hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} /> <HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
)} )}
{/* 사고 위치 마커 (MapLibre Marker) */} {/* 사고 위치 마커 (MapLibre Marker) */}

파일 보기

@ -0,0 +1,329 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useMap } from '@vis.gl/react-maplibre';
import { API_BASE_URL } from '../../services/api';
import { useLayerTree } from '../../hooks/useLayers';
import type { Layer } from '../../services/layerService';
import { getOpacityProp, getColorProp } from './srStyles';
const SR_SOURCE_ID = 'sr';
const PROXY_PREFIX = `${API_BASE_URL}/tiles`;
// MapLibre 내부 요청은 절대 URL이 필요
const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http')
? PROXY_PREFIX
: `${window.location.origin}${PROXY_PREFIX}`;
// ─── SR 스타일 JSON (Martin style/sr) ───
interface SrStyleLayer {
id: string;
type: string;
source: string;
'source-layer': string;
paint?: Record<string, unknown>;
layout?: Record<string, unknown>;
filter?: unknown;
minzoom?: number;
maxzoom?: number;
}
interface SrStyle {
sources: Record<string, { type: string; tiles?: string[]; url?: string }>;
layers: SrStyleLayer[];
}
let cachedStyle: SrStyle | null = null;
async function loadSrStyle(): Promise<SrStyle> {
if (cachedStyle) return cachedStyle;
const res = await fetch(`${PROXY_PREFIX}/sr/style`);
if (!res.ok) throw new Error(`SR style fetch failed: ${res.status}`);
cachedStyle = await res.json();
return cachedStyle!;
}
// ─── 헬퍼: wmsLayer(mpc:XXX)에서 코드 추출 ───
function extractCode(wmsLayer: string): string | null {
// mpc:468 → '468', mpc:386_spr → '386', mpc:kcg → 'kcg', mpc:kcg_ofi → 'kcg_ofi'
const match = wmsLayer.match(/^mpc:(.+?)(?:_(spr|sum|fal|win|apr))?$/);
return match ? match[1] : null;
}
// ─── layerTree → SR 매핑 구축 ───
interface SrMapping {
layerCd: string; // DB LAYER_CD (예: 'LYR001002001004005')
code: string; // mpc: 뒤 코드 (예: '468', 'kcg', '3')
name: string; // DB 레이어명 (예: '갯벌', '경찰청', '군산')
}
// ─── source-layer → DB layerCd 매칭 ───
function matchSourceLayer(sourceLayer: string, mappings: SrMapping[]): string[] {
// 1차: 숫자 접두사 매칭 (468_갯벌 → code '468')
const numMatch = sourceLayer.match(/^(\d+)/);
if (numMatch) {
const code = numMatch[1];
const matched = mappings.filter(m => m.code === code);
if (matched.length > 0) return matched.map(m => m.layerCd);
}
// 2차: 이름 정확 일치 (경찰청 = 경찰청)
const exactMatch = mappings.filter(m => sourceLayer === m.name);
if (exactMatch.length > 0) return exactMatch.map(m => m.layerCd);
// 3차: 접미사 일치 (해경관할구역-군산 → name '군산')
const suffixMatch = mappings.filter(m =>
sourceLayer.endsWith(`-${m.name}`) || sourceLayer.endsWith(`_${m.name}`)
);
if (suffixMatch.length > 0) return suffixMatch.map(m => m.layerCd);
return [];
}
function buildSrMappings(layers: Layer[]): SrMapping[] {
const result: SrMapping[] = [];
function traverse(nodes: Layer[]) {
for (const node of nodes) {
if (node.wmsLayer) {
const code = extractCode(node.wmsLayer);
if (code) {
result.push({ layerCd: node.id, code, name: node.name });
}
}
if (node.children) traverse(node.children);
}
}
traverse(layers);
return result;
}
// ─── 컴포넌트 ───
interface SrOverlayProps {
enabledLayers: Set<string>;
opacity?: number;
layerColors?: Record<string, string>;
}
export function SrOverlay({ enabledLayers, opacity = 100, layerColors }: SrOverlayProps) {
const { current: mapRef } = useMap();
const { data: layerTree } = useLayerTree();
const addedLayersRef = useRef<Set<string>>(new Set());
const sourceAddedRef = useRef(false);
const [style, setStyle] = useState<SrStyle | null>(cachedStyle);
// 스타일 JSON 로드 (최초 1회)
useEffect(() => {
if (style) return;
loadSrStyle()
.then(setStyle)
.catch((err) => console.error('[SrOverlay] SR 스타일 로드 실패:', err));
}, [style]);
const ensureSource = useCallback((map: maplibregl.Map) => {
if (sourceAddedRef.current) return;
if (map.getSource(SR_SOURCE_ID)) {
sourceAddedRef.current = true;
return;
}
map.addSource(SR_SOURCE_ID, {
type: 'vector',
tiles: [`${ABSOLUTE_PREFIX}/sr/{z}/{x}/{y}`],
maxzoom: 14,
});
sourceAddedRef.current = true;
}, []);
const removeAll = useCallback((map: maplibregl.Map) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(map as any).style) {
addedLayersRef.current.clear();
sourceAddedRef.current = false;
return;
}
for (const id of addedLayersRef.current) {
if (map.getLayer(id)) map.removeLayer(id);
}
addedLayersRef.current.clear();
if (map.getSource(SR_SOURCE_ID)) map.removeSource(SR_SOURCE_ID);
sourceAddedRef.current = false;
}, []);
// enabledLayers 변경 시 레이어 동기화
useEffect(() => {
const map = mapRef?.getMap();
if (!map || !layerTree || !style) return;
const mappings = buildSrMappings(layerTree);
// source-layer → DB layerCd[] 매핑
const sourceLayerToIds = new Map<string, string[]>();
for (const sl of style.layers) {
const ids = matchSourceLayer(sl['source-layer'], mappings);
if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids);
}
// 커스텀 색상 조회 (source-layer name 기반)
const getCustomColor = (sourceLayer: string): string | undefined => {
const ids = sourceLayerToIds.get(sourceLayer);
if (!ids) return undefined;
for (const id of ids) {
const c = layerColors?.[id];
if (c) return c;
}
return undefined;
};
// style JSON 레이어 중 활성화된 DB 레이어에 해당하는 스타일 레이어 필터
const enabledStyleLayers = style.layers.filter(sl => {
const ids = sourceLayerToIds.get(sl['source-layer']);
return ids && ids.some(id => enabledLayers.has(id));
});
const syncLayers = () => {
ensureSource(map);
const activeLayerIds = new Set<string>();
// 활성화된 레이어 추가 또는 visible 설정
for (const sl of enabledStyleLayers) {
const layerId = `sr-${sl.id}`;
activeLayerIds.add(layerId);
const customColor = getCustomColor(sl['source-layer']);
const layerType = sl.type as 'fill' | 'line' | 'circle';
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', 'visible');
// 기존 레이어에 커스텀 색상 적용
const colorProp = getColorProp(layerType);
if (customColor) {
map.setPaintProperty(layerId, colorProp, customColor);
} else {
const orig = sl.paint?.[colorProp];
if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig);
}
} else {
const opacityValue = opacity / 100;
const opacityProp = getOpacityProp(layerType);
const paint = { ...sl.paint, [opacityProp]: opacityValue };
// 커스텀 색상 적용
if (customColor) {
const colorProp = getColorProp(layerType);
paint[colorProp] = customColor;
if (sl.type === 'fill') {
paint['fill-outline-color'] = customColor;
}
}
try {
map.addLayer({
id: layerId,
type: sl.type,
source: SR_SOURCE_ID,
'source-layer': sl['source-layer'],
paint,
layout: { visibility: 'visible', ...sl.layout },
...(sl.filter ? { filter: sl.filter } : {}),
...(sl.minzoom !== undefined && { minzoom: sl.minzoom }),
...(sl.maxzoom !== undefined && { maxzoom: sl.maxzoom }),
} as maplibregl.AddLayerObject);
addedLayersRef.current.add(layerId);
} catch (err) {
console.warn(`[SrOverlay] 레이어 추가 실패 (${sl.id}):`, err);
}
}
}
// 비활성화된 레이어 숨김
for (const layerId of addedLayersRef.current) {
if (!activeLayerIds.has(layerId)) {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', 'none');
}
}
}
};
if (map.isStyleLoaded()) {
syncLayers();
} else {
map.once('style.load', syncLayers);
return () => { map.off('style.load', syncLayers); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabledLayers, layerTree, style, mapRef, layerColors]);
// opacity 변경 시 paint 업데이트
useEffect(() => {
const map = mapRef?.getMap();
if (!map || !style) return;
const opacityValue = opacity / 100;
for (const layerId of addedLayersRef.current) {
if (!map.getLayer(layerId)) continue;
const originalId = layerId.replace(/^sr-/, '');
const sl = style.layers.find((l) => l.id === originalId);
if (sl) {
const prop = getOpacityProp(sl.type as 'fill' | 'line' | 'circle');
map.setPaintProperty(layerId, prop, opacityValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opacity, mapRef]);
// layerColors 변경 시 paint 업데이트
useEffect(() => {
const map = mapRef?.getMap();
if (!map || !style || !layerTree) return;
const mappings = buildSrMappings(layerTree);
const sourceLayerToIds = new Map<string, string[]>();
for (const sl of style.layers) {
const ids = matchSourceLayer(sl['source-layer'], mappings);
if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids);
}
const getCustomColor = (sourceLayer: string): string | undefined => {
const ids = sourceLayerToIds.get(sourceLayer);
if (!ids) return undefined;
for (const id of ids) {
const c = layerColors?.[id];
if (c) return c;
}
return undefined;
};
for (const layerId of addedLayersRef.current) {
if (!map.getLayer(layerId)) continue;
const originalId = layerId.replace(/^sr-/, '');
const sl = style.layers.find((l) => l.id === originalId);
if (!sl) continue;
const customColor = getCustomColor(sl['source-layer']);
const layerType = sl.type as 'fill' | 'line' | 'circle';
const colorProp = getColorProp(layerType);
if (customColor) {
map.setPaintProperty(layerId, colorProp, customColor);
} else {
const orig = sl.paint?.[colorProp];
if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig as string);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layerColors, mapRef, style, layerTree]);
// cleanup on unmount
useEffect(() => {
const map = mapRef?.getMap();
if (!map) return;
return () => { removeAll(map); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapRef]);
return null;
}

파일 보기

@ -0,0 +1,20 @@
// SR(민감자원) 벡터타일 헬퍼
// 스타일은 Martin style/sr JSON에서 동적 로드 (SrOverlay에서 사용)
/** opacity 속성 키를 레이어 타입에 따라 반환 */
export function getOpacityProp(type: 'fill' | 'line' | 'circle'): string {
switch (type) {
case 'fill': return 'fill-opacity';
case 'line': return 'line-opacity';
case 'circle': return 'circle-opacity';
}
}
/** color 속성 키를 레이어 타입에 따라 반환 */
export function getColorProp(type: 'fill' | 'line' | 'circle'): string {
switch (type) {
case 'fill': return 'fill-color';
case 'line': return 'line-color';
case 'circle': return 'circle-color';
}
}

파일 보기

@ -1,17 +1,15 @@
import type { StyleSpecification } from 'maplibre-gl'; import type { StyleSpecification } from 'maplibre-gl';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { import {
BASE_STYLE,
LIGHT_STYLE, LIGHT_STYLE,
SATELLITE_3D_STYLE, SATELLITE_3D_STYLE,
ENC_EMPTY_STYLE, ENC_EMPTY_STYLE,
} from '@common/components/map/mapStyles'; } from '@common/components/map/mapStyles';
export function useBaseMapStyle(lightMode = false): StyleSpecification { export function useBaseMapStyle(): StyleSpecification {
const mapToggles = useMapStore((s) => s.mapToggles); const mapToggles = useMapStore((s) => s.mapToggles);
if (mapToggles.s57) return ENC_EMPTY_STYLE; if (mapToggles.s57) return ENC_EMPTY_STYLE;
if (mapToggles.threeD) return SATELLITE_3D_STYLE; if (mapToggles.threeD) return SATELLITE_3D_STYLE;
if (lightMode) return LIGHT_STYLE; return LIGHT_STYLE;
return BASE_STYLE;
} }

파일 보기

@ -12,102 +12,36 @@ import { useState } from 'react';
type Status = 'forbidden' | 'allowed' | 'conditional'; 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', 'conditional', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
category: '분뇨', { category: '분뇨', item: '분뇨저장탱크', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
item: '분뇨저장장치', { category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], // 음식물찌꺼기
condition: '항속 4노트 이상시 서서히 배출', { category: '음식물찌꺼기', item: '미분쇄 음식물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'] },
}, { category: '음식물찌꺼기', item: '분쇄·연마 음식물 (25mm 이하)', zones: ['forbidden', 'conditional', 'allowed', 'allowed', 'allowed'], condition: '25mm 이하 개구 스크린 통과 가능시' },
{ // 화물잔류물
category: '분뇨', { category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'] },
item: '분뇨마쇄소독장치', { category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'] },
zones: ['forbidden', 'conditional', 'conditional', 'conditional'], { category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능', // 화물유
}, { category: '화물유', item: '화물유 섞인 평형수·세정수·선저폐수', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'conditional'], condition: '항해 중, 순간배출률 1해리당 30L 이하, 기름오염방지설비 작동 중' },
{ // 유해액체물질
category: '분뇨', { category: '유해액체물질', item: '유해액체물질 섞인 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'], condition: '자항선 7노트/비자항선 4노트 이상, 수심 25m 이상, 수면하 배출구 사용' },
item: '분뇨처리장치', // 폐기물
zones: ['allowed', 'allowed', 'allowed', 'allowed'], { category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가', { category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
}, { category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'] },
// 중수 ]
{
category: '중수',
item: '거주구역 중수',
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가',
},
// 수산동식물
{
category: '수산동식물',
item: '자연기원물질',
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면',
},
];
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') if (status === 'forbidden')
@ -139,15 +73,16 @@ function StatusBadge({ status }: { status: Status }) {
} }
interface DischargeZonePanelProps { interface DischargeZonePanelProps {
lat: number; lat: number
lon: number; lon: number
distanceNm: number; distanceNm: number
onClose: () => void; zoneIndex: number
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))];
@ -193,7 +128,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
</span> </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>
@ -296,7 +231,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }} 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,10 +127,11 @@ 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()
const mapToggles = useMapStore((s) => s.mapToggles) const mapToggles = useMapStore((s) => s.mapToggles)
// Measure tool // Measure tool
@ -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,195 +4,102 @@
* : * :
* 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][] = [ interface GeoJSONFeature {
// 동해안 (북→남) geometry: {
[38.6177, 128.656], type: string;
[38.5504, 128.4092], coordinates: number[][][][] | number[][][];
[38.4032, 128.7767], };
[38.1904, 128.8902], }
[38.0681, 128.9977],
[37.9726, 129.0715], // ── 영해기선 폴리곤 (거리 계산용) ──
[37.8794, 129.1721],
[37.8179, 129.2397], let cachedBaselineRings: [number, number][][] | null = null;
[37.6258, 129.3669], let baselineLoadingPromise: Promise<[number, number][][]> | null = null;
[37.5053, 129.4577],
[37.3617, 129.57], function extractOuterRings(geojson: { features: GeoJSONFeature[] }): [number, number][][] {
[37.1579, 129.6538], const rings: [number, number][][] = [];
[37.0087, 129.6706], for (const feature of geojson.features) {
[36.6618, 129.721], const geom = feature.geometry;
[36.3944, 129.6827], if (geom.type === 'MultiPolygon') {
[36.2052, 129.7641], const polygons = geom.coordinates as [number, number][][][];
[35.9397, 129.8124], for (const polygon of polygons) {
[35.6272, 129.7121], rings.push(polygon[0]);
[35.4732, 129.6908], }
[35.2843, 129.5924], } else if (geom.type === 'Polygon') {
[35.141, 129.4656], const polygon = geom.coordinates as [number, number][][];
[35.0829, 129.2125], rings.push(polygon[0]);
// 남해안 (부산→여수→목포) }
[34.895, 129.0658], }
[34.205, 128.3063], return rings;
[35.022, 128.0362], }
[34.9663, 127.8732],
[34.9547, 127.7148], export async function loadTerritorialBaseline(): Promise<[number, number][][]> {
[34.8434, 127.6625], if (cachedBaselineRings) return cachedBaselineRings;
[34.7826, 127.7422], if (baselineLoadingPromise) return baselineLoadingPromise;
[34.6902, 127.6324],
[34.8401, 127.5236], baselineLoadingPromise = fetch('/data/대한민국.geojson')
[34.823, 127.4043], .then((res) => res.json())
[34.6882, 127.4234], .then((data: { features: GeoJSONFeature[] }) => {
[34.6252, 127.4791], cachedBaselineRings = extractOuterRings(data);
[34.5525, 127.4012], return cachedBaselineRings;
[34.4633, 127.3246], });
[34.5461, 127.1734],
[34.6617, 127.2605], return baselineLoadingPromise;
[34.7551, 127.2471], }
[34.6069, 127.0308],
[34.4389, 126.8975], export function getCachedBaseline(): [number, number][][] | null {
[34.4511, 126.8263], return cachedBaselineRings;
[34.4949, 126.7965], }
[34.5119, 126.7548],
[34.4035, 126.6108], // ── 구역 경계선 GeoJSON (런타임 로드) ──
[34.3175, 126.5844],
[34.3143, 126.5314], interface ZoneGeoJSON {
[34.3506, 126.5083], nm: number;
[34.4284, 126.5064], rings: [number, number][][];
[34.4939, 126.4817], }
[34.5896, 126.3326],
[34.6732, 126.2645], let cachedZones: ZoneGeoJSON[] | null = null;
// 서해안 (목포→인천) let zoneLoadingPromise: Promise<ZoneGeoJSON[]> | null = null;
[34.72, 126.3011],
[34.6946, 126.4256], const ZONE_FILES = [
[34.6979, 126.5245], { nm: 3, file: '/data/대한민국_3해리.geojson' },
[34.7787, 126.5386], { nm: 12, file: '/data/대한민국_12해리.geojson' },
[34.8244, 126.5934], { nm: 25, file: '/data/대한민국_25해리.geojson' },
[34.8104, 126.4785], { nm: 50, file: '/data/대한민국_50해리.geojson' },
[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.571, 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.872, 126.753],
[35.8979, 126.7196],
[35.9225, 126.6475],
[35.9745, 126.6637],
[36.0142, 126.6935],
[36.0379, 126.6823],
[36.105, 126.5971],
[36.1662, 126.5404],
[36.2358, 126.5572],
[36.3412, 126.5442],
[36.4297, 126.552],
[36.4776, 126.5482],
[36.5856, 126.5066],
[36.6938, 126.4877],
[36.678, 126.433],
[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.969, 126.4287],
[37.0075, 126.487],
[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.958],
[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.979, 126.7543],
// DMZ (간소화)
[38.1066, 126.8789],
[38.1756, 126.94],
[38.2405, 127.0097],
[38.2839, 127.0903],
[38.3045, 127.1695],
[38.3133, 127.294],
[38.3244, 127.5469],
[38.3353, 127.7299],
[38.3469, 127.7858],
[38.3066, 127.8207],
[38.325, 127.9001],
[38.315, 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.656],
]; ];
// 제주도 — OpenStreetMap 기반 (26 points) export async function loadZoneGeoJSON(): Promise<ZoneGeoJSON[]> {
const JEJU_POINTS: [number, number][] = [ if (cachedZones) return cachedZones;
[33.5168, 126.0128], if (zoneLoadingPromise) return zoneLoadingPromise;
[33.5067, 126.0073],
[33.119, 126.0102],
[33.0938, 126.0176],
[33.0748, 126.0305],
[33.0556, 126.0355],
[33.028, 126.0492],
[33.0159, 126.4783],
[33.0115, 126.5186],
[33.0143, 126.5572],
[33.0231, 126.597],
[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.043],
[33.2226, 127.0634],
[33.2436, 127.0723],
[33.4646, 127.2106],
[33.544, 126.0355],
[33.5808, 126.0814],
[33.5168, 126.0128],
];
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]; 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;
});
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */ 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;
@ -203,83 +110,129 @@ function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): nu
return 2 * R * Math.asin(Math.sqrt(a)); return 2 * R * Math.asin(Math.sqrt(a));
} }
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */ /** 점 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 { 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; let minDist = Infinity;
for (const [cLat, cLon] of ALL_COASTLINE) { for (const ring of cachedBaselineRings) {
const dist = haversineNm(lat, lon, cLat, cLon); for (let i = 0; i < ring.length - 1; i++) {
if (dist < minDist) minDist = dist; 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; return minDist;
} }
/** // ── 구역 판별 (Point-in-Polygon) ──
* (NM) ()
*/
function offsetCoastline(
points: [number, number][],
distanceNm: number,
outwardSign: number = 1,
): [number, number][] {
const degPerNm = 1 / 60;
const result: [number, number][] = [];
for (let i = 0; i < points.length; i++) { /** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
const prev = points[(i - 1 + points.length) % points.length]; function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
const curr = points[i]; let inside = false;
const next = points[(i + 1) % points.length]; 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];
const cosLat = Math.cos((curr[0] * Math.PI) / 180); if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
const dx0 = (curr[1] - prev[1]) * cosLat; inside = !inside;
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]);
} }
return inside;
return result;
} }
/** 점이 MultiPolygon의 어느 폴리곤에든 포함되는지 */
function pointInZone(lon: number, lat: number, rings: [number, number][][]): boolean {
return rings.some((ring) => pointInRing(lon, lat, ring));
}
/**
*
* @returns 0=~3, 1=3~12, 2=12~25, 3=25~50, 4=50+
*/
export function determineZone(lat: number, lon: number): number {
if (!cachedZones) return 4;
// 작은 구역부터 검사 (3 → 12 → 25 → 50)
const sortedZones = [...cachedZones].sort((a, b) => a.nm - b.nm);
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 4; // 50해리+
}
// ── 구역 경계선 생성 ──
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);
lines.push({ if (!style) continue;
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
color: zone.color, for (let i = 0; i < zone.rings.length; i++) {
label: zone.label, lines.push({
distanceNm: zone.nm, path: zone.rings[i],
}); color: style.color,
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1); label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
lines.push({ distanceNm: style.nm,
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]), });
color: zone.color, }
label: `${zone.label} (제주)`,
distanceNm: zone.nm,
});
} }
return lines; return lines;
} }

파일 보기

@ -1,4 +1,3 @@
import { useState } from 'react';
import { LayerTree } from '@common/components/layer/LayerTree'; import { LayerTree } from '@common/components/layer/LayerTree';
import { useLayerTree } from '@common/hooks/useLayers'; import { useLayerTree } from '@common/hooks/useLayers';
import type { Layer } from '@common/services/layerService'; import type { Layer } from '@common/services/layerService';
@ -12,6 +11,8 @@ interface InfoLayerSectionProps {
onLayerOpacityChange: (val: number) => void; onLayerOpacityChange: (val: number) => void;
layerBrightness: number; layerBrightness: number;
onLayerBrightnessChange: (val: number) => void; onLayerBrightnessChange: (val: number) => void;
layerColors: Record<string, string>;
onLayerColorChange: (layerId: string, color: string) => void;
} }
const InfoLayerSection = ({ const InfoLayerSection = ({
@ -23,12 +24,12 @@ const InfoLayerSection = ({
onLayerOpacityChange, onLayerOpacityChange,
layerBrightness, layerBrightness,
onLayerBrightnessChange, onLayerBrightnessChange,
layerColors,
onLayerColorChange,
}: InfoLayerSectionProps) => { }: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환) // API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
const { data: layerTree, isLoading } = useLayerTree(); const { data: layerTree, isLoading } = useLayerTree();
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음) // 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
const effectiveLayers: Layer[] = layerTree ?? []; const effectiveLayers: Layer[] = layerTree ?? [];
@ -134,7 +135,7 @@ const InfoLayerSection = ({
enabledLayers={enabledLayers} enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer} onToggleLayer={onToggleLayer}
layerColors={layerColors} layerColors={layerColors}
onColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))} onColorChange={onLayerColorChange}
/> />
)} )}

파일 보기

@ -108,6 +108,8 @@ export function LeftPanel({
onLayerOpacityChange, onLayerOpacityChange,
layerBrightness, layerBrightness,
onLayerBrightnessChange, onLayerBrightnessChange,
layerColors,
onLayerColorChange,
sensitiveResources = [], sensitiveResources = [],
onImageAnalysisResult, onImageAnalysisResult,
validationErrors, validationErrors,
@ -345,6 +347,8 @@ export function LeftPanel({
onLayerOpacityChange={onLayerOpacityChange} onLayerOpacityChange={onLayerOpacityChange}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
onLayerBrightnessChange={onLayerBrightnessChange} onLayerBrightnessChange={onLayerBrightnessChange}
layerColors={layerColors}
onLayerColorChange={onLayerColorChange}
/> />
{/* Oil Boom Placement Guide Section */} {/* Oil Boom Placement Guide Section */}

파일 보기

@ -216,9 +216,10 @@ export function OilSpillView() {
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]); const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]);
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null); const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null);
// 레이어 스타일 (투명도 / 밝기) // 레이어 스타일 (투명도 / 밝기 / 색상)
const [layerOpacity, setLayerOpacity] = useState(50); const [layerOpacity, setLayerOpacity] = useState(50);
const [layerBrightness, setLayerBrightness] = useState(50); const [layerBrightness, setLayerBrightness] = useState(50);
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
// 표시 정보 제어 // 표시 정보 제어
const [displayControls, setDisplayControls] = useState<DisplayControls>({ const [displayControls, setDisplayControls] = useState<DisplayControls>({
@ -1200,6 +1201,8 @@ export function OilSpillView() {
onLayerOpacityChange={setLayerOpacity} onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness} onLayerBrightnessChange={setLayerBrightness}
layerColors={layerColors}
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
sensitiveResources={sensitiveResourceCategories} sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult} onImageAnalysisResult={handleImageAnalysisResult}
validationErrors={validationErrors} validationErrors={validationErrors}
@ -1236,11 +1239,11 @@ export function OilSpillView() {
drawingPoints={drawingPoints} drawingPoints={drawingPoints}
layerOpacity={layerOpacity} layerOpacity={layerOpacity}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
layerColors={layerColors}
sensitiveResources={sensitiveResources} sensitiveResources={sensitiveResources}
sensitiveResourceGeojson={ sensitiveResourceGeojson={
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
} }
lightMode
centerPoints={centerPoints.filter((p) => centerPoints={centerPoints.filter((p) =>
visibleModels.has((p.model || 'OpenDrift') as PredictionModel), visibleModels.has((p.model || 'OpenDrift') as PredictionModel),
)} )}

파일 보기

@ -54,6 +54,8 @@ export interface LeftPanelProps {
onLayerOpacityChange: (val: number) => void; onLayerOpacityChange: (val: number) => void;
layerBrightness: number; layerBrightness: number;
onLayerBrightnessChange: (val: number) => void; onLayerBrightnessChange: (val: number) => void;
layerColors: Record<string, string>;
onLayerColorChange: (layerId: string, color: string) => void;
// 영향 민감자원 // 영향 민감자원
sensitiveResources?: SensitiveResourceCategory[]; sensitiveResources?: SensitiveResourceCategory[];
// 이미지 분석 결과 콜백 // 이미지 분석 결과 콜백

파일 보기

@ -59,7 +59,6 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
simulationStartTime={mapData.simulationStartTime || undefined} simulationStartTime={mapData.simulationStartTime || undefined}
mapCaptureRef={captureRef} mapCaptureRef={captureRef}
showOverlays={false} showOverlays={false}
lightMode
/> />
{captured && ( {captured && (

파일 보기

@ -2,27 +2,10 @@ import { useState, useEffect } from 'react';
import { Map, useControl } from '@vis.gl/react-maplibre'; import { Map, useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import { ScatterplotLayer } from '@deck.gl/layers'; import { ScatterplotLayer } from '@deck.gl/layers';
import type { StyleSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { ScatDetail } from './scatTypes'; import type { ScatDetail } from './scatTypes';
import { hexToRgba } from '@common/components/map/mapUtils'; import { hexToRgba } from '@common/components/map/mapUtils';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
// ── 베이스맵 스타일 ──────────────────────────────────────
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
};
// ── DeckGLOverlay ────────────────────────────────────── // ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -94,12 +77,14 @@ function PopupMap({
}), }),
]; ];
const currentMapStyle = useBaseMapStyle();
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Map <Map
key={`${lat}-${lng}`} key={`${lat}-${lng}`}
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }} initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
mapStyle={BASE_STYLE} mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
attributionControl={false} attributionControl={false}
onLoad={onMapLoad} onLoad={onMapLoad}