release: 2026-04-17 (320건 커밋) #190

병합
dnlee develop 에서 main 로 21 commits 를 머지했습니다 2026-04-17 13:42:37 +09:00
45개의 변경된 파일117개의 추가작업 그리고 365개의 파일을 삭제
Showing only changes of commit 1980463904 - Show all commits

파일 보기

@ -1,6 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-04-16",
"applied_date": "2026-04-17",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true

파일 보기

@ -3,6 +3,7 @@
# commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원)
#==============================================================================
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

파일 보기

@ -2,31 +2,6 @@
해양 오염 사고 대응 방제 운영 지원 시스템. 유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다.
## 🚨 절대 지침 (Absolute Rules)
### 1. 신규 기능 설계/구현 전 develop 최신화 필수
신규 기능 설계나 구현을 시작하기 전, **반드시** 다음 절차를 사용자에게 권유하고 확인을 받을 것:
1. `git fetch origin` 으로 원격 `develop` 최신 상태 확인
2. `origin/develop`이 로컬 `develop`보다 앞서 있으면 → 로컬 `develop`을 최신화 (`git pull --ff-only` 또는 `git checkout -B develop origin/develop`)
3. 최신화된 `develop`에서 신규 브랜치 생성 (`git checkout -b <type>/<name>`)
4. 해당 브랜치에서 설계·구현 진행
> **이유**: 구 버전 develop 기반으로 작업 시 머지 충돌·중복 구현·사라진 코드 복원 등 위험. 최신 base에서 시작해야 MR 리뷰·릴리즈 흐름이 안전.
### 2. 프론트엔드 구성 시 디자인 시스템 준수 필수
모든 프론트엔드 UI 구현은 **반드시** [docs/DESIGN-SYSTEM.md](docs/DESIGN-SYSTEM.md) 규칙을 준수할 것:
- 시맨틱 토큰(`--bg-base`, `--fg-default`, `--color-accent` 등) 사용 — 축약형/하드코딩 금지
- 폰트: `PretendardGOV` 단일 폰트 (4웨이트). `var(--font-korean)`, `var(--font-mono)` 경유
- 다크/라이트 테마 전환 지원 (`data-theme` 속성 기반)
- Tailwind 컬러 키는 CSS 변수 참조 (`bg.base`, `fg.DEFAULT`, `color.accent`)
- 폰트 크기 토큰: `text-caption/body-2/body-1/title-4...` — 인라인 `fontSize` 금지
> **이유**: 디자인 일관성·테마 전환·접근성(대비) 확보. 토큰 외 값은 리팩토링 비용을 증가시킴.
- **타입**: react-ts 모노레포 (frontend + backend)
- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3
- **Backend**: Express 4 + PostgreSQL (pg) + TypeScript

파일 보기

@ -375,7 +375,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
### 탭별 API 서비스 패턴
각 탭은 `components/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
```typescript
// frontend/src/components/board/services/boardApi.ts
@ -1406,7 +1406,7 @@ frontend/src/
| +-- cn.ts className 조합 유틸리티
| +-- sanitize.ts XSS 방지/입력 살균
| +-- coordinates.ts 좌표 변환 유틸리티
+-- components/ 탭별 패키지 (11개, MPA 컴포넌트 구조)
+-- tabs/ 탭별 패키지 (11개)
| +-- {탭명}/
| +-- components/ 탭 뷰 컴포넌트
| +-- services/{탭명}Api.ts 탭별 API 서비스

파일 보기

