chore: develop 머지 충돌 해결

This commit is contained in:
leedano 2026-04-14 11:22:19 +09:00
커밋 fef7583eb5
33개의 변경된 파일7051개의 추가작업 그리고 470개의 파일을 삭제

파일 보기

@ -7,6 +7,7 @@ import {
getIncidentWeather,
saveIncidentWeather,
getIncidentMedia,
getIncidentImageAnalysis,
} from './incidentsService.js';
const router = Router();
@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
}
});
// ============================================================
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
// ============================================================
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
return;
}
const data = await getIncidentImageAnalysis(sn);
if (!data) {
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
return;
}
res.json(data);
} catch (err) {
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
}
});
export default router;

파일 보기

@ -24,7 +24,9 @@ interface IncidentListItem {
spilQty: number | null;
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}
interface PredExecItem {
@ -111,11 +113,17 @@ export async function listIncidents(filters: {
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a
LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
@ -148,7 +156,9 @@ export async function listIncidents(filters: {
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
}));
}
@ -162,11 +172,17 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a
LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
@ -205,7 +221,9 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
predictions,
weather,
media,
@ -419,3 +437,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
};
}
// ============================================================
// 이미지 분석 데이터 조회
// ============================================================
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
const sql = `
SELECT IMG_RSLT_DATA
FROM wing.SPIL_DATA
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
ORDER BY SPIL_DATA_SN
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
}

파일 보기

@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
return { lat, lon, occurredAt };
}
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> {
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
const fileId = crypto.randomUUID();
// camTy는 현재 "mx15hdi"로 하드코딩한다.
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
const volume = firstOil?.volume ?? 0;
// ACDNT INSERT
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
await wingPool.query(
`INSERT INTO wing.SPIL_DATA
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`,
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
[
acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',

파일 보기

@ -230,7 +230,8 @@ router.post(
res.status(400).json({ error: '이미지 파일이 필요합니다' });
return;
}
const result = await analyzeImageFile(req.file.buffer, req.file.originalname);
const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
res.json(result);
} catch (err: unknown) {
if (err instanceof Error) {

파일 보기

@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
const OIL_TYPE_MAP: Record<string, string> = {
'벙커C유': 'GENERIC BUNKER C',
'경유': 'GENERIC DIESEL',
'원유': 'WEST TEXAS INTERMEDIATE (WTI)',
'원유': 'WEST TEXAS INTERMEDIATE',
'중유': 'GENERIC HEAVY FUEL OIL',
'등유': 'FUEL OIL NO.1 (KEROSENE)',
'등유': 'FUEL OIL NO.1 (KEROSENE) ',
'휘발유': 'GENERIC GASOLINE',
}

파일 보기

@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
ACDNT_SN INTEGER NOT NULL, -- 사고순번
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
SPIL_QTY NUMERIC(12,2), -- 유출량
SPIL_QTY NUMERIC(14,10), -- 유출량
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리

파일 보기

@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL,
ACDNT_SN INTEGER NOT NULL,
OIL_TP_CD VARCHAR(50) NOT NULL,
SPIL_QTY NUMERIC(12,2),
SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER,

파일 보기

@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
SBST_NM VARCHAR(100),
UN_NO VARCHAR(10),
CAS_NO VARCHAR(20),
SPIL_QTY NUMERIC(10,2),
SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER,

파일 보기

@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
);
-- ============================================================
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
-- - 유출 모델링: 파공부 유출률 변화
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
-- ============================================================
INSERT INTO RESCUE_SCENARIO (
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
) VALUES
-- S-01: 사고 발생 (Initial Impact)
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
(
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
'[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]',
1
),
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
(
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]',
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
2
),
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
(
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]',
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
3
),
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
(
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]',
'[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]',
4
),
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
(
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
'[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]',
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
5
),
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
(
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
'[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
'[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]',
6
),
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
(
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
'[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]',
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
7
),
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
(
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
'[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]',
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
8
),
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
(
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
'[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]',
'[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]',
9
),
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
(
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
5
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
'[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]',
10
);

파일 보기

@ -0,0 +1,7 @@
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
-- 정수부 최대 4자리, 소수부 10자리
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);

파일 보기

@ -6,13 +6,32 @@
### 추가
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
- 관리자: 비식별화조치 메뉴 및 패널 추가
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
### 변경
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
## [2026-04-13]
### 추가
- 사고별 이미지 분석 데이터 조회 API 추가
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
- 사고 마커 클릭 팝업 디자인 리뉴얼
- 지도에 필터링된 사고만 표시되도록 개선
### 변경
- 이미지 분석 시 사고명 파라미터 지원
- 기본 예측시간 48시간 → 6시간으로 변경
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
- OpenDrift 유종 매핑 수정 (원유, 등유)
- 소량 유출량 과학적 표기법으로 표시
## [2026-04-09]
### 추가
- HNS 확산 파티클 렌더링 성능 최적화 (TypedArray + 수동 Mercator 투영 + 페이드 트레일)
- 오염 종합 상황/확산 예측 요약 위험도 뱃지 동적 표시 (심각/경계/주의/관심 4단계)
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가

파일 보기

@ -1,7 +1,6 @@
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;
@ -9,24 +8,13 @@ interface HydrParticleOverlayProps {
const PARTICLE_COUNT = 3000;
const MAX_AGE = 300;
const SPEED_SCALE = 0.1;
const SPEED_SCALE = 0.15;
const DT = 600;
const TRAIL_LENGTH = 30; // 파티클당 저장할 화면 좌표 수
const NUM_ALPHA_BANDS = 4; // stroke 배치 단위
interface TrailPoint {
x: number;
y: number;
}
interface Particle {
lon: number;
lat: number;
trail: TrailPoint[];
age: number;
}
const DEG_TO_RAD = Math.PI / 180;
const PI_4 = Math.PI / 4;
const FADE_ALPHA = 0.02; // 프레임당 페이드량 (낮을수록 긴 꼬리)
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
const lightMode = useThemeStore((s) => s.theme) === 'light';
const { current: map } = useMap();
const animRef = useRef<number>();
@ -52,21 +40,21 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
const lats: number[] = [boundLonLat.bottom];
for (const d of latInterval) lats.push(lats[lats.length - 1] + d);
function bisect(arr: number[], val: number): number {
let lo = 0,
hi = arr.length - 2;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (val < arr[mid]) hi = mid - 1;
else if (val >= arr[mid + 1]) lo = mid + 1;
else return mid;
}
return -1;
}
function getUV(lon: number, lat: number): [number, number] {
let col = -1,
row = -1;
for (let i = 0; i < lons.length - 1; i++) {
if (lon >= lons[i] && lon < lons[i + 1]) {
col = i;
break;
}
}
for (let i = 0; i < lats.length - 1; i++) {
if (lat >= lats[i] && lat < lats[i + 1]) {
row = i;
break;
}
}
const col = bisect(lons, lon);
const row = bisect(lats, lat);
if (col < 0 || row < 0) return [0, 0];
const fx = (lon - lons[col]) / (lons[col + 1] - lons[col]);
const fy = (lat - lats[row]) / (lats[row + 1] - lats[row]);
@ -78,96 +66,134 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
v01 = v2d[row]?.[col + 1] ?? v00;
const v10 = v2d[row + 1]?.[col] ?? v00,
v11 = v2d[row + 1]?.[col + 1] ?? v00;
const u =
u00 * (1 - fx) * (1 - fy) + u01 * fx * (1 - fy) + u10 * (1 - fx) * fy + u11 * fx * fy;
const v =
v00 * (1 - fx) * (1 - fy) + v01 * fx * (1 - fy) + v10 * (1 - fx) * fy + v11 * fx * fy;
return [u, v];
const _1fx = 1 - fx,
_1fy = 1 - fy;
return [
u00 * _1fx * _1fy + u01 * fx * _1fy + u10 * _1fx * fy + u11 * fx * fy,
v00 * _1fx * _1fy + v01 * fx * _1fy + v10 * _1fx * fy + v11 * fx * fy,
];
}
const bbox = boundLonLat;
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => ({
lon: bbox.left + Math.random() * (bbox.right - bbox.left),
lat: bbox.bottom + Math.random() * (bbox.top - bbox.bottom),
trail: [],
age: Math.floor(Math.random() * MAX_AGE),
}));
const bboxW = bbox.right - bbox.left;
const bboxH = bbox.top - bbox.bottom;
function resetParticle(p: Particle) {
p.lon = bbox.left + Math.random() * (bbox.right - bbox.left);
p.lat = bbox.bottom + Math.random() * (bbox.top - bbox.bottom);
p.trail = [];
p.age = 0;
// 파티클: 위치 + 이전 화면좌표 (선분 1개만 그리면 됨)
const pLon = new Float64Array(PARTICLE_COUNT);
const pLat = new Float64Array(PARTICLE_COUNT);
const pAge = new Int32Array(PARTICLE_COUNT);
const pPrevX = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 X
const pPrevY = new Float32Array(PARTICLE_COUNT); // 이전 프레임 화면 Y
const pHasPrev = new Uint8Array(PARTICLE_COUNT); // 이전 좌표 유효 여부
for (let i = 0; i < PARTICLE_COUNT; i++) {
pLon[i] = bbox.left + Math.random() * bboxW;
pLat[i] = bbox.bottom + Math.random() * bboxH;
pAge[i] = Math.floor(Math.random() * MAX_AGE);
pHasPrev[i] = 0;
}
// 지도 이동/줌 시 화면 좌표가 틀어지므로 trail 초기화
function resetParticle(i: number) {
pLon[i] = bbox.left + Math.random() * bboxW;
pLat[i] = bbox.bottom + Math.random() * bboxH;
pAge[i] = 0;
pHasPrev[i] = 0;
}
// Mercator 수동 투영
function lngToMercX(lng: number, worldSize: number): number {
return ((lng + 180) / 360) * worldSize;
}
function latToMercY(lat: number, worldSize: number): number {
return ((1 - Math.log(Math.tan(PI_4 + (lat * DEG_TO_RAD) / 2)) / Math.PI) / 2) * worldSize;
}
// 지도 이동 시 캔버스 초기화 + 이전 좌표 무효화
const onMove = () => {
for (const p of particles) p.trail = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < PARTICLE_COUNT; i++) pHasPrev[i] = 0;
};
map.on('move', onMove);
function animate() {
// 매 프레임 완전 초기화 → 잔상 없음
ctx.clearRect(0, 0, canvas.width, canvas.height);
const w = canvas.width;
const h = canvas.height;
// alpha band별 세그먼트 버퍼 (드로우 콜 최소화)
const bands: [number, number, number, number][][] = Array.from(
{ length: NUM_ALPHA_BANDS },
() => [],
);
// ── 페이드: 기존 내용을 서서히 지움 (destination-out) ──
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
for (const p of particles) {
const [u, v] = getUV(p.lon, p.lat);
const speed = Math.sqrt(u * u + v * v);
if (speed < 0.001) {
resetParticle(p);
// 뷰포트 transform (프레임당 1회)
const zoom = map.getZoom();
const center = map.getCenter();
const bearing = map.getBearing();
const worldSize = 512 * Math.pow(2, zoom);
const cx = lngToMercX(center.lng, worldSize);
const cy = latToMercY(center.lat, worldSize);
const halfW = w / 2;
const halfH = h / 2;
const bearingRad = -bearing * DEG_TO_RAD;
const cosB = Math.cos(bearingRad);
const sinB = Math.sin(bearingRad);
const hasBearing = Math.abs(bearing) > 0.01;
// ── 파티클당 선분 1개만 그리기 (3000 선분) ──
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < PARTICLE_COUNT; i++) {
const lon = pLon[i],
lat = pLat[i];
const [u, v] = getUV(lon, lat);
const speed2 = u * u + v * v;
if (speed2 < 0.000001) {
resetParticle(i);
continue;
}
const cosLat = Math.cos((p.lat * Math.PI) / 180);
p.lon += (u * SPEED_SCALE * DT) / (cosLat * 111320);
p.lat += (v * SPEED_SCALE * DT) / 111320;
p.age++;
const cosLat = Math.cos(lat * DEG_TO_RAD);
pLon[i] = lon + (u * SPEED_SCALE * DT) / (cosLat * 111320);
pLat[i] = lat + (v * SPEED_SCALE * DT) / 111320;
pAge[i]++;
if (
p.lon < bbox.left ||
p.lon > bbox.right ||
p.lat < bbox.bottom ||
p.lat > bbox.top ||
p.age > MAX_AGE
pLon[i] < bbox.left ||
pLon[i] > bbox.right ||
pLat[i] < bbox.bottom ||
pLat[i] > bbox.top ||
pAge[i] > MAX_AGE
) {
resetParticle(p);
resetParticle(i);
continue;
}
const curr = map.project([p.lon, p.lat]);
if (!curr) continue;
p.trail.push({ x: curr.x, y: curr.y });
if (p.trail.length > TRAIL_LENGTH) p.trail.shift();
if (p.trail.length < 2) continue;
for (let i = 1; i < p.trail.length; i++) {
const t = i / p.trail.length; // 0=oldest, 1=newest
const band = Math.min(NUM_ALPHA_BANDS - 1, Math.floor(t * NUM_ALPHA_BANDS));
const a = p.trail[i - 1],
b = p.trail[i];
bands[band].push([a.x, a.y, b.x, b.y]);
// 수동 Mercator 투영
let dx = lngToMercX(pLon[i], worldSize) - cx;
let dy = latToMercY(pLat[i], worldSize) - cy;
if (hasBearing) {
const rx = dx * cosB - dy * sinB;
const ry = dx * sinB + dy * cosB;
dx = rx;
dy = ry;
}
const sx = dx + halfW;
const sy = dy + halfH;
// 이전 좌표가 있으면 선분 1개 추가
if (pHasPrev[i]) {
ctx.moveTo(pPrevX[i], pPrevY[i]);
ctx.lineTo(sx, sy);
}
pPrevX[i] = sx;
pPrevY[i] = sy;
pHasPrev[i] = 1;
}
// alpha band별 일괄 렌더링
ctx.lineWidth = 0.8;
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255];
ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
ctx.beginPath();
for (const [x1, y1, x2, y2] of bands[b]) {
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
}
ctx.stroke();
animRef.current = requestAnimationFrame(animate);
}
@ -186,7 +212,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
map.off('move', onMove);
canvas.remove();
};
}, [map, hydrStep, lightMode]);
}, [map, hydrStep]);
return null;
}

파일 보기

@ -4,6 +4,21 @@
z-index: 500;
}
/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */
.incident-popup .maplibregl-popup-content {
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
border: none;
}
.incident-popup .maplibregl-popup-tip {
border-top-color: var(--bg-elevated);
border-bottom-color: var(--bg-elevated);
border-left-color: transparent;
border-right-color: transparent;
}
@layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content {

파일 보기

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import AdminSidebar from './AdminSidebar';
import AdminPlaceholder from './AdminPlaceholder';
import { findMenuLabel } from './adminMenuConfig';
@ -19,9 +19,15 @@ import MonitorVesselPanel from './MonitorVesselPanel';
import CollectHrPanel from './CollectHrPanel';
import MonitorForecastPanel from './MonitorForecastPanel';
import VesselMaterialsPanel from './VesselMaterialsPanel';
import DeidentifyPanel from './DeidentifyPanel';
import RndPoseidonPanel from './RndPoseidonPanel';
import RndKospsPanel from './RndKospsPanel';
import RndHnsAtmosPanel from './RndHnsAtmosPanel';
import RndRescuePanel from './RndRescuePanel';
import SystemArchPanel from './SystemArchPanel';
/** 기존 패널이 있는 메뉴 ID 매핑 */
const PANEL_MAP: Record<string, () => JSX.Element> = {
const PANEL_MAP: Record<string, () => React.JSX.Element> = {
users: () => <UsersPanel />,
permissions: () => <PermissionsPanel />,
menus: () => <MenusPanel />,
@ -42,6 +48,12 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
'monitor-vessel': () => <MonitorVesselPanel />,
'collect-hr': () => <CollectHrPanel />,
'monitor-forecast': () => <MonitorForecastPanel />,
deidentify: () => <DeidentifyPanel />,
'rnd-poseidon': () => <RndPoseidonPanel />,
'rnd-kosps': () => <RndKospsPanel />,
'rnd-hns-atmos': () => <RndHnsAtmosPanel />,
'rnd-rescue': () => <RndRescuePanel />,
'system-arch': () => <SystemArchPanel />,
};
export function AdminView() {

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

파일 보기

@ -0,0 +1,638 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | '충북대 API';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'chungbuk-api',
name: '충북대 API 서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: 'API 호출',
},
{
id: 'atmos-compute',
name: 'HNS 대기확산 연산',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 시작 즉시',
},
{
id: 'result-receive',
name: '결과 수신',
status: '지연',
lastReceived: '2026-04-11 06:00',
cycle: '연산 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: 'SST',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: '해류',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '대기안정도',
size: '14 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:07',
source: '충북대 API',
dataType: 'API 호출 요청',
size: '0.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:15',
source: '충북대 API',
dataType: 'HNS 확산 결과',
size: '-',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: 'SSH',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상청',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:07',
source: '충북대 API',
dataType: 'API 호출 요청',
size: '0.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:20',
source: '충북대 API',
dataType: 'HNS 확산 결과',
size: '12 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 03:20',
source: '충북대 API',
dataType: '피해범위 데이터',
size: '4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:05',
source: '기상청',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: 'SST',
size: '97 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '주의',
message: '충북대 API 결과 수신 지연 — 최근 응답 15분 지연 (2026-04-11 06:15)',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '정보',
message: 'HYCOM 데이터 정상 수신 완료 (2026-04-11 06:00)',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 HNS 대기확산 예측 완료: 2회/4회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface HnsAtmosData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchHnsAtmosData(): Promise<HnsAtmosData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndHnsAtmosPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchHnsAtmosData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1">HNS () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">2 / 4</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="충북대 API"> API</option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,638 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'kosps-server',
name: '광주 KOSPS 서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: '수신 즉시',
},
{
id: 'fortran-dll',
name: 'KOSPS Fortran DLL 연산',
status: '지연',
lastReceived: '2026-04-11 05:45',
cycle: '예측 시작 즉시',
},
{
id: 'result-api',
name: '결과 수신 API',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '28 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 05:55',
source: 'KOSPS DLL',
dataType: 'DLL 호출 요청',
size: '3 MB',
receiveStatus: '수신완료',
processStatus: '처리중',
},
{
id: 'log-08',
timestamp: '2026-04-11 05:45',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '-',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 03:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '140 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상청',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:00',
source: 'KOSPS DLL',
dataType: 'DLL 응답 결과',
size: '27 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:05',
source: '기상청',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: 'KOSPS DLL',
dataType: 'DLL 호출 요청',
size: '-',
receiveStatus: '수신실패',
processStatus: '오류',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '경고',
message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과',
timestamp: '2026-04-11 05:45',
},
{
id: 'alert-02',
level: '주의',
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 KOSPS 예측 완료: 3회 / 6회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface KospsData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchKospsData(): Promise<KospsData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndKospsPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchKospsData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> (KOSPS) </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">3 / 6</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="KOSPS DLL">KOSPS DLL</option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,665 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상수치모델' | '기상기술';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상수치모델(KMA)',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'relay',
name: '기상기술 중계서버',
status: '지연',
lastReceived: '2026-04-11 05:30',
cycle: '3시간 주기',
},
{
id: 'api',
name: '해경 9층 API서버',
status: '정상',
lastReceived: '2026-04-11 06:05',
cycle: '수신 즉시',
},
{
id: 'gpu',
name: 'GPU 연산서버',
status: '정상',
lastReceived: '2026-04-11 06:10',
cycle: '예측 시작 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:05',
source: '기상수치모델',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: '기상수치모델',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:00',
source: '기상수치모델',
dataType: '강수량',
size: '11 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '310 MB',
receiveStatus: '수신대기',
processStatus: '대기',
},
{
id: 'log-09',
timestamp: '2026-04-11 03:10',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '140 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 03:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '37 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '97 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '305 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:05',
source: '기상수치모델',
dataType: '기압',
size: '23 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-11 00:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '-',
receiveStatus: '수신실패',
processStatus: '오류',
},
{
id: 'log-16',
timestamp: '2026-04-10 21:05',
source: '기상수치모델',
dataType: '풍향/풍속',
size: '36 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-17',
timestamp: '2026-04-10 21:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '139 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-18',
timestamp: '2026-04-10 21:00',
source: '기상기술',
dataType: '전처리 완료 데이터',
size: '302 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '경고',
message: '기상기술 중계서버 데이터 30분 지연 — 06:00 배치 전처리 미완료',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '주의',
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: 'GPU 연산서버 금일 처리 완료: 4회 / 8회',
timestamp: '2026-04-11 06:12',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface PoseidonData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchPoseidonData(): Promise<PoseidonData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:30:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndPoseidonPanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchPoseidonData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">4 / 8</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상수치모델"></option>
<option value="기상기술"></option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,638 @@
import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────────────────────────
type PipelineStatus = '정상' | '지연' | '중단';
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
type DataSource = 'HYCOM' | '기상청' | '긴급구난시스템';
type AlertLevel = '경고' | '주의' | '정보';
interface PipelineNode {
id: string;
name: string;
status: PipelineStatus;
lastReceived: string;
cycle: string;
}
interface DataLogRow {
id: string;
timestamp: string;
source: DataSource;
dataType: string;
size: string;
receiveStatus: ReceiveStatus;
processStatus: ProcessStatus;
}
interface AlertItem {
id: string;
level: AlertLevel;
message: string;
timestamp: string;
}
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
const MOCK_PIPELINE: PipelineNode[] = [
{
id: 'hycom',
name: 'HYCOM 해양순환모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '6시간 주기',
},
{
id: 'kma',
name: '기상청 수치모델',
status: '정상',
lastReceived: '2026-04-11 06:00',
cycle: '3시간 주기',
},
{
id: 'rescue',
name: '해경 긴급구난 시스템',
status: '정상',
lastReceived: '2026-04-11 06:30',
cycle: '내부 연계',
},
{
id: 'analysis',
name: '구난 분석 연산',
status: '정상',
lastReceived: '2026-04-11 06:35',
cycle: '연계 시작 즉시',
},
{
id: 'result',
name: '결과 연계 수신',
status: '정상',
lastReceived: '2026-04-11 06:40',
cycle: '분석 완료 즉시',
},
];
const MOCK_LOGS: DataLogRow[] = [
{
id: 'log-01',
timestamp: '2026-04-11 06:40',
source: '긴급구난시스템',
dataType: '구난 가능성 판단',
size: '1.2 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-02',
timestamp: '2026-04-11 06:35',
source: '긴급구난시스템',
dataType: '선체상태 분석',
size: '3.4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-03',
timestamp: '2026-04-11 06:30',
source: '긴급구난시스템',
dataType: '사고선 위치정보',
size: '0.8 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-04',
timestamp: '2026-04-11 06:30',
source: '긴급구난시스템',
dataType: '비상배인력 정보',
size: '0.5 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-05',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면온도(SST)',
size: '98 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-06',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해류(U/V)',
size: '142 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-07',
timestamp: '2026-04-11 06:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '54 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-08',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '풍향/풍속',
size: '38 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-09',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기압',
size: '22 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-10',
timestamp: '2026-04-11 06:00',
source: '기상청',
dataType: '기온',
size: '19 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-11',
timestamp: '2026-04-11 03:30',
source: '긴급구난시스템',
dataType: '선체상태 분석',
size: '3.1 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-12',
timestamp: '2026-04-11 03:30',
source: '긴급구난시스템',
dataType: '구난 가능성 판단',
size: '1.1 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-13',
timestamp: '2026-04-11 00:30',
source: '긴급구난시스템',
dataType: '사고선 위치정보',
size: '0.8 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
{
id: 'log-14',
timestamp: '2026-04-11 00:00',
source: 'HYCOM',
dataType: '해수면높이(SSH)',
size: '53 MB',
receiveStatus: '시간초과',
processStatus: '오류',
},
{
id: 'log-15',
timestamp: '2026-04-10 21:30',
source: '긴급구난시스템',
dataType: '비상배인력 정보',
size: '0.4 MB',
receiveStatus: '수신완료',
processStatus: '처리완료',
},
];
const MOCK_ALERTS: AlertItem[] = [
{
id: 'alert-01',
level: '정보',
message: '해경 긴급구난 시스템 정상 연계 중',
timestamp: '2026-04-11 06:30',
},
{
id: 'alert-02',
level: '정보',
message: 'HYCOM 데이터 정상 수신',
timestamp: '2026-04-11 06:00',
},
{
id: 'alert-03',
level: '정보',
message: '금일 긴급구난 분석 완료: 5회/6회',
timestamp: '2026-04-11 06:40',
},
];
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
interface RescueData {
pipeline: PipelineNode[];
logs: DataLogRow[];
alerts: AlertItem[];
}
function fetchRescueData(): Promise<RescueData> {
return new Promise((resolve) => {
setTimeout(
() =>
resolve({
pipeline: MOCK_PIPELINE,
logs: MOCK_LOGS,
alerts: MOCK_ALERTS,
}),
300,
);
});
}
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
function PipelineCard({ node }: { node: PipelineNode }) {
const badgeStyle = getPipelineStatusStyle(node.status);
const borderStyle = getPipelineBorderStyle(node.status);
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
{node.status}
</span>
<div className="text-label-2 text-t3 mt-0.5"> : {node.lastReceived}</div>
<div className="text-label-2 text-t3">{node.cycle}</div>
</div>
);
}
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
if (loading && nodes.length === 0) {
return (
<div className="flex items-center gap-1 animate-pulse">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
</div>
))}
</div>
);
}
return (
<div className="flex items-stretch gap-1">
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && (
<span className="text-t3 text-sm shrink-0 px-0.5"></span>
)}
</div>
))}
</div>
);
}
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
type FilterSource = 'all' | DataSource;
type FilterReceive = 'all' | ReceiveStatus;
type FilterPeriod = '6h' | '12h' | '24h';
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
function filterLogs(
rows: DataLogRow[],
source: FilterSource,
receive: FilterReceive,
period: FilterPeriod,
): DataLogRow[] {
const cutoff = new Date('2026-04-11T06:40:00');
const hours = PERIOD_HOURS[period];
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
return rows.filter((r) => {
if (source !== 'all' && r.source !== source) return false;
if (receive !== 'all' && r.receiveStatus !== receive) return false;
const ts = new Date(r.timestamp.replace(' ', 'T'));
if (ts < from) return false;
return true;
});
}
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{row.timestamp}
</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
>
{row.receiveStatus}
</span>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
>
{row.processStatus}
</span>
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
if (loading && alerts.length === 0) {
return (
<div className="flex flex-col gap-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-bg-elevated rounded" />
))}
</div>
);
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
}
return (
<div className="flex flex-col gap-1.5">
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
</div>
))}
</div>
);
}
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
export default function RndRescuePanel() {
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [logs, setLogs] = useState<DataLogRow[]>([]);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// 필터
const [filterSource, setFilterSource] = useState<FilterSource>('all');
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const data = await fetchRescueData();
setPipeline(data.pipeline);
setLogs(data.logs);
setAlerts(data.alerts);
setLastUpdate(new Date());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let isMounted = true;
void Promise.resolve().then(() => {
if (isMounted) void fetchData();
});
return () => {
isMounted = false;
};
}, [fetchData]);
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
const totalFailed = logs.filter(
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
).length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
)}
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<span>
:{' '}
<span className="text-emerald-400 font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
:{' '}
<span className="text-cyan-400 font-medium">5 / 6</span>
</span>
</div>
</div>
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
{/* 데이터소스 필터 */}
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
<option value="기상청"></option>
<option value="긴급구난시스템"></option>
</select>
{/* 수신상태 필터 */}
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
<option value="수신대기"></option>
<option value="수신실패"></option>
<option value="시간초과"></option>
</select>
{/* 기간 필터 */}
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
</section>
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
</h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>
</div>
);
}

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

파일 보기

@ -15,6 +15,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
children: [
{ id: 'menus', label: '메뉴관리' },
{ id: 'settings', label: '시스템설정' },
{ id: 'system-arch', label: '시스템구조' },
],
},
{
@ -91,6 +92,17 @@ export const ADMIN_MENU: AdminMenuItem[] = [
{ id: 'monitor-vessel', label: '선박위치정보' },
],
},
{
id: 'rnd',
label: 'R&D과제',
children: [
{ id: 'rnd-poseidon', label: '유출유확산예측(포세이돈)' },
{ id: 'rnd-kosps', label: '유출유확산예측(KOSPS)' },
{ id: 'rnd-hns-atmos', label: 'HNS대기확산(충북대)' },
{ id: 'rnd-rescue', label: '긴급구난과제' },
],
},
{ id: 'deidentify', label: '비식별화조치' },
],
},
];

파일 보기

@ -0,0 +1,2 @@
// 이 파일은 사용되지 않습니다. 이미지 보기 기능은 MediaModal에 통합되었습니다.
export {};

파일 보기

@ -15,12 +15,14 @@ export interface Incident {
prediction?: string;
vesselName?: string;
mediaCount?: number;
hasImgAnalysis?: boolean;
}
interface IncidentsLeftPanelProps {
incidents: Incident[];
selectedIncidentId: string | null;
onIncidentSelect: (id: string | null) => void;
onFilteredChange?: (filtered: Incident[]) => void;
}
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const;
@ -75,6 +77,7 @@ export function IncidentsLeftPanel({
incidents,
selectedIncidentId,
onIncidentSelect,
onFilteredChange,
}: IncidentsLeftPanelProps) {
const today = formatDate(new Date());
const todayLabel = today.replace(/-/g, '-');
@ -157,6 +160,10 @@ export function IncidentsLeftPanel({
});
}, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]);
useEffect(() => {
onFilteredChange?.(filteredIncidents);
}, [filteredIncidents, onFilteredChange]);
const regionCounts = useMemo(() => {
const dateFiltered = incidents.filter((i) => {
const matchesSearch =
@ -551,6 +558,27 @@ export function IncidentsLeftPanel({
<span className="text-caption">{inc.mediaCount}</span>
</button>
)}
{inc.hasImgAnalysis && (
<button
onClick={(e) => {
e.stopPropagation();
setMediaModalIncident(inc);
}}
title="현장 이미지 보기"
className="cursor-pointer text-label-2"
style={{
padding: '3px 7px',
borderRadius: '4px',
lineHeight: 1,
border: '1px solid rgba(59,130,246,0.25)',
background: 'rgba(59,130,246,0.08)',
color: '#60a5fa',
transition: '0.15s',
}}
>
📷
</button>
)}
</div>
</div>
</div>

파일 보기

@ -129,6 +129,7 @@ interface HoverInfo {
*/
export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([]);
const [filteredIncidents, setFilteredIncidents] = useState<IncidentCompat[]>([]);
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null);
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null);
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null);
@ -256,7 +257,7 @@ export function IncidentsView() {
() =>
new ScatterplotLayer({
id: 'incidents',
data: incidents,
data: filteredIncidents,
getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat],
getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12),
getFillColor: (d: IncidentCompat) => getMarkerColor(d.status),
@ -297,7 +298,7 @@ export function IncidentsView() {
getLineWidth: [selectedIncidentId],
},
}),
[incidents, selectedIncidentId],
[filteredIncidents, selectedIncidentId],
);
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
@ -585,6 +586,7 @@ export function IncidentsView() {
incidents={incidents}
selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId}
onFilteredChange={setFilteredIncidents}
/>
</div>
@ -740,30 +742,15 @@ export function IncidentsView() {
latitude={incidentPopup.latitude}
anchor="bottom"
onClose={() => setIncidentPopup(null)}
closeButton={true}
closeButton={false}
closeOnClick={false}
className="incident-popup"
maxWidth="none"
>
<div className="text-center min-w-[180px] text-caption">
<div className="font-semibold" style={{ marginBottom: 6, color: '#1a1d21' }}>
{incidentPopup.incident.name}
</div>
<div className="text-label-2 leading-[1.6]" style={{ color: '#4b5563' }}>
<div>: {getStatusLabel(incidentPopup.incident.status)}</div>
<div>
: {incidentPopup.incident.date} {incidentPopup.incident.time}
</div>
<div>: {incidentPopup.incident.office}</div>
{incidentPopup.incident.causeType && (
<div>: {incidentPopup.incident.causeType}</div>
)}
{incidentPopup.incident.prediction && (
<div className="font-semibold" style={{ color: 'var(--color-info)' }}>
{incidentPopup.incident.prediction}
</div>
)}
</div>
</div>
<IncidentPopupContent
incident={incidentPopup.incident}
onClose={() => setIncidentPopup(null)}
/>
</Popup>
)}
</MapLibre>
@ -1522,6 +1509,162 @@ function PopupRow({
);
}
/*
IncidentPopupContent
*/
function IncidentPopupContent({
incident: inc,
onClose,
}: {
incident: IncidentCompat;
onClose: () => void;
}) {
const dotColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
const stBg: Record<string, string> = {
active: 'rgba(239,68,68,0.15)',
investigating: 'rgba(249,115,22,0.15)',
closed: 'rgba(100,116,139,0.15)',
};
const stColor: Record<string, string> = {
active: 'var(--color-danger)',
investigating: 'var(--color-warning)',
closed: 'var(--fg-disabled)',
};
return (
<div
style={{
width: 260,
background: 'var(--bg-elevated)',
border: '1px solid var(--stroke-default)',
borderRadius: 10,
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
>
{/* Header */}
<div
className="flex items-center gap-2 border-b border-stroke"
style={{ padding: '10px 14px' }}
>
<span
className="shrink-0"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: dotColor[inc.status],
boxShadow: inc.status !== 'closed' ? `0 0 6px ${dotColor[inc.status]}` : 'none',
}}
/>
<div className="flex-1 min-w-0 text-label-1 font-bold text-fg whitespace-nowrap overflow-hidden text-ellipsis">
{inc.name}
</div>
<span
onClick={onClose}
className="cursor-pointer text-fg-disabled hover:text-fg flex items-center justify-center"
style={{
width: 22,
height: 22,
borderRadius: 'var(--radius-sm)',
fontSize: 14,
lineHeight: 1,
transition: '0.15s',
}}
>
</span>
</div>
{/* Tags */}
<div
className="flex flex-wrap gap-1.5 border-b border-stroke"
style={{ padding: '8px 14px' }}
>
<span
className="text-caption font-semibold rounded-sm"
style={{
padding: '2px 8px',
background: stBg[inc.status],
border: `1px solid ${stColor[inc.status]}`,
color: stColor[inc.status],
}}
>
{getStatusLabel(inc.status)}
</span>
{inc.causeType && (
<span
className="text-caption font-medium text-fg-sub rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(100,116,139,0.08)',
border: '1px solid rgba(100,116,139,0.2)',
}}
>
{inc.causeType}
</span>
)}
{inc.oilType && (
<span
className="text-caption font-medium text-color-warning rounded-sm"
style={{
padding: '2px 8px',
background: 'rgba(249,115,22,0.08)',
border: '1px solid rgba(249,115,22,0.2)',
}}
>
{inc.oilType}
</span>
)}
</div>
{/* Info rows */}
<div style={{ padding: '4px 0' }}>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold font-mono">
{inc.date} {inc.time}
</span>
</div>
<div
className="flex justify-between text-caption"
style={{ padding: '5px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}
>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.office}</span>
</div>
<div className="flex justify-between text-caption" style={{ padding: '5px 14px' }}>
<span className="text-fg-disabled"></span>
<span className="text-fg-sub font-semibold">{inc.region}</span>
</div>
</div>
{/* Prediction badge */}
{inc.prediction && (
<div className="border-t border-stroke" style={{ padding: '8px 14px' }}>
<span
className="text-caption font-semibold text-color-accent rounded-sm"
style={{
padding: '3px 10px',
background: 'rgba(6,182,212,0.1)',
border: '1px solid rgba(6,182,212,0.25)',
}}
>
{inc.prediction}
</span>
</div>
)}
</div>
);
}
/*
VesselDetailModal
*/

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import type { Incident } from './IncidentsLeftPanel';
import { fetchIncidentMedia } from '../services/incidentsApi';
import type { MediaInfo } from '../services/incidentsApi';
import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi';
import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi';
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv';
@ -35,9 +35,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
const [activeTab, setActiveTab] = useState<MediaTab>('all');
const [selectedCam, setSelectedCam] = useState(0);
const [media, setMedia] = useState<MediaInfo | null>(null);
const [aerialImages, setAerialImages] = useState<AerialMediaItem[]>([]);
const [selectedImageIdx, setSelectedImageIdx] = useState(0);
useEffect(() => {
fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages);
}, [incident.id]);
// Timeline dots (UI constant)
@ -75,7 +78,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
);
}
const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt;
const total = (media.photoCnt ?? 0) + (media.videoCnt ?? 0) + (media.satCnt ?? 0) + (media.cctvCnt ?? 0) + aerialImages.length;
const showPhoto = activeTab === 'all' || activeTab === 'photo';
const showVideo = activeTab === 'all' || activeTab === 'video';
@ -233,61 +236,171 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div className="flex items-center gap-[6px]">
<span className="text-label-1">📷</span>
<span className="text-label-1 font-bold text-fg">
{str(media.photoMeta, 'title', '현장 사진')}
{aerialImages.length > 0 ? `${aerialImages.length}` : str(media.photoMeta, 'title', '현장 사진')}
</span>
</div>
<div className="flex gap-[4px]">
<NavBtn label="◀" /> <NavBtn label="▶" /> <NavBtn label="↗" />
{aerialImages.length > 1 && (
<>
<NavBtn label="◀" onClick={() => setSelectedImageIdx((p) => Math.max(0, p - 1))} />
<NavBtn label="▶" onClick={() => setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} />
</>
)}
<NavBtn label="↗" />
</div>
</div>
{/* Photo content */}
<div className="flex-1 flex items-center justify-center flex-col gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
📷
</div>
<div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
<div className="flex-1 flex items-center justify-center overflow-hidden relative">
{aerialImages.length > 0 ? (
<>
<img
src={getMediaImageUrl(aerialImages[selectedImageIdx].aerialMediaSn)}
alt={aerialImages[selectedImageIdx].orgnlNm ?? '현장 사진'}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
<div className="text-label-1 text-fg-disabled"> </div>
</div>
{aerialImages.length > 1 && (
<>
<button
onClick={() => setSelectedImageIdx((prev) => Math.max(0, prev - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28, height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === 0 ? 0.3 : 1,
}}
disabled={selectedImageIdx === 0}
>
</button>
<button
onClick={() => setSelectedImageIdx((prev) => Math.min(aerialImages.length - 1, prev + 1))}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center text-fg-disabled cursor-pointer rounded"
style={{
width: 28, height: 28,
background: 'rgba(0,0,0,0.5)',
border: '1px solid var(--stroke-default)',
opacity: selectedImageIdx === aerialImages.length - 1 ? 0.3 : 1,
}}
disabled={selectedImageIdx === aerialImages.length - 1}
>
</button>
</>
)}
<div
className="absolute bottom-2 left-1/2 -translate-x-1/2 text-caption font-mono text-fg-disabled"
style={{
padding: '2px 8px',
background: 'rgba(0,0,0,0.6)',
borderRadius: 4,
}}
>
{selectedImageIdx + 1} / {aerialImages.length}
</div>
</>
) : (
<div className="flex flex-col items-center gap-2">
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>📷</div>
<div className="text-label-1 text-fg-sub font-semibold">
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div>
<div className="text-caption text-fg-disabled font-mono">
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div>
</div>
)}
</div>
{/* Thumbnails */}
<div
className="shrink-0"
style={{ padding: '8px 12px', borderTop: '1px solid var(--stroke-light)' }}
>
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map(
(_, i) => (
<div
key={i}
className="flex items-center justify-center text-title-3 cursor-pointer"
style={{
width: 40,
height: 36,
borderRadius: 4,
color: 'var(--stroke-default)',
background: i === 0 ? 'rgba(168,85,247,0.15)' : 'var(--bg-elevated)',
border:
i === 0
? '2px solid rgba(168,85,247,0.5)'
{aerialImages.length > 0 ? (
<>
<div className="flex gap-1.5 overflow-x-auto" style={{ marginBottom: 6 }}>
{aerialImages.map((img, i) => (
<div
key={img.aerialMediaSn}
className="shrink-0 cursor-pointer overflow-hidden"
style={{
width: 48,
height: 40,
borderRadius: 4,
background: i === selectedImageIdx ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
border: i === selectedImageIdx
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}}
>
📷
</div>
),
)}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
}}
onClick={() => setSelectedImageIdx(i)}
>
<img
src={getMediaImageUrl(img.aerialMediaSn)}
alt={img.orgnlNm ?? `사진 ${i + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = 'none';
}}
/>
</div>
))}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {aerialImages.length}
{aerialImages[selectedImageIdx]?.takngDtm
? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}`
: ''}
</span>
<span className="text-caption text-fg-disabled font-mono">
{aerialImages[selectedImageIdx]?.orgnlNm ?? ''}
</span>
</div>
</>
) : (
<>
<div className="flex gap-1.5" style={{ marginBottom: 6 }}>
{Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map(
(_, i) => (
<div
key={i}
className="flex items-center justify-center text-title-3 cursor-pointer"
style={{
width: 40,
height: 36,
borderRadius: 4,
color: 'var(--stroke-default)',
background: i === 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg-elevated)',
border:
i === 0
? '2px solid rgba(6,182,212,0.5)'
: '1px solid var(--stroke-default)',
}}
>
📷
</div>
),
)}
</div>
<div className="flex justify-between items-center">
<span className="text-caption text-fg-disabled">
📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span>
<span className="text-caption text-color-tertiary cursor-pointer">
🔗 R&D
</span>
</div>
</>
)}
</div>
</div>
)}
@ -560,16 +673,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
>
<div className="flex gap-4 text-caption font-mono text-fg-disabled">
<span>
📷 <b className="text-fg">{media.photoCnt}</b>
📷 <b className="text-fg">{aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)}</b>
</span>
<span>
🎬 <b className="text-fg">{media.videoCnt}</b>
🎬 <b className="text-fg">{media.videoCnt ?? 0}</b>
</span>
<span>
🛰 <b className="text-fg">{media.satCnt}</b>
🛰 <b className="text-fg">{media.satCnt ?? 0}</b>
</span>
<span>
📹 CCTV <b className="text-fg">{media.cctvCnt}</b>
📹 CCTV <b className="text-fg">{media.cctvCnt ?? 0}</b>
</span>
<span>
📎 <b className="text-color-tertiary">{total}</b>
@ -604,9 +717,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
);
}
function NavBtn({ label }: { label: string }) {
function NavBtn({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<button
onClick={onClick}
className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated"
style={{
width: 22,

파일 보기

@ -24,7 +24,9 @@ export interface IncidentListItem {
spilQty: number | null;
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}
export interface PredExecItem {
@ -89,6 +91,7 @@ export interface IncidentCompat {
prediction?: string;
vesselName?: string;
mediaCount?: number;
hasImgAnalysis?: boolean;
}
function toCompat(item: IncidentListItem): IncidentCompat {
@ -109,8 +112,9 @@ function toCompat(item: IncidentListItem): IncidentCompat {
location: { lat: item.lat, lon: item.lng },
causeType: item.acdntTpCd,
oilType: item.oilTpCd ?? undefined,
prediction: item.fcstHr ? '예측완료' : undefined,
prediction: item.hasPredCompleted ? '예측완료' : undefined,
mediaCount: item.mediaCnt,
hasImgAnalysis: item.hasImgAnalysis || undefined,
};
}
@ -201,3 +205,40 @@ export async function fetchNearbyOrgs(
});
return data;
}
// ============================================================
// 사고 관련 이미지 (AERIAL_MEDIA)
// ============================================================
export interface AerialMediaItem {
aerialMediaSn: number;
acdntSn: number | null;
fileNm: string;
orgnlNm: string | null;
filePath: string | null;
lon: number | null;
lat: number | null;
locDc: string | null;
equipTpCd: string | null;
equipNm: string | null;
mediaTpCd: string | null;
takngDtm: string | null;
fileSz: string | null;
resolution: string | null;
regDtm: string;
}
export async function fetchIncidentAerialMedia(acdntSn: number): Promise<AerialMediaItem[]> {
try {
const { data } = await api.get<AerialMediaItem[]>('/aerial/media', {
params: { acdntSn },
});
return data;
} catch {
return [];
}
}
export function getMediaImageUrl(aerialMediaSn: number): string {
return `/api/aerial/media/${aerialMediaSn}/download`;
}

파일 보기

@ -58,6 +58,12 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
</span>
);
case 'failed':
return (
<span className="px-2 py-1 text-label-2 font-medium rounded-md bg-[rgba(239,68,68,0.15)] text-color-danger">
</span>
);
default:
return null;
}
@ -246,7 +252,11 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
</td>
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.oilType}</td>
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-medium">
{analysis.volume != null ? analysis.volume.toFixed(2) : '—'}
{analysis.volume != null
? analysis.volume >= 0.01
? analysis.volume.toFixed(2)
: analysis.volume.toExponential(2)
: '—'}
</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
<td className="px-4 py-3 text-center">

파일 보기

@ -190,7 +190,7 @@ export function OilSpillView() {
new Set(['OpenDrift']),
);
const [visibleModels, setVisibleModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']));
const [predictionTime, setPredictionTime] = useState(48);
const [predictionTime, setPredictionTime] = useState(6);
const [accidentTime, setAccidentTime] = useState<string>('');
const [spillType, setSpillType] = useState('연속');
const [oilType, setOilType] = useState('벙커C유');
@ -588,7 +588,7 @@ export function OilSpillView() {
};
setOilType(oilTypeMap[analysis.oilType] || '벙커C유');
setSpillAmount(analysis.volume ?? 100);
setPredictionTime(parseInt(analysis.duration) || 48);
setPredictionTime(parseInt(analysis.duration) || 6);
// 모델 상태에 따라 선택 모델 설정
const models = new Set<PredictionModel>();
if (analysis.kospsStatus !== 'pending') models.add('KOSPS');
@ -663,7 +663,7 @@ export function OilSpillView() {
const demoTrajectory = generateDemoTrajectory(
coord ?? { lat: 37.39, lon: 126.64 },
demoModels,
parseInt(analysis.duration) || 48,
parseInt(analysis.duration) || 6,
);
setOilTrajectory(demoTrajectory);
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings));
@ -756,7 +756,7 @@ export function OilSpillView() {
setFlyToCoord({ lat: result.lat, lon: result.lon });
setAccidentTime(toLocalDateTimeStr(result.occurredAt));
setOilType(result.oilType);
setSpillAmount(parseFloat(result.volume.toFixed(4)));
setSpillAmount(parseFloat(result.volume.toFixed(20)));
setSpillUnit('kL');
setSelectedAnalysis({
acdntSn: result.acdntSn,
@ -764,7 +764,7 @@ export function OilSpillView() {
occurredAt: result.occurredAt,
analysisDate: '',
requestor: '',
duration: '48',
duration: '6',
oilType: result.oilType,
volume: result.volume,
location: '',

파일 보기

@ -92,7 +92,7 @@ const PredictionInputSection = ({
setIsAnalyzing(true);
setAnalyzeError(null);
try {
const result = await analyzeImage(uploadedFile);
const result = await analyzeImage(uploadedFile, incidentName);
setAnalyzeResult(result);
onImageAnalysisResult?.(result);
} catch (err: unknown) {
@ -149,23 +149,19 @@ const PredictionInputSection = ({
</label>
</div>
{/* Direct Input Mode */}
{inputMode === 'direct' && (
<>
<input
className="prd-i"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
style={
validationErrors?.has('incidentName')
? { borderColor: 'var(--color-danger)' }
: undefined
}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
</>
)}
{/* 사고명 입력 (직접입력 / 이미지업로드 공통) */}
<input
className="prd-i"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
style={
validationErrors?.has('incidentName')
? { borderColor: 'var(--color-danger)' }
: undefined
}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
{/* Image Upload Mode */}
{inputMode === 'upload' && (
@ -353,10 +349,10 @@ const PredictionInputSection = ({
className="prd-i"
placeholder="유출량"
type="number"
min="1"
step="1"
min="0"
step="any"
value={spillAmount}
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
onChange={(e) => onSpillAmountChange(parseFloat(e.target.value) || 0)}
/>
<ComboBox
className="prd-i"

파일 보기

@ -329,7 +329,11 @@ export function RightPanel({
</Section>
{/* 오염 종합 상황 */}
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
<Section
title="오염 종합 상황"
badge={getPollutionSeverity(spill?.volume)?.label}
badgeColor={getPollutionSeverity(spill?.volume)?.color}
>
<div className="grid grid-cols-2 gap-0.5 text-label-2">
<StatBox
label="유출량"
@ -367,7 +371,11 @@ export function RightPanel({
</Section>
{/* 확산 예측 요약 */}
<Section title={`확산 예측 요약 (+${predictionTime ?? 18}h)`} badge="위험" badgeColor="red">
<Section
title={`확산 예측 요약 (+${predictionTime ?? 18}h)`}
badge={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.label}
badgeColor={getSpreadSeverity(spreadSummary?.distance, spreadSummary?.speed)?.color}
>
<div className="grid grid-cols-2 gap-0.5 text-label-2">
<PredictionCard
value={spreadSummary?.area != null ? `${spreadSummary.area.toFixed(1)} km²` : '—'}
@ -598,7 +606,55 @@ export function RightPanel({
);
}
// 위험도 등급 (방제대책본부 운영 규칙 유출량 기준 + 국가 위기경보 4단계)
type SeverityColor = 'red' | 'orange' | 'yellow' | 'green';
interface SeverityLevel {
label: string;
color: SeverityColor;
}
const SEVERITY_LEVELS: SeverityLevel[] = [
{ label: '심각', color: 'red' },
{ label: '경계', color: 'orange' },
{ label: '주의', color: 'yellow' },
{ label: '관심', color: 'green' },
];
/** 오염 종합 상황 — 유출량(kl) 기준 */
function getPollutionSeverity(volumeKl: number | null | undefined): SeverityLevel | null {
if (volumeKl == null) return null;
if (volumeKl >= 500) return SEVERITY_LEVELS[0]; // 심각 (중앙방제대책본부)
if (volumeKl >= 50) return SEVERITY_LEVELS[1]; // 경계 (광역방제대책본부)
if (volumeKl >= 10) return SEVERITY_LEVELS[2]; // 주의 (지역방제대책본부)
return SEVERITY_LEVELS[3]; // 관심
}
/** 확산 예측 요약 — 확산거리(km) + 속도(m/s) 중 높은 등급 */
function getSpreadSeverity(
distanceKm: number | null | undefined,
speedMs: number | null | undefined,
): SeverityLevel | null {
if (distanceKm == null && speedMs == null) return null;
const distLevel =
distanceKm == null ? 3 : distanceKm >= 15 ? 0 : distanceKm >= 5 ? 1 : distanceKm >= 1 ? 2 : 3;
const speedLevel =
speedMs == null ? 3 : speedMs >= 0.3 ? 0 : speedMs >= 0.15 ? 1 : speedMs >= 0.05 ? 2 : 3;
return SEVERITY_LEVELS[Math.min(distLevel, speedLevel)];
}
// Helper Components
const BADGE_STYLES: Record<string, string> = {
red: 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]',
orange:
'bg-[rgba(249,115,22,0.08)] text-color-warning border border-[rgba(249,115,22,0.25)]',
yellow:
'bg-[rgba(234,179,8,0.08)] text-color-caution border border-[rgba(234,179,8,0.25)]',
green:
'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]',
};
function Section({
title,
badge,
@ -607,7 +663,7 @@ function Section({
}: {
title: string;
badge?: string;
badgeColor?: 'red' | 'green';
badgeColor?: 'red' | 'orange' | 'yellow' | 'green';
children: React.ReactNode;
}) {
return (
@ -617,9 +673,7 @@ function Section({
{badge && (
<span
className={`text-label-2 font-medium px-2 py-0.5 rounded-full ${
badgeColor === 'red'
? 'bg-[rgba(239,68,68,0.08)] text-color-danger border border-[rgba(239,68,68,0.25)]'
: 'bg-[rgba(34,197,94,0.08)] text-color-success border border-[rgba(34,197,94,0.25)]'
BADGE_STYLES[badgeColor ?? 'green']
}`}
>
{badge}

파일 보기

@ -311,9 +311,10 @@ export interface ImageAnalyzeResult {
occurredAt: string;
}
export const analyzeImage = async (file: File): Promise<ImageAnalyzeResult> => {
export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageAnalyzeResult> => {
const formData = new FormData();
formData.append('image', file);
if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim());
const response = await api.post<ImageAnalyzeResult>('/prediction/image-analyze', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 330_000,

파일 보기

@ -1,4 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
@ -103,6 +106,295 @@ interface ChartDataItem {
severity: Severity;
}
/* ─── 시나리오 관리 요건 ─── */
const SCENARIO_MGMT_GUIDELINES = [
'긴급구난 R&D 분석 결과는 시간 단계별 시나리오의 형태로 관리되어야 함',
'각 시나리오는 사고 발생 시점부터 구난 진행 단계별 상태 변화를 포함하여야 함',
'시나리오별 분석 결과는 사고 단위로 기존 사고 정보와 연계되어 관리되어야 함',
'동일 사고에 대해 복수 시나리오(시간대, 조건별)가 존재할 경우, 상호 비교·검토가 되어야 함',
'시나리오별 분석결과는 긴급구난 대응 판단을 지원할 수 있도록 요약 정보 형태로 제공되어야 함',
'시나리오 관리 기능은 기존 통합지원시스템의 흐름과 연계되어 실질적인 구난 대응 업무에 활용 가능하도록 반영되어야 함',
'긴급구난 시나리오 관리 기능 구현 시 1차 구축 완료된 GIS기능을 활용하여 구축하여 재개발하거나 중복구현하지 않도록 함',
];
/* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */
const MOCK_SCENARIOS: RescueScenarioItem[] = [
{
scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h',
scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL',
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0,
description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '위험 (GM 0.8m < IMO 1.0m)', color: 'var(--red)' },
{ label: '유출 위험', value: '활발 유출중 (100 L/min)', color: 'var(--red)' },
{ label: '선체 강도', value: 'BM 92% (경계)', color: 'var(--orange)' },
{ label: '승선인원', value: '15/20 확인, 5명 수색중', color: 'var(--red)' },
],
actions: [
{ time: '10:30', text: '충돌 발생, VHF Ch.16 조난 통보', color: 'var(--red)' },
{ time: '10:32', text: 'EPIRB 자동 발신 확인', color: 'var(--red)' },
{ time: '10:35', text: '해경 3009함 출동 지시', color: 'var(--orange)' },
{ time: '10:42', text: '인근 선박 구조 활동 개시', color: 'var(--cyan)' },
],
sortOrd: 1,
},
{
scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m',
scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL',
gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0,
description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '악화 (GM 0.7m, GZ 커브 감소)', color: 'var(--red)' },
{ label: '유출 위험', value: '증가 (120 L/min)', color: 'var(--red)' },
{ label: '선체 강도', value: 'BM 90% — 종강도 모니터링 개시', color: 'var(--orange)' },
{ label: '승선인원', value: '15명 퇴선, 5명 수색중', color: 'var(--red)' },
],
actions: [
{ time: '10:50', text: '잠수사 투입, 수중 손상 조사 개시', color: 'var(--cyan)' },
{ time: '10:55', text: '파공 규모 확인: 1.2m×0.8m', color: 'var(--red)' },
{ time: '11:00', text: '손상복원성 재계산 — IMO 기준 위험', color: 'var(--red)' },
{ time: '11:00', text: '유출유 방제선 배치 요청', color: 'var(--orange)' },
],
sortOrd: 2,
},
{
scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h',
scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL',
gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0,
description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#2 Port Tank', status: 'FLOODING', color: 'var(--red)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '한계 접근 (GM 0.65m)', color: 'var(--red)' },
{ label: '유출 위험', value: '파공 확대 우려 (135 L/min)', color: 'var(--red)' },
{ label: '선체 강도', value: 'BM 89% — Hogging 증가', color: 'var(--orange)' },
{ label: '인명구조', value: '실종 5명 수색중, 표류 1.2nm', color: 'var(--red)' },
],
actions: [
{ time: '11:10', text: '해경 3009함 현장 도착, SAR 구역 설정', color: 'var(--cyan)' },
{ time: '11:15', text: 'Leeway 표류 예측 모델 적용', color: 'var(--cyan)' },
{ time: '11:20', text: '회전익 항공기 수색 개시', color: 'var(--cyan)' },
{ time: '11:30', text: '#2 Port Tank 2차 침수 징후', color: 'var(--red)' },
],
sortOrd: 3,
},
{
scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h',
scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL',
gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0,
description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '위기 (GM 0.5m, FSE 보정)', color: 'var(--red)' },
{ label: '유출 위험', value: '최대치 접근 (160 L/min)', color: 'var(--red)' },
{ label: '선체 강도', value: 'BM 86% — Sagging 경고', color: 'var(--red)' },
{ label: '승선인원', value: '실종 3명 발견, 2명 수색', color: 'var(--orange)' },
],
actions: [
{ time: '12:00', text: '#2 Port Tank 격벽 관통 침수', color: 'var(--red)' },
{ time: '12:10', text: '자유표면효과(FSE) 보정 재계산', color: 'var(--red)' },
{ time: '12:15', text: '긴급 Counter-Flooding 검토', color: 'var(--orange)' },
{ time: '12:30', text: '실종자 3명 추가 발견 구조', color: 'var(--green)' },
],
sortOrd: 4,
},
{
scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h',
scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH',
gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0,
description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
],
assessment: [
{ label: '복원력', value: '개선 중 (GM 0.55m, 경사 16°)', color: 'var(--orange)' },
{ label: '유출 위험', value: '감소 추세 (140 L/min)', color: 'var(--orange)' },
{ label: '선체 강도', value: 'BM 87% — Counter-Flooding 평가', color: 'var(--orange)' },
{ label: '구조 상황', value: '실종 2명 수색 지속', color: 'var(--orange)' },
],
actions: [
{ time: '12:45', text: 'Counter-Flooding — #3 Stbd 주입 개시', color: 'var(--orange)' },
{ time: '13:00', text: '평형수 280톤 주입, 경사 교정 진행', color: 'var(--cyan)' },
{ time: '13:15', text: '종강도 재계산 — 허용 범위 내', color: 'var(--cyan)' },
{ time: '13:30', text: '횡경사 16° 안정화 확인', color: 'var(--green)' },
],
sortOrd: 5,
},
{
scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h',
scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH',
gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0,
description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
],
assessment: [
{ label: '복원력', value: '개선 (GM 0.7m, 예인 가능)', color: 'var(--orange)' },
{ label: '유출 위험', value: '수중패치 효과 (80 L/min)', color: 'var(--orange)' },
{ label: '선체 강도', value: 'BM 90% — 안정 범위', color: 'var(--green)' },
{ label: '구조 상황', value: '전원 구조 완료', color: 'var(--green)' },
],
actions: [
{ time: '14:00', text: '수중패치 설치 작업 개시', color: 'var(--cyan)' },
{ time: '14:30', text: '수중패치 설치 완료', color: 'var(--green)' },
{ time: '15:00', text: '해상크레인 도착, 잔류유 이적 준비', color: 'var(--cyan)' },
{ time: '16:30', text: '잔류유 1차 이적 완료 (45kL)', color: 'var(--green)' },
],
sortOrd: 6,
},
{
scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h',
scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM',
gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0,
description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
],
assessment: [
{ label: '복원력', value: '안정 (GM 0.8m)', color: 'var(--orange)' },
{ label: '유출 위험', value: '방제 진행 (55 L/min, 회수 35%)', color: 'var(--orange)' },
{ label: '선체 강도', value: 'BM 91%', color: 'var(--green)' },
{ label: '방제 현황', value: '오일붐 2중, 유회수기 3대', color: 'var(--cyan)' },
],
actions: [
{ time: '17:00', text: '오일붐 1차 전개 (500m)', color: 'var(--cyan)' },
{ time: '17:30', text: '오일붐 2차 전개 (이중 방어선)', color: 'var(--cyan)' },
{ time: '17:45', text: '유회수기 3대 배치·가동', color: 'var(--cyan)' },
{ time: '18:30', text: 'GNOME 확산 예측 갱신', color: 'var(--orange)' },
],
sortOrd: 7,
},
{
scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h',
scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM',
gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0,
description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
],
assessment: [
{ label: '복원력', value: '안정 (GM 0.9m)', color: 'var(--orange)' },
{ label: '유출 위험', value: '억제 중 (30 L/min)', color: 'var(--green)' },
{ label: '선체 강도', value: 'BM 94%', color: 'var(--green)' },
{ label: '예인 상태', value: '목포항, ETA 14h, 3kn', color: 'var(--cyan)' },
],
actions: [
{ time: '18:00', text: '예인 접속, 예인삭 250m 전개', color: 'var(--cyan)' },
{ time: '18:30', text: '예인 개시 (목포항 방향)', color: 'var(--cyan)' },
{ time: '20:00', text: '야간 감시 체제 전환', color: 'var(--orange)' },
{ time: '22:30', text: '예인 진행률 30%, 선체 안정', color: 'var(--green)' },
],
sortOrd: 8,
},
{
scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h',
scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM',
gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0,
description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.',
compartments: [
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '양호 (GM 1.0m, IMO 충족)', color: 'var(--green)' },
{ label: '유출 위험', value: '미량 유출 (15 L/min)', color: 'var(--green)' },
{ label: '선체 강도', value: 'BM 96% 정상', color: 'var(--green)' },
{ label: '예인 상태', value: '진행률 65%, ETA 5.5h', color: 'var(--cyan)' },
],
actions: [
{ time: '00:00', text: '야간 예인 정상 진행', color: 'var(--green)' },
{ time: '02:00', text: '파랑 응답 분석 — 안전 확인', color: 'var(--green)' },
{ time: '03:00', text: '잔류유 유출률 15 L/min', color: 'var(--green)' },
{ time: '04:30', text: '목포항 VTS 통보, 입항 협의', color: 'var(--cyan)' },
],
sortOrd: 9,
},
{
scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h',
scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED',
gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0,
description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.',
compartments: [
{ name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
],
assessment: [
{ label: '복원력', value: '안전 (GM 1.2m)', color: 'var(--green)' },
{ label: '유출 위험', value: '차단 완료', color: 'var(--green)' },
{ label: '선체 강도', value: 'BM 98% 정상', color: 'var(--green)' },
{ label: '최종 상태', value: '접안 완료, 상황 종료', color: 'var(--green)' },
],
actions: [
{ time: '06:00', text: '목포항 접근, 도선사 대기', color: 'var(--cyan)' },
{ time: '08:00', text: '도선사 승선, 접안 개시', color: 'var(--cyan)' },
{ time: '09:30', text: '접안 완료, 잔류유 이적선 접현', color: 'var(--green)' },
{ time: '10:30', text: '잔류유 전량 이적, 상황 종료', color: 'var(--green)' },
],
sortOrd: 10,
},
];
const MOCK_OPS: RescueOpsItem[] = [
{
rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision',
vesselNm: 'M/V SEA GUARDIAN', commanderNm: null,
lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E',
depthM: 25.0, currentDc: '2.5kn NE',
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0,
oilRateLpm: 100.0, bmRatioPct: 92.0,
totalCrew: 20, survivors: 15, missing: 5,
hydroData: null, gmdssData: null,
sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z',
},
];
/*
RescueScenarioView
*/
@ -116,14 +408,15 @@ export function RescueScenarioView() {
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time');
const [detailView, setDetailView] = useState<DetailView>(0);
const [newScnModalOpen, setNewScnModalOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
const loadScenarios = useCallback(async (opsSn: number) => {
setLoading(true);
try {
const items = await fetchRescueScenarios(opsSn);
setApiScenarios(items);
} catch (err) {
console.error('[rescue] 시나리오 조회 실패:', err);
setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS);
} catch {
setApiScenarios(MOCK_SCENARIOS);
} finally {
setLoading(false);
}
@ -132,14 +425,17 @@ export function RescueScenarioView() {
const loadOps = useCallback(async () => {
try {
const items = await fetchRescueOps();
setOps(items);
if (items.length > 0) {
setOps(items);
loadScenarios(items[0].rescueOpsSn);
} else {
setOps(MOCK_OPS);
setApiScenarios(MOCK_SCENARIOS);
setLoading(false);
}
} catch (err) {
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
} catch {
setOps(MOCK_OPS);
setApiScenarios(MOCK_SCENARIOS);
setLoading(false);
}
}, [loadScenarios]);
@ -229,9 +525,35 @@ export function RescueScenarioView() {
>
+
</button>
<button
onClick={() => setGuideOpen((v) => !v)}
className="cursor-pointer whitespace-nowrap font-semibold text-label-2 px-[14px] py-1.5 rounded-sm"
style={{
border: '1px solid rgba(6,182,212,.15)',
background: guideOpen ? 'rgba(6,182,212,.12)' : 'transparent',
color: guideOpen ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
{guideOpen ? '▴ 관리 요건' : '▾ 관리 요건'}
</button>
</div>
</div>
{/* ── 시나리오 관리 요건 가이드라인 ── */}
{guideOpen && (
<div className="border-b border-stroke px-5 py-3 bg-bg-surface shrink-0">
<p className="text-label-1 font-bold mb-2"> </p>
<ul className="flex flex-col gap-1">
{SCENARIO_MGMT_GUIDELINES.map((g, i) => (
<li key={i} className="text-caption text-fg-sub leading-relaxed flex gap-1.5">
<span className="text-color-accent shrink-0">{i + 1}.</span>
<span>{g}</span>
</li>
))}
</ul>
</div>
)}
{/* ── Content: Left List + Right Detail ── */}
<div className="flex flex-1 overflow-hidden">
{/* ═══ LEFT: 시나리오 목록 ═══ */}
@ -376,7 +698,7 @@ export function RescueScenarioView() {
</div>
{/* View content */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
{/* ─── VIEW 0: 시나리오 상세 ─── */}
{detailView === 0 && selected && (
<div className="p-5">
@ -536,37 +858,14 @@ export function RescueScenarioView() {
{/* ─── VIEW 2: 지도 오버레이 ─── */}
{detailView === 2 && (
<div className="p-5">
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
<div className="text-title-4 font-bold mb-1.5">GIS </div>
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
.
</div>
<div className="flex gap-2 justify-center flex-wrap">
{scenarios.map((sc) => (
<div
key={sc.id}
className="px-3 py-1.5 rounded-md text-caption"
style={{
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
background: SEV_STYLE[sc.severity].bg,
}}
>
<span className="font-bold" style={{ color: SEV_STYLE[sc.severity].color }}>
{sc.id}
</span>
<span className="text-fg-sub ml-1.5">{sc.name}</span>
</div>
))}
</div>
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
<div className="text-label-2 text-fg-disabled">
</div>
</div>
</div>
</div>
<ScenarioMapOverlay
ops={ops}
selectedIncident={selectedIncident}
scenarios={scenarios}
selectedId={selectedId}
checked={checked}
onSelectScenario={setSelectedId}
/>
)}
</div>
</div>
@ -578,6 +877,310 @@ export function RescueScenarioView() {
);
}
/* ═══ 지도 오버레이 ═══ */
interface ScenarioMapOverlayProps {
ops: RescueOpsItem[];
selectedIncident: number;
scenarios: RescueScenario[];
selectedId: string;
checked: Set<string>;
onSelectScenario: (id: string) => void;
}
function ScenarioMapOverlay({
ops,
selectedIncident,
scenarios,
selectedId,
checked,
onSelectScenario,
}: ScenarioMapOverlayProps) {
const [popupId, setPopupId] = useState<string | null>(null);
const baseMapStyle = useBaseMapStyle();
const currentOp = ops[selectedIncident] ?? null;
const center = useMemo<[number, number]>(
() =>
currentOp?.lon != null && currentOp?.lat != null
? [currentOp.lon, currentOp.lat]
: [126.25, 37.467],
[currentOp],
);
const visibleScenarios = useMemo(
() => scenarios.filter((s) => checked.has(s.id)),
[scenarios, checked],
);
const selected = scenarios.find((s) => s.id === selectedId);
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* 시나리오 선택 바 */}
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface shrink-0 overflow-x-auto">
<span className="text-caption text-fg-disabled shrink-0">:</span>
{visibleScenarios.map((sc) => {
const sev = SEV_STYLE[sc.severity];
const isActive = selectedId === sc.id;
return (
<button
key={sc.id}
onClick={() => onSelectScenario(sc.id)}
className="cursor-pointer shrink-0 px-2 py-1 rounded text-caption font-semibold transition-all"
style={{
border: `1.5px solid ${isActive ? sev.color : sev.color + '40'}`,
background: isActive ? sev.bg : 'transparent',
color: isActive ? sev.color : 'var(--fg-disabled)',
}}
>
{sc.id} {sc.timeStep}
</button>
);
})}
</div>
{/* 지도 영역 */}
<div className="flex-1 relative">
<Map
initialViewState={{ longitude: center[0], latitude: center[1], zoom: 11 }}
mapStyle={baseMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{/* 사고 위치 마커 */}
{currentOp && currentOp.lon != null && currentOp.lat != null && (
<Marker longitude={currentOp.lon} latitude={currentOp.lat} anchor="center">
<div
className="flex items-center justify-center"
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(239,68,68,0.25)',
border: '2px solid var(--color-danger)',
}}
>
<div
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: 'var(--color-danger)',
}}
/>
</div>
</Marker>
)}
{/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */}
{visibleScenarios.map((sc, idx) => {
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
const radius = 0.015 + idx * 0.003;
const lng = center[0] + Math.cos(angle) * radius;
const lat = center[1] + Math.sin(angle) * radius * 0.8;
const sev = SEV_STYLE[sc.severity];
const isActive = selectedId === sc.id;
return (
<Marker
key={sc.id}
longitude={lng}
latitude={lat}
anchor="center"
onClick={(e) => {
e.originalEvent.stopPropagation();
onSelectScenario(sc.id);
setPopupId(popupId === sc.id ? null : sc.id);
}}
>
<div
className="cursor-pointer flex items-center justify-center transition-transform"
style={{
width: isActive ? 36 : 28,
height: isActive ? 36 : 28,
borderRadius: '50%',
background: sev.bg,
border: `2px solid ${sev.color}`,
transform: isActive ? 'scale(1.15)' : 'scale(1)',
boxShadow: isActive ? `0 0 12px ${sev.color}60` : 'none',
zIndex: isActive ? 10 : 1,
}}
>
<span
className="font-bold font-mono"
style={{ fontSize: isActive ? 11 : 9, color: sev.color }}
>
{sc.timeStep.replace('T+', '')}
</span>
</div>
</Marker>
);
})}
{/* 팝업 — 클릭한 시나리오 정보 표출 */}
{popupId &&
(() => {
const sc = visibleScenarios.find((s) => s.id === popupId);
if (!sc) return null;
const idx = visibleScenarios.indexOf(sc);
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
const radius = 0.015 + idx * 0.003;
const lng = center[0] + Math.cos(angle) * radius;
const lat = center[1] + Math.sin(angle) * radius * 0.8;
const sev = SEV_STYLE[sc.severity];
return (
<Popup
longitude={lng}
latitude={lat}
anchor="bottom"
closeOnClick={false}
onClose={() => setPopupId(null)}
maxWidth="320px"
className="rescue-map-popup"
>
<div style={{ padding: '8px 4px', minWidth: 260, background: 'var(--bg-card)', color: 'var(--fg)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<span style={{ fontWeight: 800, fontFamily: 'monospace', color: sev.color, fontSize: 13 }}>
{sc.id}
</span>
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
<span
style={{
marginLeft: 'auto',
padding: '1px 6px',
borderRadius: 8,
fontSize: 10,
fontWeight: 700,
background: sev.bg,
color: sev.color,
}}
>
{sev.label}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--fg-sub)', lineHeight: 1.5, marginBottom: 6 }}>
{sc.description}
</div>
{/* KPI */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
{[
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
{ label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) },
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
{ label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) },
].map((m) => (
<div
key={m.label}
style={{
textAlign: 'center',
padding: '3px 2px',
borderRadius: 3,
background: 'var(--bg-base)',
fontSize: 10,
}}
>
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div>
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>{m.value}</div>
</div>
))}
</div>
{/* 구획 상태 */}
{sc.compartments.length > 0 && (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-disabled)', marginBottom: 3 }}>
</div>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{sc.compartments.map((c) => (
<span
key={c.name}
style={{
fontSize: 9,
padding: '1px 5px',
borderRadius: 3,
border: `1px solid ${c.color}40`,
color: c.color,
}}
>
{c.name}: {c.status}
</span>
))}
</div>
</div>
)}
</div>
</Popup>
);
})()}
</Map>
{/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */}
{selected && (
<div
className="absolute bottom-3 left-3 z-10 rounded-lg border border-stroke overflow-hidden"
style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }}
>
<div className="px-3 py-2 border-b border-stroke flex items-center gap-2">
<span className="font-bold font-mono text-color-accent text-label-2">{selected.id}</span>
<span className="text-caption font-bold">{selected.timeStep}</span>
<span
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold"
style={{ background: SEV_STYLE[selected.severity].bg, color: SEV_STYLE[selected.severity].color }}
>
{SEV_STYLE[selected.severity].label}
</span>
</div>
<div className="px-3 py-2">
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-2">
{[
{ label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) },
{ label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) },
{ label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) },
{ label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) },
].map((m) => (
<div key={m.label} className="text-center p-1 bg-bg-base rounded">
<div className="text-fg-disabled" style={{ fontSize: 9 }}>{m.label}</div>
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div>
</div>
))}
</div>
<div className="text-caption text-fg-sub leading-relaxed" style={{ fontSize: 10 }}>
{selected.description.slice(0, 120)}
{selected.description.length > 120 ? '...' : ''}
</div>
</div>
</div>
)}
{/* 우측 상단 — 범례 */}
<div
className="absolute top-3 right-3 z-10 rounded-lg border border-stroke px-3 py-2"
style={{ background: 'rgba(15,23,42,0.88)', backdropFilter: 'blur(8px)' }}
>
<div className="text-caption font-bold text-fg-disabled mb-1.5"> </div>
{(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => (
<div key={sev} className="flex items-center gap-1.5 mb-0.5">
<span
className="inline-block rounded-full"
style={{ width: 8, height: 8, background: SEV_COLOR[sev] }}
/>
<span className="text-caption text-fg-sub">{SEV_STYLE[sev].label}</span>
</div>
))}
<div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
<span
className="inline-block rounded-full"
style={{ width: 8, height: 8, background: 'var(--color-danger)', border: '1px solid var(--color-danger)' }}
/>
<span className="text-caption text-fg-sub"> </span>
</div>
</div>
</div>
</div>
);
}
/* ═══ 신규 시나리오 생성 모달 ═══ */
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
const overlayRef = useRef<HTMLDivElement>(null);

파일 보기

@ -1,9 +1,12 @@
import { Fragment, useState, useEffect, useCallback } from 'react';
import { useSubMenu } from '@common/hooks/useSubMenu';
import { MapView } from '@common/components/map/MapView';
import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView';
import { fetchRescueOps } from '../services/rescueApi';
import type { RescueOpsItem } from '../services/rescueApi';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
/* ─── Types ─── */
type AccidentType =
@ -221,12 +224,145 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
function LeftPanel({
activeType,
onTypeChange,
incidents,
selectedAcdnt,
onSelectAcdnt,
}: {
activeType: AccidentType;
onTypeChange: (t: AccidentType) => void;
incidents: IncidentListItem[];
selectedAcdnt: IncidentListItem | null;
onSelectAcdnt: (item: IncidentListItem | null) => void;
}) {
const [acdntName, setAcdntName] = useState('');
const [acdntDate, setAcdntDate] = useState('');
const [acdntTime, setAcdntTime] = useState('');
const [acdntLat, setAcdntLat] = useState('');
const [acdntLon, setAcdntLon] = useState('');
const [showList, setShowList] = useState(false);
// 사고 선택 시 필드 자동 채움
const handlePickIncident = (item: IncidentListItem) => {
onSelectAcdnt(item);
setAcdntName(item.acdntNm);
const dt = new Date(item.occrnDtm);
setAcdntDate(
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
);
setAcdntTime(
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
);
setAcdntLat(String(item.lat));
setAcdntLon(String(item.lng));
setShowList(false);
};
return (
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
{/* ── 사고 기본정보 ── */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
</div>
{/* 사고명 직접 입력 */}
<input
type="text"
placeholder="사고명 직접 입력"
value={acdntName}
onChange={(e) => {
setAcdntName(e.target.value);
if (selectedAcdnt) onSelectAcdnt(null);
}}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
{/* 또는 사고 리스트에서 선택 */}
<div className="relative">
<button
onClick={() => setShowList(!showList)}
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
>
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
</span>
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
</button>
{showList && (
<div className="absolute left-0 right-0 top-full mt-0.5 z-30 bg-bg-card border border-stroke rounded shadow-lg max-h-[200px] overflow-y-auto scrollbar-thin">
{incidents.length === 0 && (
<div className="px-2 py-3 text-caption text-fg-disabled text-center font-korean">
</div>
)}
{incidents.map((item) => (
<button
key={item.acdntSn}
onClick={() => handlePickIncident(item)}
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
>
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
<div className="text-fg-disabled text-[10px]">
{item.acdntCd} · {item.regionNm}
</div>
</button>
))}
</div>
)}
</div>
{/* 사고 발생 일시 */}
<div className="text-[10px] text-fg-disabled font-korean mt-1"> </div>
<div className="flex gap-1">
<input
type="text"
placeholder="2026. 04. 11."
value={acdntDate}
onChange={(e) => setAcdntDate(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="오후 03:42"
value={acdntTime}
onChange={(e) => setAcdntTime(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
</div>
{/* 위도 / 경도 */}
<div className="flex gap-1 mt-0.5">
<input
type="text"
placeholder="위도"
value={acdntLat}
onChange={(e) => setAcdntLat(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<input
type="text"
placeholder="경도"
value={acdntLon}
onChange={(e) => setAcdntLon(e.target.value)}
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
/>
<button
className="shrink-0 px-1.5 py-1 text-[10px] font-bold rounded cursor-pointer"
style={{
background: 'rgba(239,68,68,0.15)',
color: 'var(--color-danger)',
border: '1px solid rgba(239,68,68,0.3)',
}}
>
</button>
</div>
<div className="text-[10px] text-fg-disabled font-korean text-center mb-1">
</div>
{/* 구분선 */}
<div className="border-t border-stroke my-1" />
{/* 사고유형 제목 */}
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
(INCIDENT TYPE)
@ -290,191 +426,6 @@ function LeftPanel({
}
/* ─── 중앙 지도 영역 ─── */
function CenterMap({ activeType }: { activeType: AccidentType }) {
const d = rscTypeData[activeType];
return (
<div className="flex-1 relative overflow-hidden bg-bg-base">
{/* 해양 배경 그라데이션 */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(ellipse at 30% 40%, rgba(6,90,130,.25) 0%, transparent 60%), radial-gradient(ellipse at 70% 60%, rgba(8,60,100,.2) 0%, transparent 50%), linear-gradient(180deg, #0a1628, #0d1f35 50%, #091520)',
}}
/>
{/* 격자 */}
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(rgba(30,60,100,.12) 1px, transparent 1px), linear-gradient(90deg, rgba(30,60,100,.12) 1px, transparent 1px)',
backgroundSize: '80px 80px',
}}
/>
{/* 해안선 힌트 */}
<div
className="absolute right-0 top-0 w-[55%] h-full"
style={{
background:
'linear-gradient(225deg, rgba(30,50,70,.55), rgba(20,35,50,.25) 35%, transparent 55%)',
clipPath:
'polygon(60% 0%, 65% 5%, 70% 12%, 72% 20%, 68% 30%, 65% 40%, 60% 50%, 55% 58%, 50% 65%, 45% 72%, 42% 80%, 48% 88%, 100% 100%, 100% 0%)',
}}
/>
{/* 사고 해역 정보 */}
<div className="absolute top-2.5 left-2.5 z-20 bg-[var(--dropdown-bg)] border border-stroke rounded-md px-3 py-2 backdrop-blur-sm font-mono text-caption text-fg-disabled">
<div className="text-label-2 font-bold text-fg font-korean mb-1"> </div>
<div className="grid gap-x-1.5 gap-y-px" style={{ gridTemplateColumns: '32px 1fr' }}>
<span></span>
<b className="text-fg">37°28'N, 126°15'E</b>
<span></span>
<b className="text-fg">45m</b>
<span></span>
<b className="text-fg">2.5 knots NE</b>
</div>
</div>
{/* 선박 모형 */}
<div className="absolute z-[15] top-[42%] left-[46%] -rotate-[15deg]">
<div
className="relative w-[72px] h-5"
style={{
background: 'linear-gradient(90deg, #4a3728, #6b4c33)',
borderRadius: '3px 10px 10px 3px',
border: '1px solid rgba(255,150,50,.4)',
boxShadow: '0 0 18px rgba(255,100,0,.2)',
}}
>
<div
className="absolute w-0.5 h-[7px] bg-fg-disabled rounded-[1px]"
style={{ top: '-3px', left: '60%' }}
/>
</div>
<div className="text-caption text-center mt-0.5 font-mono text-[rgba(255,200,150,0.5)]">
M/V SEA GUARDIAN
</div>
</div>
{/* 예측 구역 원 */}
<div
className="absolute z-[5] rounded-full"
style={{
top: '32%',
left: '32%',
width: '220px',
height: '220px',
background:
'radial-gradient(circle, rgba(6,182,212,.07), rgba(6,182,212,.02) 50%, transparent 70%)',
border: '1.5px dashed rgba(6,182,212,.2)',
}}
/>
{/* 구역 라벨 */}
<div className="absolute z-[6] text-caption font-korean font-semibold tracking-wider uppercase whitespace-pre-line text-[rgba(6,182,212,0.45)] top-1/2 left-[36%]">
{d.zone.replace('\\n', '\n')}
</div>
{/* SAR 자산 */}
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[10%] left-[42%]">
ETA 5 MIN
</div>
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[14%] left-[56%]">
ETA 15 MIN
</div>
<div className="absolute z-[12] text-title-3 opacity-60 top-[7%] left-[52%] -rotate-[30deg]">
🚁
</div>
<div className="absolute z-[12] text-caption font-mono text-[rgba(200,220,255,0.45)] top-[20%] left-[60%]">
6M
</div>
<div className="absolute z-[12] text-label-2 opacity-45 top-[28%] left-[54%]">🚢</div>
{/* 환경 민감 구역 */}
<div className="absolute z-10 px-3.5 py-2 rounded bg-[rgba(34,197,94,0.06)] border border-[rgba(34,197,94,0.2)] bottom-[6%] right-[6%]">
<div className="text-caption font-bold font-serif uppercase tracking-wider leading-snug text-[rgba(34,197,94,0.55)]">
ENVIRONMENTALLY SENSITIVE
<br />
AREA: AQUACULTURE FARM
</div>
</div>
{/* 지도 컨트롤 */}
<div className="absolute top-2.5 right-2.5 z-20 flex flex-col gap-0.5">
{['🗺', '🔍', '📐'].map((ico, i) => (
<button
key={i}
className="w-7 h-7 bg-[rgba(13,17,23,0.85)] border border-stroke rounded text-fg-disabled text-label-1 flex items-center justify-center cursor-pointer hover:text-fg"
>
{ico}
</button>
))}
</div>
{/* 스케일 바 */}
<div className="absolute bottom-2.5 left-2.5 z-20 bg-[rgba(13,17,23,0.8)] border border-stroke rounded px-2.5 py-1 text-caption text-fg-disabled font-mono">
<div
className="w-[70px] h-0.5 mb-0.5"
style={{ background: 'linear-gradient(90deg, #e4e8f1 50%, var(--stroke-default) 50%)' }}
/>
5 km · Zoom: 100%
</div>
{/* 사고 유형 표시 */}
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
<div className="text-caption text-fg-disabled font-korean"> </div>
<div className="text-label-2 font-bold font-korean text-color-accent">
{at.icon} {at.label} ({at.eng})
</div>
</div> */}
{/* 타임라인 시뮬레이션 컨트롤 */}
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap">TIMELINE</div>
<div className="flex items-center gap-1.5 text-caption text-fg-disabled font-mono">
<span>[-6h]</span>
<span className="font-bold text-fg">[NOW]</span>
<span>[+6H]</span>
<span>[+12H]</span>
<span>[+24H]</span>
</div>
<div className="relative w-24 h-1.5 bg-bg-surface-hover rounded-sm">
<div
className="absolute rounded-full border-2 border-bg-0 bg-color-accent"
style={{
left: '35%',
top: '-3px',
width: '12px',
height: '12px',
boxShadow: '0 0 8px rgba(6,182,212,.4)',
}}
/>
</div>
<div className="flex items-center gap-1.5">
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
</button>
<button
className="w-8 h-8 rounded-full text-color-accent text-title-4 flex items-center justify-center cursor-pointer hover:brightness-125"
style={{
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
}}
>
</button>
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
</button>
</div>
<div className="text-caption text-fg-disabled font-mono whitespace-nowrap">
<b className="text-color-accent">10:45</b> KST
</div>
</div>
</div>
);
}
/* ─── 오른쪽 분석 패널 ─── */
function RightPanel({
activeAnalysis,
@ -1572,6 +1523,44 @@ export function RescueView() {
const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState<AccidentType>('collision');
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
useEffect(() => {
fetchIncidentsRaw()
.then((items) => setIncidents(items))
.catch(() => setIncidents([]));
}, []);
// 지도 클릭 시 좌표 선택
const handleMapClick = useCallback((lon: number, lat: number) => {
setIncidentCoord({ lon, lat });
setIsSelectingLocation(false);
}, []);
// 사고 선택 시 사고유형 자동 매핑
const handleSelectAcdnt = useCallback(
(item: IncidentListItem | null) => {
setSelectedAcdnt(item);
if (item) {
const typeMap: Record<string, AccidentType> = {
collision: 'collision',
grounding: 'grounding',
turning: 'turning',
capsizing: 'capsizing',
sharpTurn: 'sharpTurn',
flooding: 'flooding',
sinking: 'sinking',
};
const mapped = typeMap[item.acdntTpCd];
if (mapped) setActiveType(mapped);
setIncidentCoord({ lon: item.lng, lat: item.lat });
}
},
[],
);
if (activeSubTab === 'list') {
return (
@ -1596,8 +1585,23 @@ export function RescueView() {
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
<div className="flex flex-1 overflow-hidden">
<LeftPanel activeType={activeType} onTypeChange={setActiveType} />
<CenterMap activeType={activeType} />
<LeftPanel
activeType={activeType}
onTypeChange={setActiveType}
incidents={incidents}
selectedAcdnt={selectedAcdnt}
onSelectAcdnt={handleSelectAcdnt}
/>
<div className="flex-1 relative overflow-hidden">
<MapView
incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={new Set()}
showOverlays={false}
/>
</div>
<RightPanel
activeAnalysis={activeAnalysis}
onAnalysisChange={setActiveAnalysis}