Merge pull request 'feat(map): HNS ���� ���� ���� ? SR �ΰ��ڿ� ��������, ����Ʈ���� ����, ���̾� ���� ����' (#160) from feature/hns into develop
This commit is contained in:
커밋
4d71ca3a01
@ -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]
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
8049
frontend/public/data/대한민국.geojson
Normal file
8049
frontend/public/data/대한민국.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
9350
frontend/public/data/대한민국_12해리.geojson
Normal file
9350
frontend/public/data/대한민국_12해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
8468
frontend/public/data/대한민국_25해리.geojson
Normal file
8468
frontend/public/data/대한민국_25해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
9974
frontend/public/data/대한민국_3해리.geojson
Normal file
9974
frontend/public/data/대한민국_3해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
7788
frontend/public/data/대한민국_50해리.geojson
Normal file
7788
frontend/public/data/대한민국_50해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
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) */}
|
||||||
|
|||||||
329
frontend/src/common/components/map/SrOverlay.tsx
Normal file
329
frontend/src/common/components/map/SrOverlay.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
20
frontend/src/common/components/map/srStyles.ts
Normal file
20
frontend/src/common/components/map/srStyles.ts
Normal file
@ -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}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user