Merge remote-tracking branch 'origin/develop' into feature/design-system-refactoring
# Conflicts: # docs/RELEASE-NOTES.md # frontend/src/common/components/map/MapView.tsx # frontend/src/tabs/incidents/components/DischargeZonePanel.tsx # frontend/src/tabs/incidents/components/IncidentsView.tsx
This commit is contained in:
커밋
9e51651fc7
52
.claude/tmp-approve-merge.mjs
Normal file
52
.claude/tmp-approve-merge.mjs
Normal file
@ -0,0 +1,52 @@
|
||||
import https from 'https';
|
||||
|
||||
const prNumber = 161;
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
const botToken = process.env.CLAUDE_BOT_TOKEN;
|
||||
|
||||
function req(method, path, token, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : '';
|
||||
const r = https.request({
|
||||
hostname: 'gitea.gc-si.dev',
|
||||
path,
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
}, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => buf += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
||||
});
|
||||
r.on('error', reject);
|
||||
if (data) r.write(data);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!botToken) {
|
||||
console.log('NO_BOT_TOKEN: skipping approval/merge');
|
||||
process.exit(0);
|
||||
}
|
||||
// 1. approve
|
||||
const ap = await req('POST', `/api/v1/repos/gc/wing-ops/pulls/${prNumber}/reviews`, botToken, {
|
||||
event: 'APPROVED',
|
||||
body: 'MR 승인 (via /mr skill)',
|
||||
});
|
||||
console.log(`APPROVE_STATUS=${ap.status}`);
|
||||
if (ap.status >= 300) { console.error(ap.body); process.exit(1); }
|
||||
|
||||
// 2. merge
|
||||
const mg = await req('POST', `/api/v1/repos/gc/wing-ops/pulls/${prNumber}/merge`, giteaToken, {
|
||||
Do: 'merge',
|
||||
merge_message_field: 'feat(design): 디자인 시스템 토큰 적용 및 Float 카탈로그 추가',
|
||||
delete_branch_after_merge: true,
|
||||
});
|
||||
console.log(`MERGE_STATUS=${mg.status}`);
|
||||
if (mg.status >= 300) { console.error(mg.body); process.exit(1); }
|
||||
console.log('MERGE_DONE');
|
||||
})();
|
||||
52
.claude/tmp-create-mr.mjs
Normal file
52
.claude/tmp-create-mr.mjs
Normal file
@ -0,0 +1,52 @@
|
||||
import https from 'https';
|
||||
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (!token) { console.error('NO_TOKEN'); process.exit(1); }
|
||||
|
||||
const body = {
|
||||
title: 'feat(design): 디자인 시스템 토큰 적용 및 Float 카탈로그 추가',
|
||||
body: `## 변경 사항
|
||||
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트(admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)에 전면 적용
|
||||
- 릴리즈 노트 업데이트
|
||||
|
||||
## 관련 이슈
|
||||
- (없음)
|
||||
|
||||
## 테스트
|
||||
- [x] TypeScript 타입 체크 통과 (pre-commit)
|
||||
- [x] ESLint 통과 (pre-commit)
|
||||
- [ ] 기존 기능 동작 확인
|
||||
`,
|
||||
head: 'feature/design-system-refactoring',
|
||||
base: 'develop',
|
||||
};
|
||||
|
||||
const data = JSON.stringify(body);
|
||||
const req = https.request({
|
||||
hostname: 'gitea.gc-si.dev',
|
||||
path: '/api/v1/repos/gc/wing-ops/pulls',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
}, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => buf += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const pr = JSON.parse(buf);
|
||||
console.log(`PR_NUMBER=${pr.number}`);
|
||||
console.log(`PR_URL=${pr.html_url}`);
|
||||
} else {
|
||||
console.error(`STATUS=${res.statusCode}`);
|
||||
console.error(buf);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => { console.error(e); process.exit(1); });
|
||||
req.write(data);
|
||||
req.end();
|
||||
@ -219,9 +219,9 @@ export async function createAnalysis(input: {
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
ANALYST_NM, EXEC_STTS_CD
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
||||
$1, $2, $3, $4::numeric, $5::numeric,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END,
|
||||
$6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16,
|
||||
$17, 'PENDING'
|
||||
|
||||
@ -54,6 +54,28 @@ router.get('/vworld/:z/:y/:x', async (req, res) => {
|
||||
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) ───
|
||||
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||
|
||||
|
||||
@ -7,6 +7,14 @@
|
||||
### 추가
|
||||
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
|
||||
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가
|
||||
- 해양 오염물질 배출규정 구역 판별 기능 추가
|
||||
|
||||
### 변경
|
||||
- 지도: 항상 라이트 모드로 고정 (앱 다크 모드와 무관)
|
||||
- 지도: lightMode prop 제거, useThemeStore 기반 테마 전환 통합
|
||||
- 레이어 색상 상태를 OilSpillView로 끌어올림
|
||||
- 대한민국 해리 GeoJSON 데이터 갱신
|
||||
|
||||
## [2026-04-02]
|
||||
|
||||
|
||||
2040
frontend/public/data/대한민국.geojson
Normal file
2040
frontend/public/data/대한민국.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||
import { useThemeStore } from '@common/store/themeStore';
|
||||
|
||||
interface HydrParticleOverlayProps {
|
||||
hydrStep: HydrDataStep | null;
|
||||
lightMode?: boolean;
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 3000;
|
||||
@ -25,10 +25,8 @@ interface Particle {
|
||||
age: number;
|
||||
}
|
||||
|
||||
export default function HydrParticleOverlay({
|
||||
hydrStep,
|
||||
lightMode = false,
|
||||
}: HydrParticleOverlayProps) {
|
||||
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
||||
const lightMode = useThemeStore((s) => s.theme) === 'light';
|
||||
const { current: map } = useMap();
|
||||
const animRef = useRef<number>();
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import { MeasureOverlay } from './MeasureOverlay';
|
||||
import { useMeasureTool } from '@common/hooks/useMeasureTool';
|
||||
import { hexToRgba } from './mapUtils';
|
||||
import { S57EncOverlay } from './S57EncOverlay';
|
||||
import { SrOverlay } from './SrOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
|
||||
@ -129,6 +130,7 @@ interface MapViewProps {
|
||||
drawingPoints?: BoomLineCoord[];
|
||||
layerOpacity?: number;
|
||||
layerBrightness?: number;
|
||||
layerColors?: Record<string, string>;
|
||||
backtrackReplay?: {
|
||||
isActive: boolean;
|
||||
ships: ReplayShip[];
|
||||
@ -159,8 +161,6 @@ interface MapViewProps {
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>;
|
||||
analysisCircleCenter?: { lat: number; lon: number } | null;
|
||||
analysisCircleRadiusM?: number;
|
||||
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
|
||||
lightMode?: boolean;
|
||||
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
||||
showOverlays?: boolean;
|
||||
}
|
||||
@ -345,6 +345,7 @@ export function MapView({
|
||||
drawingPoints = [],
|
||||
layerOpacity = 50,
|
||||
layerBrightness = 50,
|
||||
layerColors,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
sensitiveResourceGeojson,
|
||||
@ -366,9 +367,9 @@ export function MapView({
|
||||
analysisPolygonPoints = [],
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
lightMode = false,
|
||||
showOverlays = true,
|
||||
}: MapViewProps) {
|
||||
const lightMode = true;
|
||||
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
|
||||
const { handleMeasureClick } = useMeasureTool();
|
||||
const isControlled = externalCurrentTime !== undefined;
|
||||
@ -869,10 +870,12 @@ export function MapView({
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
||||
const imageUrl = canvas.toDataURL('image/png');
|
||||
result.push(
|
||||
new BitmapLayer({
|
||||
id: 'hns-dispersion-bitmap',
|
||||
image: canvas,
|
||||
image: imageUrl,
|
||||
bounds: [minLon, minLat, maxLon, maxLat],
|
||||
opacity: 1.0,
|
||||
pickable: false,
|
||||
@ -1257,8 +1260,8 @@ export function MapView({
|
||||
lightMode,
|
||||
]);
|
||||
|
||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||
const currentMapStyle = useBaseMapStyle(lightMode);
|
||||
// 3D 모드 / 테마에 따른 지도 스타일 전환
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
@ -1296,6 +1299,9 @@ export function MapView({
|
||||
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
|
||||
{/* SR 민감자원 벡터타일 오버레이 */}
|
||||
<SrOverlay enabledLayers={enabledLayers} opacity={layerOpacity} layerColors={layerColors} />
|
||||
|
||||
{/* WMS 레이어 */}
|
||||
{wmsLayers.map((layer) => (
|
||||
<Source
|
||||
@ -1324,7 +1330,7 @@ export function MapView({
|
||||
|
||||
{/* 해류 파티클 오버레이 */}
|
||||
{hydrData.length > 0 && showCurrent && (
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||
)}
|
||||
|
||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||
|
||||
333
frontend/src/common/components/map/SrOverlay.tsx
Normal file
333
frontend/src/common/components/map/SrOverlay.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
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;
|
||||
}
|
||||
26
frontend/src/common/components/map/srStyles.ts
Normal file
26
frontend/src/common/components/map/srStyles.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// 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,11 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import {
|
||||
BASE_STYLE,
|
||||
LIGHT_STYLE,
|
||||
SATELLITE_3D_STYLE,
|
||||
ENC_EMPTY_STYLE,
|
||||
} from '@common/components/map/mapStyles';
|
||||
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
|
||||
|
||||
export function useBaseMapStyle(lightMode = false): StyleSpecification {
|
||||
export function useBaseMapStyle(): StyleSpecification {
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
if (mapToggles.s57) return ENC_EMPTY_STYLE;
|
||||
if (mapToggles.threeD) return SATELLITE_3D_STYLE;
|
||||
if (lightMode) return LIGHT_STYLE;
|
||||
return BASE_STYLE;
|
||||
return LIGHT_STYLE;
|
||||
}
|
||||
|
||||
@ -14,100 +14,93 @@ type Status = 'forbidden' | 'allowed' | 'conditional';
|
||||
interface DischargeRule {
|
||||
category: string;
|
||||
item: string;
|
||||
zones: [Status, Status, Status, Status]; // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
||||
zones: [Status, Status, Status, Status, Status]; // [~3NM, 3~12NM, 12~25NM, 25~50NM, 50NM+]
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
const RULES: DischargeRule[] = [
|
||||
// 폐기물
|
||||
// 분뇨
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '플라스틱 제품',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨마쇄소독장치',
|
||||
zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
||||
},
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '포장유해물질·용기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨저장탱크',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출',
|
||||
},
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '중금속 포함 쓰레기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨처리장치',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가',
|
||||
},
|
||||
// 음식물찌꺼기
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '미분쇄 음식물',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '분쇄·연마 음식물 (25mm 이하)',
|
||||
zones: ['forbidden', 'conditional', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '25mm 이하 개구 스크린 통과 가능시',
|
||||
},
|
||||
// 화물잔류물
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '부유성 화물잔류물',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'],
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '침강성 화물잔류물',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '화물창 세정수',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '해양환경에 해롭지 않은 일반세제 사용시',
|
||||
},
|
||||
// 음식물 찌꺼기
|
||||
// 화물유
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '미분쇄',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
category: '화물유',
|
||||
item: '화물유 섞인 평형수·세정수·선저폐수',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'conditional'],
|
||||
condition: '항해 중, 순간배출률 1해리당 30L 이하, 기름오염방지설비 작동 중',
|
||||
},
|
||||
// 유해액체물질
|
||||
{
|
||||
category: '유해액체물질',
|
||||
item: '유해액체물질 섞인 세정수',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '자항선 7노트/비자항선 4노트 이상, 수심 25m 이상, 수면하 배출구 사용',
|
||||
},
|
||||
// 폐기물
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '플라스틱 제품',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '분쇄·연마',
|
||||
zones: ['forbidden', 'conditional', 'allowed', 'allowed'],
|
||||
condition: '크기 25mm 이하시',
|
||||
},
|
||||
// 분뇨
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨저장장치',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출',
|
||||
category: '폐기물',
|
||||
item: '포장유해물질·용기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨마쇄소독장치',
|
||||
zones: ['forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
||||
},
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨처리장치',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가',
|
||||
},
|
||||
// 중수
|
||||
{
|
||||
category: '중수',
|
||||
item: '거주구역 중수',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가',
|
||||
},
|
||||
// 수산동식물
|
||||
{
|
||||
category: '수산동식물',
|
||||
item: '자연기원물질',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면',
|
||||
category: '폐기물',
|
||||
item: '중금속 포함 쓰레기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
];
|
||||
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+'];
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e'];
|
||||
|
||||
function getZoneIndex(distanceNm: number): number {
|
||||
if (distanceNm < 3) return 0;
|
||||
if (distanceNm < 12) return 1;
|
||||
if (distanceNm < 25) return 2;
|
||||
return 3;
|
||||
}
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b'];
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden')
|
||||
@ -142,11 +135,18 @@ interface DischargeZonePanelProps {
|
||||
lat: number;
|
||||
lon: number;
|
||||
distanceNm: number;
|
||||
zoneIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
||||
const zoneIdx = getZoneIndex(distanceNm);
|
||||
export function DischargeZonePanel({
|
||||
lat,
|
||||
lon,
|
||||
distanceNm,
|
||||
zoneIndex,
|
||||
onClose,
|
||||
}: DischargeZonePanelProps) {
|
||||
const zoneIdx = zoneIndex;
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
||||
|
||||
const categories = [...new Set(RULES.map((r) => r.category))];
|
||||
@ -193,7 +193,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-caption text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
||||
<span
|
||||
className="text-label-2 font-bold font-mono"
|
||||
style={{ color: ZONE_COLORS[zoneIdx] }}
|
||||
@ -299,7 +299,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="text-caption text-fg-sub font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있습니다.
|
||||
※ 거리는 영해기선 폴리곤 기준입니다. 구역은 버퍼 폴리곤 포함 여부로 판별됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -17,7 +17,15 @@ import type {
|
||||
SensitiveResourceFeatureCollection,
|
||||
} from '@tabs/prediction/services/predictionApi';
|
||||
import { DischargeZonePanel } from './DischargeZonePanel';
|
||||
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData';
|
||||
import {
|
||||
estimateDistanceFromCoast,
|
||||
determineZone,
|
||||
getDischargeZoneLines,
|
||||
loadTerritorialBaseline,
|
||||
getCachedBaseline,
|
||||
loadZoneGeoJSON,
|
||||
getCachedZones,
|
||||
} from '../utils/dischargeZoneData';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { useMeasureTool } from '@common/hooks/useMeasureTool';
|
||||
import { buildMeasureLayers } from '@common/components/map/measureLayers';
|
||||
@ -128,16 +136,19 @@ export function IncidentsView() {
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null);
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
||||
|
||||
// Discharge zone mode
|
||||
const [dischargeMode, setDischargeMode] = useState(false);
|
||||
const [dischargeInfo, setDischargeInfo] = useState<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
distanceNm: number;
|
||||
zoneIndex: number;
|
||||
} | null>(null);
|
||||
const [baselineLoaded, setBaselineLoaded] = useState(
|
||||
() => getCachedBaseline() !== null && getCachedZones() !== null,
|
||||
);
|
||||
|
||||
// Map style & toggles
|
||||
const currentMapStyle = useBaseMapStyle(true);
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
// Measure tool
|
||||
@ -167,6 +178,7 @@ export function IncidentsView() {
|
||||
fetchIncidents().then((data) => {
|
||||
setIncidents(data);
|
||||
});
|
||||
Promise.all([loadTerritorialBaseline(), loadZoneGeoJSON()]).then(() => setBaselineLoaded(true));
|
||||
}, []);
|
||||
|
||||
// 사고 전환 시 지도 레이어 즉시 초기화
|
||||
@ -338,7 +350,7 @@ export function IncidentsView() {
|
||||
|
||||
// ── 배출 구역 경계선 레이어 ──
|
||||
const dischargeZoneLayers = useMemo(() => {
|
||||
if (!dischargeMode) return [];
|
||||
if (!dischargeMode || !baselineLoaded) return [];
|
||||
const zoneLines = getDischargeZoneLines();
|
||||
return zoneLines.map(
|
||||
(line, i) =>
|
||||
@ -355,7 +367,7 @@ export function IncidentsView() {
|
||||
pickable: false,
|
||||
}),
|
||||
);
|
||||
}, [dischargeMode]);
|
||||
}, [dischargeMode, baselineLoaded]);
|
||||
|
||||
const measureDeckLayers = useMemo(
|
||||
() => buildMeasureLayers(measureInProgress, measureMode, measurements),
|
||||
@ -659,7 +671,8 @@ export function IncidentsView() {
|
||||
const lat = e.lngLat.lat;
|
||||
const lon = e.lngLat.lng;
|
||||
const distanceNm = estimateDistanceFromCoast(lat, lon);
|
||||
setDischargeInfo({ lat, lon, distanceNm });
|
||||
const zoneIndex = determineZone(lat, lon);
|
||||
setDischargeInfo({ lat, lon, distanceNm, zoneIndex });
|
||||
}
|
||||
}}
|
||||
cursor={measureMode !== null || dischargeMode ? 'crosshair' : undefined}
|
||||
@ -803,6 +816,7 @@ export function IncidentsView() {
|
||||
lat={dischargeInfo.lat}
|
||||
lon={dischargeInfo.lon}
|
||||
distanceNm={dischargeInfo.distanceNm}
|
||||
zoneIndex={dischargeInfo.zoneIndex}
|
||||
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
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*
|
||||
* 구역 경계선: 국립해양조사원 영해기선(TB_ZN_TRTSEA) 버퍼 GeoJSON
|
||||
* 영해기선 데이터: 국립해양조사원 TB_ZN_TRTSEA (EPSG:5179 → WGS84 변환)
|
||||
*/
|
||||
|
||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
||||
const COASTLINE_POINTS: [number, number][] = [
|
||||
// 동해안 (북→남)
|
||||
[38.6177, 128.656],
|
||||
[38.5504, 128.4092],
|
||||
[38.4032, 128.7767],
|
||||
[38.1904, 128.8902],
|
||||
[38.0681, 128.9977],
|
||||
[37.9726, 129.0715],
|
||||
[37.8794, 129.1721],
|
||||
[37.8179, 129.2397],
|
||||
[37.6258, 129.3669],
|
||||
[37.5053, 129.4577],
|
||||
[37.3617, 129.57],
|
||||
[37.1579, 129.6538],
|
||||
[37.0087, 129.6706],
|
||||
[36.6618, 129.721],
|
||||
[36.3944, 129.6827],
|
||||
[36.2052, 129.7641],
|
||||
[35.9397, 129.8124],
|
||||
[35.6272, 129.7121],
|
||||
[35.4732, 129.6908],
|
||||
[35.2843, 129.5924],
|
||||
[35.141, 129.4656],
|
||||
[35.0829, 129.2125],
|
||||
// 남해안 (부산→여수→목포)
|
||||
[34.895, 129.0658],
|
||||
[34.205, 128.3063],
|
||||
[35.022, 128.0362],
|
||||
[34.9663, 127.8732],
|
||||
[34.9547, 127.7148],
|
||||
[34.8434, 127.6625],
|
||||
[34.7826, 127.7422],
|
||||
[34.6902, 127.6324],
|
||||
[34.8401, 127.5236],
|
||||
[34.823, 127.4043],
|
||||
[34.6882, 127.4234],
|
||||
[34.6252, 127.4791],
|
||||
[34.5525, 127.4012],
|
||||
[34.4633, 127.3246],
|
||||
[34.5461, 127.1734],
|
||||
[34.6617, 127.2605],
|
||||
[34.7551, 127.2471],
|
||||
[34.6069, 127.0308],
|
||||
[34.4389, 126.8975],
|
||||
[34.4511, 126.8263],
|
||||
[34.4949, 126.7965],
|
||||
[34.5119, 126.7548],
|
||||
[34.4035, 126.6108],
|
||||
[34.3175, 126.5844],
|
||||
[34.3143, 126.5314],
|
||||
[34.3506, 126.5083],
|
||||
[34.4284, 126.5064],
|
||||
[34.4939, 126.4817],
|
||||
[34.5896, 126.3326],
|
||||
[34.6732, 126.2645],
|
||||
// 서해안 (목포→인천)
|
||||
[34.72, 126.3011],
|
||||
[34.6946, 126.4256],
|
||||
[34.6979, 126.5245],
|
||||
[34.7787, 126.5386],
|
||||
[34.8244, 126.5934],
|
||||
[34.8104, 126.4785],
|
||||
[34.8234, 126.4207],
|
||||
[34.9328, 126.3979],
|
||||
[35.0451, 126.3274],
|
||||
[35.1542, 126.2911],
|
||||
[35.2169, 126.3605],
|
||||
[35.3144, 126.3959],
|
||||
[35.4556, 126.4604],
|
||||
[35.5013, 126.4928],
|
||||
[35.5345, 126.5822],
|
||||
[35.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],
|
||||
// ── GeoJSON 타입 ──
|
||||
|
||||
interface GeoJSONFeature {
|
||||
geometry: {
|
||||
type: string;
|
||||
coordinates: number[][][][] | number[][][];
|
||||
};
|
||||
}
|
||||
|
||||
// ── 영해기선 폴리곤 (거리 계산용) ──
|
||||
|
||||
let cachedBaselineRings: [number, number][][] | null = null;
|
||||
let baselineLoadingPromise: Promise<[number, number][][]> | null = null;
|
||||
|
||||
function extractOuterRings(geojson: { features: GeoJSONFeature[] }): [number, number][][] {
|
||||
const rings: [number, number][][] = [];
|
||||
for (const feature of geojson.features) {
|
||||
const geom = feature.geometry;
|
||||
if (geom.type === 'MultiPolygon') {
|
||||
const polygons = geom.coordinates as [number, number][][][];
|
||||
for (const polygon of polygons) {
|
||||
rings.push(polygon[0]);
|
||||
}
|
||||
} else if (geom.type === 'Polygon') {
|
||||
const polygon = geom.coordinates as [number, number][][];
|
||||
rings.push(polygon[0]);
|
||||
}
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
export async function loadTerritorialBaseline(): Promise<[number, number][][]> {
|
||||
if (cachedBaselineRings) return cachedBaselineRings;
|
||||
if (baselineLoadingPromise) return baselineLoadingPromise;
|
||||
|
||||
baselineLoadingPromise = fetch('/data/대한민국.geojson')
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: GeoJSONFeature[] }) => {
|
||||
cachedBaselineRings = extractOuterRings(data);
|
||||
return cachedBaselineRings;
|
||||
});
|
||||
|
||||
return baselineLoadingPromise;
|
||||
}
|
||||
|
||||
export function getCachedBaseline(): [number, number][][] | null {
|
||||
return cachedBaselineRings;
|
||||
}
|
||||
|
||||
// ── 구역 경계선 GeoJSON (런타임 로드) ──
|
||||
|
||||
interface ZoneGeoJSON {
|
||||
nm: number;
|
||||
rings: [number, number][][];
|
||||
}
|
||||
|
||||
let cachedZones: ZoneGeoJSON[] | null = null;
|
||||
let zoneLoadingPromise: Promise<ZoneGeoJSON[]> | null = null;
|
||||
|
||||
const ZONE_FILES = [
|
||||
{ nm: 3, file: '/data/대한민국_3해리.geojson' },
|
||||
{ nm: 12, file: '/data/대한민국_12해리.geojson' },
|
||||
{ nm: 25, file: '/data/대한민국_25해리.geojson' },
|
||||
{ nm: 50, file: '/data/대한민국_50해리.geojson' },
|
||||
];
|
||||
|
||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
||||
const JEJU_POINTS: [number, number][] = [
|
||||
[33.5168, 126.0128],
|
||||
[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],
|
||||
];
|
||||
export async function loadZoneGeoJSON(): Promise<ZoneGeoJSON[]> {
|
||||
if (cachedZones) return cachedZones;
|
||||
if (zoneLoadingPromise) return zoneLoadingPromise;
|
||||
|
||||
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 {
|
||||
const R = 3440.065;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
@ -203,83 +110,136 @@ function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
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 {
|
||||
if (!cachedBaselineRings) return 0;
|
||||
|
||||
// 영해기선 폴리곤 내부이면 거리 0
|
||||
if (cachedBaselineRings.some((ring) => pointInRing(lon, lat, ring))) return 0;
|
||||
|
||||
let minDist = Infinity;
|
||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
||||
const dist = haversineNm(lat, lon, cLat, cLon);
|
||||
if (dist < minDist) minDist = dist;
|
||||
for (const ring of cachedBaselineRings) {
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const dist = pointToSegmentNm(
|
||||
lat,
|
||||
lon,
|
||||
ring[i][0],
|
||||
ring[i][1],
|
||||
ring[i + 1][0],
|
||||
ring[i + 1][1],
|
||||
);
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
||||
*/
|
||||
function offsetCoastline(
|
||||
points: [number, number][],
|
||||
distanceNm: number,
|
||||
outwardSign: number = 1,
|
||||
): [number, number][] {
|
||||
const degPerNm = 1 / 60;
|
||||
const result: [number, number][] = [];
|
||||
// ── 구역 판별 (Point-in-Polygon) ──
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[(i - 1 + points.length) % points.length];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % points.length];
|
||||
/** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
|
||||
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
|
||||
const cosLat = Math.cos((curr[0] * Math.PI) / 180);
|
||||
const dx0 = (curr[1] - prev[1]) * cosLat;
|
||||
const dy0 = curr[0] - prev[0];
|
||||
const dx1 = (next[1] - curr[1]) * cosLat;
|
||||
const dy1 = next[0] - curr[0];
|
||||
|
||||
let nx = -(dy0 + dy1) / 2;
|
||||
let ny = (dx0 + dx1) / 2;
|
||||
const len = Math.sqrt(nx * nx + ny * ny) || 1;
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
|
||||
const latOff = outwardSign * nx * distanceNm * degPerNm;
|
||||
const lonOff = (outwardSign * ny * distanceNm * degPerNm) / cosLat;
|
||||
|
||||
result.push([curr[0] + latOff, curr[1] + lonOff]);
|
||||
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 점이 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 {
|
||||
path: [number, number][];
|
||||
path: [number, number][]; // [lon, lat]
|
||||
color: [number, number, number, number];
|
||||
label: string;
|
||||
distanceNm: number;
|
||||
}
|
||||
|
||||
const ZONE_STYLES: { nm: number; color: [number, number, number, number]; label: string }[] = [
|
||||
{ nm: 3, color: [239, 68, 68, 180], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140], label: '25해리' },
|
||||
{ nm: 50, color: [100, 116, 139, 120], label: '50해리' },
|
||||
];
|
||||
|
||||
export function getDischargeZoneLines(): ZoneLine[] {
|
||||
const zones = [
|
||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
||||
];
|
||||
if (!cachedZones) return [];
|
||||
|
||||
const lines: ZoneLine[] = [];
|
||||
for (const zone of zones) {
|
||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1);
|
||||
lines.push({
|
||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: zone.label,
|
||||
distanceNm: zone.nm,
|
||||
});
|
||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1);
|
||||
lines.push({
|
||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: `${zone.label} (제주)`,
|
||||
distanceNm: zone.nm,
|
||||
});
|
||||
for (const zone of cachedZones) {
|
||||
const style = ZONE_STYLES.find((s) => s.nm === zone.nm);
|
||||
if (!style) continue;
|
||||
|
||||
for (let i = 0; i < zone.rings.length; i++) {
|
||||
lines.push({
|
||||
path: zone.rings[i],
|
||||
color: style.color,
|
||||
label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
|
||||
distanceNm: style.nm,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { LayerTree } from '@common/components/layer/LayerTree';
|
||||
import { useLayerTree } from '@common/hooks/useLayers';
|
||||
import type { Layer } from '@common/services/layerService';
|
||||
@ -12,6 +11,8 @@ interface InfoLayerSectionProps {
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
}
|
||||
|
||||
const InfoLayerSection = ({
|
||||
@ -23,12 +24,12 @@ const InfoLayerSection = ({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
}: InfoLayerSectionProps) => {
|
||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||
const { data: layerTree, isLoading } = useLayerTree();
|
||||
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||
|
||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||
|
||||
@ -134,7 +135,7 @@ const InfoLayerSection = ({
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerColors={layerColors}
|
||||
onColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
onColorChange={onLayerColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -108,6 +108,8 @@ export function LeftPanel({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
sensitiveResources = [],
|
||||
onImageAnalysisResult,
|
||||
validationErrors,
|
||||
@ -345,6 +347,8 @@ export function LeftPanel({
|
||||
onLayerOpacityChange={onLayerOpacityChange}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={onLayerBrightnessChange}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={onLayerColorChange}
|
||||
/>
|
||||
|
||||
{/* Oil Boom Placement Guide Section */}
|
||||
|
||||
@ -216,9 +216,10 @@ export function OilSpillView() {
|
||||
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]);
|
||||
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null);
|
||||
|
||||
// 레이어 스타일 (투명도 / 밝기)
|
||||
// 레이어 스타일 (투명도 / 밝기 / 색상)
|
||||
const [layerOpacity, setLayerOpacity] = useState(50);
|
||||
const [layerBrightness, setLayerBrightness] = useState(50);
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||
|
||||
// 표시 정보 제어
|
||||
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||
@ -1200,6 +1201,8 @@ export function OilSpillView() {
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
@ -1236,11 +1239,11 @@ export function OilSpillView() {
|
||||
drawingPoints={drawingPoints}
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
layerColors={layerColors}
|
||||
sensitiveResources={sensitiveResources}
|
||||
sensitiveResourceGeojson={
|
||||
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
|
||||
}
|
||||
lightMode
|
||||
centerPoints={centerPoints.filter((p) =>
|
||||
visibleModels.has((p.model || 'OpenDrift') as PredictionModel),
|
||||
)}
|
||||
|
||||
@ -54,6 +54,8 @@ export interface LeftPanelProps {
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
// 영향 민감자원
|
||||
sensitiveResources?: SensitiveResourceCategory[];
|
||||
// 이미지 분석 결과 콜백
|
||||
|
||||
@ -66,7 +66,6 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
|
||||
simulationStartTime={mapData.simulationStartTime || undefined}
|
||||
mapCaptureRef={captureRef}
|
||||
showOverlays={false}
|
||||
lightMode
|
||||
/>
|
||||
|
||||
{captured && (
|
||||
|
||||
@ -2,27 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
import { Map, useControl } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { ScatDetail } from './scatTypes';
|
||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
||||
|
||||
// ── 베이스맵 스타일 ──────────────────────────────────────
|
||||
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' }],
|
||||
};
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -94,12 +77,14 @@ function PopupMap({
|
||||
}),
|
||||
];
|
||||
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<Map
|
||||
key={`${lat}-${lng}`}
|
||||
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
mapStyle={currentMapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
onLoad={onMapLoad}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user