feat(incidents): 이미지 분석 연동 강화 및 사고 팝업 리뉴얼
- 사고별 이미지 분석 API 및 항공 미디어 조회 연동 - 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시 - 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경 - 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031) - OpenDrift 유종 매핑 수정 (원유, 등유)
This commit is contained in:
부모
972e6319cc
커밋
2640d882da
@ -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,
|
||||
|
||||
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);
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
@ -249,7 +250,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),
|
||||
@ -290,7 +291,7 @@ export function IncidentsView() {
|
||||
getLineWidth: [selectedIncidentId],
|
||||
},
|
||||
}),
|
||||
[incidents, selectedIncidentId],
|
||||
[filteredIncidents, selectedIncidentId],
|
||||
);
|
||||
|
||||
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
|
||||
@ -577,6 +578,7 @@ export function IncidentsView() {
|
||||
incidents={incidents}
|
||||
selectedIncidentId={selectedIncidentId}
|
||||
onIncidentSelect={setSelectedIncidentId}
|
||||
onFilteredChange={setFilteredIncidents}
|
||||
/>
|
||||
|
||||
{/* Center - Map + Analysis Views */}
|
||||
@ -689,29 +691,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-xs">
|
||||
<div className="font-semibold text-fg" style={{ marginBottom: 6 }}>
|
||||
{incidentPopup.incident.name}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled leading-[1.6]">
|
||||
<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="text-color-accent">
|
||||
{incidentPopup.incident.prediction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IncidentPopupContent
|
||||
incident={incidentPopup.incident}
|
||||
onClose={() => setIncidentPopup(null)}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</MapLibre>
|
||||
@ -1443,6 +1431,165 @@ 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-title-3 text-fg-sub">{analysis.oilType}</td>
|
||||
<td className="px-4 py-3 text-title-3 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">
|
||||
|
||||
@ -188,7 +188,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유');
|
||||
@ -586,7 +586,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');
|
||||
@ -661,7 +661,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));
|
||||
@ -754,7 +754,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,
|
||||
@ -762,7 +762,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"
|
||||
|
||||
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user