@ -21,7 +21,7 @@ DB 설계부터 백엔드 구현, 프론트엔드 연동까지 End-to-End 패턴
```
[Frontend] [Backend] [Database]
components/{탭}/services/{tab}Api.ts src/{domain}/{domain}Router.ts PostgreSQL 16
tabs/{탭}/services/{tab}Api.ts src/{domain}/{domain}Router.ts PostgreSQL 16
Axios (withCredentials: true) requireAuth -> requirePermission
--HTTP--> src/{domain}/{domain}Service.ts wingPool / authPool
wingPool.query(SQL, params) --SQL-->

파일 보기

@ -242,7 +242,7 @@ frontend/src/
│ ├── store/ authStore (Zustand), menuStore
│ ├── types/ backtrack, boomLine, hns, navigation
│ └── utils/ coordinates, geo, sanitize
└── components/ 탭 단위 패키지 (11개, MPA 컴포넌트 구조)
└── tabs/ 탭 단위 패키지 (11개)
├── prediction/ 확산 예측
├── hns/ HNS 분석
├── rescue/ 구조 시나리오
@ -259,7 +259,7 @@ frontend/src/
**각 탭 내부 구조 패턴:**
```
components/{탭명}/
tabs/{탭명}/
├── components/ UI 컴포넌트
│ ├── {Tab}View.tsx 메인 뷰 (App.tsx에서 라우팅)
│ ├── {Tab}LeftPanel.tsx

파일 보기

@ -75,7 +75,7 @@ wing/
│ │ │ ├── store/ authStore, menuStore (Zustand)
│ │ │ ├── types/ backtrack, boomLine, hns, navigation
│ │ │ └── utils/ coordinates, geo, sanitize
│ │ └── components/ 탭 단위 패키지 (11개, MPA 구조)
│ │ └── tabs/ 탭 단위 패키지 (11개)
│ │ ├── prediction/ 확산 예측
│ │ ├── hns/ HNS 분석
│ │ ├── rescue/ 구조 시나리오

파일 보기

@ -66,7 +66,7 @@ wing/
│ │ ├── utils/ cn, coordinates, geo, sanitize
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
│ └── components/ @components/ alias (11개 탭, MPA 구조)
│ └── tabs/ @components/ alias (11개 탭)
│ ├── prediction/ 유류 확산 예측
│ ├── hns/ HNS 분석
│ ├── rescue/ 구조 시나리오

파일 보기

@ -5,15 +5,7 @@
## [Unreleased]
### 수정
- 사건사고: MPA 리팩토링 누락 imports 정리 — IncidentsView `SplitPanelContent` 중복 import 제거, `fetchOilSpillSummary``predictionApi`로 이관(이전 위치는 `api` 미임포트로 런타임 동작 불가), `AnalysisSelectModal`·`hnsDispersionLayers`의 `@tabs/`·`@common/components/` 구 경로를 신 경로로 정정
### 문서
- MPA 컴포넌트 구조 반영: 개발 가이드·공통 가이드·CRUD 가이드·설치 가이드·docs/README의 `tabs/` 경로 예시를 `components/`로 정정
## [2026-04-17]
### 문서
- CLAUDE.md 절대 지침 추가 (develop 최신화, 디자인 시스템 준수)
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
## [2026-04-16]

파일 보기

