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:
leedano 2026-04-07 18:02:57 +09:00
커밋 9e51651fc7
24개의 변경된 파일11843개의 추가작업 그리고 362개의 파일을 삭제

파일 보기

@ -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
파일 보기

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

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

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

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

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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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) */}

파일 보기

@ -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;
}

파일 보기

@ -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);
for (const ring of cachedBaselineRings) {
for (let i = 0; i < ring.length - 1; i++) {
const dist = pointToSegmentNm(
lat,
lon,
ring[i][0],
ring[i][1],
ring[i + 1][0],
ring[i + 1][1],
);
if (dist < minDist) minDist = dist;
}
}
return minDist;
}
// ── 구역 판별 (Point-in-Polygon) ──
/** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/** 점이 MultiPolygon의 어느 폴리곤에든 포함되는지 */
function pointInZone(lon: number, lat: number, rings: [number, number][][]): boolean {
return rings.some((ring) => pointInRing(lon, lat, ring));
}
/**
* (NM) ()
*
* @returns 0=~3, 1=3~12, 2=12~25, 3=25~50, 4=50+
*/
function offsetCoastline(
points: [number, number][],
distanceNm: number,
outwardSign: number = 1,
): [number, number][] {
const degPerNm = 1 / 60;
const result: [number, number][] = [];
export function determineZone(lat: number, lon: number): number {
if (!cachedZones) return 4;
for (let i = 0; i < points.length; i++) {
const prev = points[(i - 1 + points.length) % points.length];
const curr = points[i];
const next = points[(i + 1) % points.length];
// 작은 구역부터 검사 (3 → 12 → 25 → 50)
const sortedZones = [...cachedZones].sort((a, b) => a.nm - b.nm);
const cosLat = Math.cos((curr[0] * Math.PI) / 180);
const dx0 = (curr[1] - prev[1]) * cosLat;
const dy0 = curr[0] - prev[0];
const dx1 = (next[1] - curr[1]) * cosLat;
const dy1 = next[0] - curr[0];
let nx = -(dy0 + dy1) / 2;
let ny = (dx0 + dx1) / 2;
const len = Math.sqrt(nx * nx + ny * ny) || 1;
nx /= len;
ny /= len;
const latOff = outwardSign * nx * distanceNm * degPerNm;
const lonOff = (outwardSign * ny * distanceNm * degPerNm) / cosLat;
result.push([curr[0] + latOff, curr[1] + lonOff]);
for (let i = 0; i < sortedZones.length; i++) {
if (pointInZone(lon, lat, sortedZones[i].rings)) {
return i; // 0=3nm 내, 1=12nm 내, 2=25nm 내, 3=50nm 내
}
}
return 4; // 50해리+
}
return result;
}
// ── 구역 경계선 생성 ──
export interface ZoneLine {
path: [number, number][];
path: [number, number][]; // [lon, lat]
color: [number, number, number, number];
label: string;
distanceNm: number;
}
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해리' },
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[] {
if (!cachedZones) return [];
const lines: ZoneLine[] = [];
for (const zone of zones) {
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1);
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: 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,
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}