Compare commits
16 커밋
f47aeef3ce
...
fef7583eb5
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| fef7583eb5 | |||
| 8093727efe | |||
| af4ab9dd80 | |||
| 28931d9a5e | |||
| 4f5260ae12 | |||
| 9630b1daac | |||
| bf0de764c6 | |||
| 965b238b08 | |||
| 2640d882da | |||
|
|
387e2a2e40 | ||
|
|
1142e0cc46 | ||
| 972e6319cc | |||
| 0d53f850b2 | |||
| 7e0da5ea76 | |||
| 1ef0f5bce9 | |||
| d8d236c624 |
@ -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
|
||||
);
|
||||
|
||||
7
database/migration/031_spil_qty_precision.sql
Normal file
7
database/migration/031_spil_qty_precision.sql
Normal file
@ -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() {
|
||||
|
||||
1241
frontend/src/tabs/admin/components/DeidentifyPanel.tsx
Normal file
1241
frontend/src/tabs/admin/components/DeidentifyPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
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}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user