@ -217,8 +217,6 @@ export function generateAIBoomLines(
const totalDist = haversineDistance(incident, centroid);
// 입자 분산 폭 계산 (최종 시간 기준)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perpBearing = (mainBearing + 90) % 360;
let maxSpread = 0;
for (const p of finalPoints) {
const bearing = computeBearing(incident, p);

파일 보기

@ -31,45 +31,6 @@ export function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
/**
* HTML
* /
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ALLOWED_TAGS = new Set([
'b',
'i',
'u',
'strong',
'em',
'br',
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'sup',
'sub',
'hr',
'blockquote',
'pre',
'code',
]);
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;

파일 보기

@ -2,6 +2,11 @@ import { useState, useEffect, useCallback } from 'react';
import {
fetchRoles,
fetchPermTree,
updatePermissionsApi,
createRoleApi,
deleteRoleApi,
updateRoleApi,
updateRoleDefaultApi,
type RoleWithPermissions,
type PermTreeNode,
} from '@common/services/authApi';

파일 보기

@ -89,7 +89,7 @@ export function OilAreaAnalysis() {
processedFilesRef.current.add(file);
exifr
.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false } as unknown as Parameters<typeof exifr.parse>[1])
.then((exif) => {
const info: ImageExif = {
lat: exif?.latitude ?? null,

파일 보기

@ -300,7 +300,7 @@ export function AoiPanel() {
getLineWidth: (d: MonitorZone) => (selectedZone === d.id ? 3 : 1.5),
lineWidthUnits: 'pixels',
pickable: true,
onClick: ({ object }: { object: MonitorZone }) => {
onClick: ({ object }: { object?: MonitorZone }) => {
if (object && !isDrawing)
setSelectedZone(object.id === selectedZone ? null : object.id);
},

파일 보기

@ -238,7 +238,7 @@ export function DetectPanel() {
lineWidthMinPixels: 2,
stroked: true,
pickable: true,
onClick: ({ object }: { object: VesselDetection }) => {
onClick: ({ object }: { object?: VesselDetection }) => {
if (object) setSelectedId(object.id === selectedId ? null : object.id);
},
updateTriggers: { getRadius: [selectedId] },

파일 보기

@ -247,17 +247,17 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{mapTypes.map((item) => (
<button
key={item.mapKey}
onClick={() => toggleMap(item.mapKey)}
onClick={() => toggleMap(item.mapKey as keyof typeof mapToggles)}
className="w-full px-3 py-2 flex items-center justify-between text-title-5 text-fg-sub hover:bg-[var(--hover-overlay)] transition-all"
>
<span className="flex items-center gap-2.5">
<span className="text-title-4">🗺</span> {item.mapNm}
</span>
<div
className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey as keyof typeof mapToggles] ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
>
<div
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`}
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey as keyof typeof mapToggles] ? 'left-[16px]' : 'left-[2px]'}`}
/>
</div>
</button>

파일 보기

@ -273,12 +273,11 @@ export function BaseMap({
<Map
initialViewState={{ longitude: center[1], latitude: center[0], zoom }}
mapStyle={mapStyle}
className="w-full h-full"
onClick={handleClick}
onZoom={handleZoom}
style={{ cursor: cursor ?? 'grab' }}
style={{ cursor: cursor ?? 'grab', width: '100%', height: '100%' }}
attributionControl={false}
preserveDrawingBuffer={true}
{...({ preserveDrawingBuffer: true } as Record<string, unknown>)}
>
{/* 공통 오버레이 */}
<S57EncOverlay visible={mapToggles.s57 ?? false} />

파일 보기

@ -15,11 +15,12 @@ const PI_4 = Math.PI / 4;
const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리)
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
const { current: map } = useMap();
const animRef = useRef<number>();
const { current: mapRef } = useMap();
const animRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!map || !hydrStep) return;
if (!mapRef || !hydrStep) return;
const map = mapRef;
const container = map.getContainer();
const canvas = document.createElement('canvas');
@ -212,7 +213,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
map.off('move', onMove);
canvas.remove();
};
}, [map, hydrStep]);
}, [mapRef, hydrStep]);
return null;
}

파일 보기

@ -17,7 +17,7 @@ import type { SensitiveResource } from '@interfaces/prediction/PredictionInterfa
import type {
HydrDataStep,
SensitiveResourceFeatureCollection,
} from '@components/prediction/services/predictionApi';
} from '@interfaces/prediction/PredictionInterface';
import HydrParticleOverlay from './HydrParticleOverlay';
import { TimelineControl } from './TimelineControl';
import type { BoomLine, BoomLineCoord } from '@/types/boomLine';
@ -1331,8 +1331,9 @@ export function MapView({
zoom: zoom,
}}
mapStyle={currentMapStyle}
className="w-full h-full"
style={{
width: '100%',
height: '100%',
cursor:
isSelectingLocation || drawAnalysisMode !== null || measureMode !== null
? 'crosshair'
@ -1340,7 +1341,7 @@ export function MapView({
}}
onClick={handleMapClick}
attributionControl={false}
preserveDrawingBuffer={true}
{...({ preserveDrawingBuffer: true } as Record<string, unknown>)}
>
{/* 지도 캡처 셋업 */}
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}

파일 보기

@ -315,12 +315,6 @@ export function HNSSubstanceView() {
const [activeTab, setActiveTab] = useState(0);
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchName, setDetailSearchName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchCas, setDetailSearchCas] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류');
/* Panel 3: 물질 상세검색 state */
const [hmsSearchType, setHmsSearchType] = useState<
'all' | 'abbr' | 'korName' | 'engName' | 'cas' | 'un'
@ -439,18 +433,6 @@ ${styles}
return matchCategory && matchSearch;
});
/* Detail search filter for Panel 3 (legacy) */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const detailFiltered = substances.filter((s) => {
const qName = detailSearchName.toLowerCase();
const qCas = detailSearchCas.toLowerCase();
const matchName =
!qName || s.name.toLowerCase().includes(qName) || s.nameEn.toLowerCase().includes(qName);
const matchCas = !qCas || s.casNumber.includes(qCas);
const matchSebc =
detailSearchSebc === '전체 거동분류' || s.sebc.includes(detailSearchSebc.split(' ')[0]);
return matchName && matchCas && matchSebc;
});
/* Panel 3: HNS API 기반 검색 결과 */
const HMS_PER_PAGE = 10;

파일 보기

@ -93,7 +93,7 @@ export function HNSView() {
const [inputParams, setInputParams] = useState<HNSInputParams | null>(null);
const [loadedParams, setLoadedParams] = useState<Partial<HNSInputParams> | null>(null);
const hasRunOnce = useRef(false); // 최초 실행 여부
const mapCaptureRef = useRef<(() => string | null) | null>(null);
const mapCaptureRef = useRef<(() => Promise<string | null>) | null>(null);
const handleReset = useCallback(() => {
setDispersionResult(null);
@ -376,7 +376,7 @@ export function HNSView() {
/** 분석 결과 저장 */
const handleSave = async () => {
if (!dispersionResult || !inputParams || !computedResult) {
if (!dispersionResult || !inputParams || !computedResult || !incidentCoord) {
alert('저장할 분석 결과가 없습니다. 먼저 예측을 실행해주세요.');
return;
}
@ -672,11 +672,11 @@ export function HNSView() {
};
/** 보고서 생성 — 실 데이터 수집 + 지도 캡처 후 탭 이동 */
const handleOpenReport = () => {
const handleOpenReport = async () => {
try {
let mapImage: string | null = null;
try {
mapImage = mapCaptureRef.current?.() ?? null;
mapImage = (await mapCaptureRef.current?.()) ?? null;
} catch {
/* canvas capture 실패 무시 */
}

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { fetchPredictionAnalyses } from '@components/prediction/services/predictionApi';
import type { PredictionAnalysis } from '@interfaces/prediction/PredictionInterface';
import type { PredictionAnalysis } from '@components/prediction/services/predictionApi';
import { fetchHnsAnalyses } from '@components/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import { fetchRescueOps } from '@components/rescue/services/rescueApi';

파일 보기

@ -70,54 +70,6 @@ interface AnalysisItem {
checked: boolean;
}
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반, 미사용 보존) ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const CATEGORY_ICON: Record<string, string> = {
: '🐟',
: '🦪',
: '🦪',
: '🐟',
: '🦪',
: '🌿',
: '🔲',
: '🦐',
: '📦',
: '🎣',
: '🎣',
: '🐟',
: '🪸',
: '🪨',
: '🚢',
: '🏖',
: '🪨',
: '🚤',
: '⛵',
: '🚢',
: '⛵',
: '⚓',
: '⚓',
: '⚓',
: '⚓',
: '🚢',
: '⛵',
: '🔴',
: '💧',
'취수구·배수구': '🚰',
LNG: '⚡',
: '🔌',
'발전소·산단': '🏭',
: '🏭',
: '🛢',
'해저케이블·배관': '🔌',
: '🪨',
_ESI: '🏖',
: '🛡',
: '🌿',
: '🐦',
: '🏖',
: '🐢',
'보호종 서식지': '🐢',
};
/* ── 헬퍼: 활성 모델 문자열 ─────────────────────── */
function getActiveModels(p: PredictionAnalysis): string {

파일 보기

@ -14,11 +14,14 @@ import { getVesselCacheStatus, type VesselCacheStatus } from '@common/services/v
import { IncidentsLeftPanel } from './IncidentsLeftPanel';
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel';
import { fetchIncidents } from '../services/incidentsApi';
import type { IncidentCompat } from '@interfaces/incidents/IncidentsInterface';
import type { Incident, IncidentCompat } from '@interfaces/incidents/IncidentsInterface';
import { fetchHnsAnalyses } from '@components/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import { buildHnsDispersionLayers } from '../utils/hnsDispersionLayers';
import { fetchAnalysisTrajectory, fetchOilSpillSummary } from '@components/prediction/services/predictionApi';
import {
fetchAnalysisTrajectory,
fetchOilSpillSummary,
} from '@components/prediction/services/predictionApi';
import type {
TrajectoryResponse,
SensitiveResourceFeatureCollection,
@ -512,8 +515,8 @@ export function IncidentsView() {
layers.push(
new PathLayer({
id: `traj-path-${pathId}`,
data: [{ path: sorted.map((p) => [p.lon, p.lat]) }],
getPath: (d: { path: number[][] }) => d.path,
data: [{ path: sorted.map((p) => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...color, 230] as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
@ -572,7 +575,8 @@ export function IncidentsView() {
if (filtered.features.length === 0) return null;
return new GeoJsonLayer({
id: 'incidents-sensitive-geojson',
data: filtered,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: filtered as any,
pickable: false,
stroked: true,
filled: true,
@ -1622,8 +1626,7 @@ function SplitResultMap({
}
/* ── (미사용) 분석별 SVG placeholder — 참고용 보존 ────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) {
export function SplitVisualization({ slotKey, color }: { slotKey: SplitSlotKey; color: string }) {
if (slotKey === 'oil') {
return (
<svg viewBox="0 0 320 200" className="w-full h-full">

파일 보기

@ -5,7 +5,8 @@ import {
fetchIncidentAerialMedia,
getMediaImageUrl,
} from '../services/incidentsApi';
import type { MediaInfo, AerialMediaItem } from '@interfaces/incidents/IncidentsInterface';
import type { MediaInfo } from '@interfaces/incidents/IncidentsInterface';
import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface';
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv';

파일 보기

@ -8,8 +8,17 @@ import { BitmapLayer, ScatterplotLayer } from '@deck.gl/layers';
import { computeDispersion } from '@components/hns/utils/dispersionEngine';
import { getSubstanceToxicity } from '@components/hns/utils/toxicityData';
import { hexToRgba } from '@components/common/map/mapUtils';
import type { HnsAnalysisItem, MeteoParams, SourceParams, SimParams } from '@interfaces/hns/HnsInterface';
import type { DispersionModel, AlgorithmType, StabilityClass } from '@types/hns/HnsType';
import type { HnsAnalysisItem } from '@interfaces/hns/HnsInterface';
import type {
MeteoParams,
SourceParams,
SimParams,
} from '@interfaces/hns/HnsInterface';
import type {
DispersionModel,
AlgorithmType,
StabilityClass,
} from '@/types/hns/HnsType';
// MapView와 동일한 색상 정지점
const COLOR_STOPS: [number, number, number, number][] = [

파일 보기

@ -776,6 +776,8 @@ export function OilSpillView() {
analyst: '',
officeName: '',
acdntSttsCd: 'ACTIVE',
predRunSn: null,
runDtm: null,
});
}, []);

파일 보기

@ -524,7 +524,7 @@ export function KospsPanel() {
{/* Akima 수심 보간 & NGSST 수온 */}
<div className="grid grid-cols-2 gap-2.5 mb-3">
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke">
<div className="rounded-lg p-3 bg-bg-card border border-stroke">
<div className="text-label-2 font-bold mb-2 text-color-accent">
🗺 Akima
</div>
@ -539,7 +539,7 @@ export function KospsPanel() {
<span className="text-label-2 text-fg-default">(i5, i+j5)</span>
</div>
</div>
<div className="rounded-lg p-3" className="bg-bg-card border border-stroke">
<div className="rounded-lg p-3 bg-bg-card border border-stroke">
<div className="text-label-2 font-bold mb-2 text-color-accent">
🌡 NGSST
</div>

파일 보기

@ -4,7 +4,7 @@ export function RoadmapPanel() {
return (
<div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className={`${card} ${cardBg}`} className="m-0">
<div className={`${card} ${cardBg} m-0`}>
<div style={labelStyle('var(--color-info)')}> </div>
<div className="flex flex-col gap-2 text-label-2 text-fg-sub">
<div
@ -42,7 +42,7 @@ export function RoadmapPanel() {
</div>
</div>
</div>
<div className={`${card} ${cardBg}`} className="m-0">
<div className={`${card} ${cardBg} m-0`}>
<div style={labelStyle('var(--color-info)')}> </div>
<div className="flex flex-col gap-2 text-label-2 text-fg-sub">
{[

파일 보기

@ -4,14 +4,29 @@ import type {
PredictionDetail,
BacktrackResult,
TrajectoryResponse,
OilSpillSummaryResponse,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
SpreadParticlesGeojson,
HydrDataStep,
OilSpillSummaryResponse,
ImageAnalyzeResult,
GscAccidentListItem,
} from '@interfaces/prediction/PredictionInterface';
export type {
PredictionAnalysis,
PredictionDetail,
BacktrackResult,
TrajectoryResponse,
SensitiveResourceCategory,
SensitiveResourceFeature,
SensitiveResourceFeatureCollection,
SpreadParticlesGeojson,
HydrDataStep,
OilSpillSummaryResponse,
};
export const fetchPredictionAnalyses = async (params?: {
search?: string;
acdntSn?: number;
@ -64,17 +79,6 @@ export const fetchAnalysisTrajectory = async (
return response.data;
};
export const fetchOilSpillSummary = async (
acdntSn: number,
predRunSn?: number,
): Promise<OilSpillSummaryResponse> => {
const response = await api.get<OilSpillSummaryResponse>(
`/prediction/analyses/${acdntSn}/oil-summary`,
predRunSn != null ? { params: { predRunSn } } : undefined,
);
return response.data;
};
export const fetchSensitiveResources = async (
acdntSn: number,
): Promise<SensitiveResourceCategory[]> => {
@ -134,3 +138,18 @@ export const fetchGscAccidents = async (): Promise<GscAccidentListItem[]> => {
const response = await api.get<GscAccidentListItem[]>('/gsc/accidents');
return response.data;
};
// ============================================================
// 유류 확산 요약
// ============================================================
export const fetchOilSpillSummary = async (
acdntSn: number,
predRunSn?: number,
): Promise<OilSpillSummaryResponse> => {
const response = await api.get<OilSpillSummaryResponse>(
`/prediction/analyses/${acdntSn}/oil-summary`,
predRunSn != null ? { params: { predRunSn } } : undefined,
);
return response.data;
};

파일 보기

@ -27,7 +27,7 @@ export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportDa
author: '',
reportType: '예측보고서',
analysisCategory: '',
jurisdiction: jurisdiction || '',
jurisdiction: jurisdiction ?? '남해청',
status: '수행중',
incident: {
name: '',

파일 보기

@ -1,9 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
OilSpillReportTemplate,
type OilSpillReportData,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { OilSpillReportTemplate } from './OilSpillReportTemplate';
import type { OilSpillReportData } from '@interfaces/reports/ReportsInterface';
import type { Jurisdiction } from '@/types/reports/ReportsType';
import { loadReportsFromApi, loadReportDetail, deleteReportApi } from '../services/reportsApi';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { templateTypes } from './reportTypes';

파일 보기

@ -230,7 +230,7 @@ export function SensitiveResourceMapSection({
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
} as maplibregl.MapOptions);
mapRef.current = map;
map.on('load', () => {
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)

파일 보기

@ -62,7 +62,7 @@ export function SensitivityMapSection({
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
} as maplibregl.MapOptions);
mapRef.current = map;
map.on('load', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -104,7 +104,7 @@ export function SensitivityMapSection({
// fitBounds
const coords: [number, number][] = [];
displayGeojson.features.forEach((f) => {
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry;
const geom = (f as unknown as { geometry: { type: string; coordinates: unknown } }).geometry;
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number]);
else if (geom.type === 'Polygon')
(geom.coordinates as [number, number][][])[0]?.forEach((c) => coords.push(c));

파일 보기

@ -4,6 +4,7 @@ import type {
RescueOpsItem,
RescueScenarioItem,
RescueScenario,
ChartDataItem,
} from '@interfaces/rescue/RescueInterface';
import type { Severity } from '@/types/rescue/RescueType';
import { ScenarioMapOverlay } from './contents/ScenarioMapOverlay';

파일 보기

@ -9,6 +9,7 @@ export async function fetchRescueOps(params?: {
sttsCd?: string;
acdntTpCd?: string;
search?: string;
acdntSn?: number;
}): Promise<RescueOpsItem[]> {
const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params });
return response.data;

파일 보기

@ -34,9 +34,6 @@ export function PreScatView() {
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [panelDetail, setPanelDetail] = useState<ScatDetail | null>(null);
const [panelLoading, setPanelLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [timelineIdx, setTimelineIdx] = useState(6);
// 초기 관할청 목록 로딩
useEffect(() => {
let cancelled = false;

파일 보기

@ -113,8 +113,6 @@ function SegRow(
function ScatLeftPanel({
segments,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
zones,
jurisdictions,
offices,
selectedOffice,
@ -125,8 +123,6 @@ function ScatLeftPanel({
jurisdictionFilter,
onJurisdictionChange,
areaFilter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAreaChange,
phaseFilter,
onPhaseChange,
statusFilter,

파일 보기

@ -24,6 +24,7 @@ export interface EnrichedWeatherStation extends WeatherStation {
};
pressure: number;
visibility: number;
salinity?: number;
}
/**

파일 보기

@ -45,7 +45,7 @@ export async function getUltraShortForecast(
// 데이터를 시간대별로 그룹화
const forecasts: WeatherForecastData[] = [];
const grouped = new Map<string, Record<string, unknown>>();
const grouped = new Map<string, WeatherForecastData>();
items.forEach((item: Record<string, string>) => {
const key = `${item.fcstDate}-${item.fcstTime}`;
@ -61,10 +61,11 @@ export async function getUltraShortForecast(
waveHeight: 0,
precipitation: 0,
humidity: 0,
});
} as WeatherForecastData);
}
const forecast = grouped.get(key);
if (!forecast) return;
// 카테고리별 값 매핑
switch (item.category) {

파일 보기

@ -207,19 +207,3 @@ export function windDirectionToText(degree: number): string {
const index = Math.round((degree % 360) / 22.5) % 16;
return directions[index];
}
// 해상 기상 정보 (Mock - 실제로는 해양기상청 API 사용)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function getMarineWeather(lat: number, lng: number) {
// TODO: 해양기상청 API 연동
// 현재는 Mock 데이터 반환
return {
waveHeight: 1.2, // 파고 (m)
waveDirection: 135, // 파향 (도)
wavePeriod: 5.5, // 주기 (초)
seaTemperature: 12.5, // 수온 (°C)
currentSpeed: 0.3, // 해류속도 (m/s)
currentDirection: 180, // 해류방향 (도)
visibility: 15, // 시정 (km)
};
}

파일 보기

@ -202,7 +202,7 @@ export interface HNSInputParams {
/** HNS 분석 — 재계산 모달 입력 파라미터 */
export interface RecalcParams {
substance: string;
releaseType: '연속 유출' | '순간 유출' | '밀도가스 유출';
releaseType: ReleaseType;
emissionRate: number;
totalRelease: number;
algorithm: string;

파일 보기

@ -29,6 +29,8 @@ export interface IncidentListItem {
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}

파일 보기

@ -10,23 +10,6 @@ interface ColorStep {
color: string;
}
interface Marker {
step: number;
label: string;
}
interface ContrastRating {
step: number;
rating: string;
}
interface ColorScaleBarProps {
steps: ColorStep[];
markers?: Marker[];
contrastRatings?: ContrastRating[];
darkBg?: boolean;
isDark: boolean;
}
interface ColorToken {
name: string;
@ -448,117 +431,6 @@ const ChipRow = ({ hex, role, gray, d, subdued = false }: ChipRowProps) => (
</div>
);
// ---------- 내부 컴포넌트: ColorScaleBar ----------
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ColorScaleBar = ({
steps,
markers,
contrastRatings,
darkBg = false,
isDark,
}: ColorScaleBarProps) => {
const badgeBg = isDark ? '#374151' : '#374151';
const badgeText = isDark ? '#e5e7eb' : '#fff';
const getContrastRating = (step: number): string | undefined => {
return contrastRatings?.find((r) => r.step === step)?.rating;
};
const getMarker = (step: number): Marker | undefined => {
return markers?.find((m) => m.step === step);
};
return (
<div>
{/* 색상 바 */}
<div
className="flex rounded-lg overflow-hidden"
style={{ border: `1px solid ${darkBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}` }}
>
{steps.map(({ step, color }, idx) => {
const isFirst = idx === 0;
const isLast = idx === steps.length - 1;
const textColor = step < 50 ? (darkBg ? '#e2e8f0' : '#1e293b') : '#e2e8f0';
const rating = getContrastRating(step);
return (
<div
key={step}
className="flex flex-col items-center justify-between"
style={{
flex: 1,
backgroundColor: color,
height: '60px',
borderLeft: isFirst
? 'none'
: `1px solid ${darkBg ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'}`,
borderTopLeftRadius: isFirst ? '8px' : undefined,
borderBottomLeftRadius: isFirst ? '8px' : undefined,
borderTopRightRadius: isLast ? '8px' : undefined,
borderBottomRightRadius: isLast ? '8px' : undefined,
padding: '6px 0',
}}
>
{/* 상단: 단계 번호 */}
<span style={{ fontSize: '9px', color: textColor, lineHeight: 1 }}>{step}</span>
{/* 하단: 접근성 등급 */}
{rating && (
<span style={{ fontSize: '8px', color: textColor, lineHeight: 1, opacity: 0.8 }}>
{rating}
</span>
)}
</div>
);
})}
</div>
{/* 마커 행 */}
{markers && markers.length > 0 && (
<div className="flex relative" style={{ height: '32px', marginTop: '2px' }}>
{steps.map(({ step }, idx) => {
const marker = getMarker(step);
const pct = (idx / (steps.length - 1)) * 100;
if (!marker) return null;
return (
<div
key={step}
className="absolute flex flex-col items-center"
style={{ left: `calc(${pct}% )`, transform: 'translateX(-50%)' }}
>
{/* 점선 */}
<div
style={{
width: 0,
height: '10px',
borderLeft: '1px dashed rgba(156,163,175,0.7)',
}}
/>
{/* 뱃지 */}
<span
className="px-2 rounded"
style={{
fontSize: '10px',
lineHeight: '18px',
backgroundColor: badgeBg,
color: badgeText,
border: '1px solid rgba(255,255,255,0.1)',
whiteSpace: 'nowrap',
}}
>
{marker.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
};
// ---------- 내부 컴포넌트: DualModeSection ----------
interface DualModeSectionProps {
@ -570,8 +442,7 @@ interface DualModeSectionProps {
darkSpecs: React.ReactNode;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const DualModeSection = ({
export const DualModeSection = ({
lightBg = '#F5F5F5',
darkBg = '#121418',
lightContent,
@ -618,8 +489,7 @@ interface ColorSpecRowProps {
dark?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ColorSpecRow = ({ role, gray, hex, dark = false }: ColorSpecRowProps) => (
export const ColorSpecRow = ({ role, gray, hex, dark = false }: ColorSpecRowProps) => (
<div
className="flex items-center gap-4 px-4 py-3"
style={{

파일 보기

@ -16,7 +16,7 @@ export type DispersionModel = 'plume' | 'puff' | 'dense_gas';
export type AlgorithmType = 'ALOHA (EPA)' | 'CAMEO' | 'Gaussian Plume' | 'AERMOD';
/** HNS 확산 — 유출 형태 UI 선택값 (연속/순간/풀증발) */
export type ReleaseType = '연속 유출' | '순간 유출' | '풀(Pool) 증발';
export type ReleaseType = '연속 유출' | '순간 유출' | '풀(Pool) 증발' | '밀도가스 유출';
/** HNS 시나리오 — 시나리오 위험도 분류 */
export type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED';