release: 2026-03-16 (81건 커밋) #93
@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 본문 크기 제한 (기본 100kb)
|
||||
* JSON 본문 크기 제한 (보고서 지도 캡처 이미지 포함 대응: 5mb)
|
||||
*/
|
||||
export const BODY_SIZE_LIMIT = '100kb'
|
||||
export const BODY_SIZE_LIMIT = '5mb'
|
||||
|
||||
/**
|
||||
* 응답 헤더에서 서버 정보 제거
|
||||
|
||||
@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req
|
||||
// ============================================================
|
||||
router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body;
|
||||
const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body;
|
||||
const result = await createReport({
|
||||
tmplSn,
|
||||
ctgrSn,
|
||||
@ -101,6 +101,7 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req
|
||||
jrsdCd,
|
||||
sttsCd,
|
||||
authorId: req.user!.sub,
|
||||
mapCaptureImg,
|
||||
sections,
|
||||
});
|
||||
res.status(201).json(result);
|
||||
@ -124,8 +125,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'),
|
||||
res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub);
|
||||
const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body;
|
||||
await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) {
|
||||
|
||||
@ -62,6 +62,7 @@ interface ReportListItem {
|
||||
authorName: string;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture: boolean;
|
||||
}
|
||||
|
||||
interface SectionData {
|
||||
@ -74,6 +75,7 @@ interface SectionData {
|
||||
interface ReportDetail extends ReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: SectionData[];
|
||||
mapCaptureImg: string | null;
|
||||
}
|
||||
|
||||
interface ListReportsInput {
|
||||
@ -100,6 +102,7 @@ interface CreateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
authorId: string;
|
||||
mapCaptureImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -108,6 +111,7 @@ interface UpdateReportInput {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}
|
||||
|
||||
@ -256,7 +260,8 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM
|
||||
r.REG_DTM, r.MDFCN_DTM,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
FROM REPORT r
|
||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||
@ -281,6 +286,7 @@ export async function listReports(input: ListReportsInput): Promise<ListReportsR
|
||||
authorName: r.author_name || '',
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
})),
|
||||
totalCount,
|
||||
page,
|
||||
@ -294,7 +300,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
c.CTGR_CD, c.CTGR_NM,
|
||||
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
|
||||
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
|
||||
r.REG_DTM, r.MDFCN_DTM
|
||||
r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG,
|
||||
CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE
|
||||
FROM REPORT r
|
||||
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
|
||||
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
|
||||
@ -331,6 +338,8 @@ export async function getReport(reportSn: number): Promise<ReportDetail> {
|
||||
authorName: r.author_name || '',
|
||||
regDtm: r.reg_dtm,
|
||||
mdfcnDtm: r.mdfcn_dtm,
|
||||
mapCaptureImg: r.map_capture_img,
|
||||
hasMapCapture: r.has_map_capture,
|
||||
sections: sectRes.rows.map((s) => ({
|
||||
sectCd: s.sect_cd,
|
||||
includeYn: s.include_yn,
|
||||
@ -350,8 +359,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
await client.query('BEGIN');
|
||||
|
||||
const res = await client.query(
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING REPORT_SN`,
|
||||
[
|
||||
input.tmplSn || null,
|
||||
@ -361,6 +370,7 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb
|
||||
input.jrsdCd || null,
|
||||
input.sttsCd || 'DRAFT',
|
||||
input.authorId,
|
||||
input.mapCaptureImg || null,
|
||||
]
|
||||
);
|
||||
const reportSn = res.rows[0].report_sn;
|
||||
@ -432,6 +442,10 @@ export async function updateReport(
|
||||
sets.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
if (input.mapCaptureImg !== undefined) {
|
||||
sets.push(`MAP_CAPTURE_IMG = $${idx++}`);
|
||||
params.push(input.mapCaptureImg);
|
||||
}
|
||||
|
||||
params.push(reportSn);
|
||||
await client.query(
|
||||
|
||||
@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS REPORT (
|
||||
USE_YN CHAR(1) DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||
MDFCN_DTM TIMESTAMPTZ,
|
||||
MAP_CAPTURE_IMG TEXT
|
||||
CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED'))
|
||||
);
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||
|
||||
interface HydrParticleOverlayProps {
|
||||
hydrStep: HydrDataStep | null;
|
||||
lightMode?: boolean;
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 3000;
|
||||
@ -21,7 +22,7 @@ interface Particle {
|
||||
age: number;
|
||||
}
|
||||
|
||||
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
||||
export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) {
|
||||
const { current: map } = useMap();
|
||||
const animRef = useRef<number>();
|
||||
|
||||
@ -125,7 +126,8 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
|
||||
// alpha band별 일괄 렌더링
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let b = 0; b < NUM_ALPHA_BANDS; b++) {
|
||||
ctx.strokeStyle = `rgba(180, 210, 255, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`;
|
||||
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);
|
||||
@ -151,7 +153,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro
|
||||
map.off('move', onMove);
|
||||
canvas.remove();
|
||||
};
|
||||
}, [map, hydrStep]);
|
||||
}, [map, hydrStep, lightMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -346,7 +346,7 @@ interface MapViewProps {
|
||||
hydrData?: (HydrDataStep | null)[]
|
||||
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
|
||||
externalCurrentTime?: number
|
||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>
|
||||
onIncidentFlyEnd?: () => void
|
||||
flyToIncident?: { lon: number; lat: number }
|
||||
showCurrent?: boolean
|
||||
@ -360,6 +360,8 @@ interface MapViewProps {
|
||||
analysisCircleRadiusM?: number
|
||||
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
|
||||
lightMode?: boolean
|
||||
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
|
||||
showOverlays?: boolean
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
@ -435,15 +437,34 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number;
|
||||
return null
|
||||
}
|
||||
|
||||
// 지도 캡처 지원 (preserveDrawingBuffer 필요)
|
||||
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) {
|
||||
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
|
||||
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise<string | null>) | null> }) {
|
||||
const { current: map } = useMap();
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
captureRef.current = () => {
|
||||
try { return map.getCanvas().toDataURL('image/png'); }
|
||||
catch { return null; }
|
||||
};
|
||||
captureRef.current = () =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
map.once('render', () => {
|
||||
try {
|
||||
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
|
||||
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
|
||||
const src = map.getCanvas();
|
||||
const maxW = 1200;
|
||||
const scale = src.width > maxW ? maxW / src.width : 1;
|
||||
const composite = document.createElement('canvas');
|
||||
composite.width = Math.round(src.width * scale);
|
||||
composite.height = Math.round(src.height * scale);
|
||||
const ctx = composite.getContext('2d')!;
|
||||
ctx.fillStyle = '#0f1117';
|
||||
ctx.fillRect(0, 0, composite.width, composite.height);
|
||||
ctx.drawImage(src, 0, 0, composite.width, composite.height);
|
||||
resolve(composite.toDataURL('image/jpeg', 0.82));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
map.triggerRepaint();
|
||||
});
|
||||
}, [map, captureRef]);
|
||||
return null;
|
||||
}
|
||||
@ -492,6 +513,7 @@ export function MapView({
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
lightMode = false,
|
||||
showOverlays = true,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
const isControlled = externalCurrentTime !== undefined
|
||||
@ -1007,7 +1029,7 @@ export function MapView({
|
||||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||||
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 200],
|
||||
getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number],
|
||||
getPixelOffset: [0, -20],
|
||||
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
@ -1028,7 +1050,7 @@ export function MapView({
|
||||
id: 'center-path',
|
||||
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [255, 220, 50, 200],
|
||||
getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number],
|
||||
getWidth: 2,
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 4,
|
||||
@ -1042,7 +1064,7 @@ export function MapView({
|
||||
data: visibleCenters,
|
||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||
getRadius: 5,
|
||||
getFillColor: [255, 220, 50, 230],
|
||||
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number],
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 8,
|
||||
pickable: false,
|
||||
@ -1067,11 +1089,11 @@ export function MapView({
|
||||
return `+${d.time}h`;
|
||||
},
|
||||
getSize: 12,
|
||||
getColor: [255, 220, 50, 220] as [number, number, number, number],
|
||||
getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number],
|
||||
getPixelOffset: [0, 16] as [number, number],
|
||||
fontWeight: 'bold',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
|
||||
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number],
|
||||
billboard: true,
|
||||
sizeUnits: 'pixels' as const,
|
||||
updateTriggers: {
|
||||
@ -1129,7 +1151,7 @@ export function MapView({
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
|
||||
])
|
||||
|
||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||
@ -1189,7 +1211,7 @@ export function MapView({
|
||||
|
||||
{/* 해류 파티클 오버레이 */}
|
||||
{hydrData.length > 0 && showCurrent && (
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
|
||||
)}
|
||||
|
||||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||||
@ -1241,15 +1263,15 @@ export function MapView({
|
||||
)}
|
||||
|
||||
{/* 기상청 연계 정보 */}
|
||||
<WeatherInfoPanel position={currentPosition} />
|
||||
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
|
||||
|
||||
{/* 범례 */}
|
||||
<MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />
|
||||
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
|
||||
|
||||
{/* 좌표 표시 */}
|
||||
<CoordinateDisplay
|
||||
{showOverlays && <CoordinateDisplay
|
||||
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
|
||||
/>
|
||||
/>}
|
||||
|
||||
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
|
||||
{!isControlled && oilTrajectory.length > 0 && (
|
||||
|
||||
@ -169,6 +169,14 @@ export function consumeHnsReportPayload(): HnsReportPayload | null {
|
||||
}
|
||||
|
||||
// ─── 유출유 예측 보고서 실 데이터 전달 ──────────────────────────
|
||||
export interface OilReportMapParticle {
|
||||
lat: number;
|
||||
lon: number;
|
||||
time: number;
|
||||
particle?: number;
|
||||
stranded?: 0 | 1;
|
||||
}
|
||||
|
||||
export interface OilReportPayload {
|
||||
incident: {
|
||||
name: string;
|
||||
@ -204,6 +212,14 @@ export interface OilReportPayload {
|
||||
firstTime: string | null;
|
||||
};
|
||||
hasSimulation: boolean;
|
||||
mapData: {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
trajectory: OilReportMapParticle[];
|
||||
currentStep: number;
|
||||
centerPoints: { lat: number; lon: number; time: number }[];
|
||||
simulationStartTime: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let _oilReportPayload: OilReportPayload | null = null;
|
||||
|
||||
@ -749,6 +749,14 @@ export function OilSpillView() {
|
||||
})(),
|
||||
},
|
||||
hasSimulation: simulationSummary !== null,
|
||||
mapData: incidentCoord ? {
|
||||
center: [incidentCoord.lat, incidentCoord.lon],
|
||||
zoom: 10,
|
||||
trajectory: oilTrajectory,
|
||||
currentStep,
|
||||
centerPoints,
|
||||
simulationStartTime: accidentTime,
|
||||
} : null,
|
||||
};
|
||||
|
||||
setOilReportPayload(payload);
|
||||
@ -829,6 +837,7 @@ export function OilSpillView() {
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
lightMode
|
||||
centerPoints={centerPoints}
|
||||
windData={windData}
|
||||
hydrData={hydrData}
|
||||
|
||||
@ -38,6 +38,8 @@ export interface OilSpillReportData {
|
||||
etcEquipment: string
|
||||
recovery: { shipName: string; period: string }[]
|
||||
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
|
||||
capturedMapImage?: string;
|
||||
hasMapCapture?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
||||
106
frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx
Normal file
106
frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import type { OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
|
||||
interface OilSpreadMapPanelProps {
|
||||
mapData: OilReportPayload['mapData'];
|
||||
capturedImage: string | null;
|
||||
onCapture: (dataUrl: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => {
|
||||
const captureRef = useRef<(() => Promise<string | null>) | null>(null);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (!captureRef.current) return;
|
||||
setIsCapturing(true);
|
||||
const dataUrl = await captureRef.current();
|
||||
setIsCapturing(false);
|
||||
if (dataUrl) {
|
||||
onCapture(dataUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mapData) {
|
||||
return (
|
||||
<div className="w-full h-[280px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */}
|
||||
<div className="relative w-full rounded-lg border border-border overflow-hidden" style={{ height: '620px' }}>
|
||||
<MapView
|
||||
center={mapData.center}
|
||||
zoom={mapData.zoom}
|
||||
incidentCoord={{ lat: mapData.center[0], lon: mapData.center[1] }}
|
||||
oilTrajectory={mapData.trajectory}
|
||||
externalCurrentTime={mapData.currentStep}
|
||||
centerPoints={mapData.centerPoints}
|
||||
showBeached={true}
|
||||
showTimeLabel={true}
|
||||
simulationStartTime={mapData.simulationStartTime || undefined}
|
||||
mapCaptureRef={captureRef}
|
||||
showOverlays={false}
|
||||
/>
|
||||
|
||||
{/* 캡처 이미지 오버레이 — 우측 상단 */}
|
||||
{capturedImage && (
|
||||
<div className="absolute top-3 right-3 z-10" style={{ width: '220px' }}>
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<img src={capturedImage} alt="확산예측 지도 캡처" className="w-full block" />
|
||||
<div
|
||||
className="flex items-center justify-between px-2.5 py-1.5"
|
||||
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
||||
>
|
||||
<span className="text-[10px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||
📷 캡처 완료
|
||||
</span>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-[10px] font-korean hover:text-text-1 transition-colors"
|
||||
style={{ color: 'rgba(148,163,184,0.8)' }}
|
||||
>
|
||||
다시 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 + 캡처 버튼 */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-[10px] text-text-3 font-korean">
|
||||
{capturedImage
|
||||
? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.'
|
||||
: '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCapture}
|
||||
disabled={isCapturing || !!capturedImage}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
||||
style={{
|
||||
background: capturedImage ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
|
||||
border: '1px solid rgba(6,182,212,0.4)',
|
||||
color: capturedImage ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||
opacity: isCapturing ? 0.6 : 1,
|
||||
cursor: capturedImage ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{capturedImage ? '✓ 캡처됨' : '📷 이 범위로 캡처'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OilSpreadMapPanel;
|
||||
@ -3,6 +3,7 @@ import {
|
||||
createEmptyReport,
|
||||
} from './OilSpillReportTemplate';
|
||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||
import { saveReport } from '../services/reportsApi';
|
||||
import {
|
||||
CATEGORIES,
|
||||
@ -34,6 +35,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
|
||||
// OIL 실 데이터 (없으면 sampleOilData fallback)
|
||||
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
|
||||
// 확산예측 지도 캡처 이미지
|
||||
const [oilMapCaptured, setOilMapCaptured] = useState<string | null>(null)
|
||||
|
||||
// 외부에서 카테고리 힌트가 변경되면 반영
|
||||
useEffect(() => {
|
||||
@ -84,6 +87,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
report.incident.spillAmount = sampleOilData.pollution.spillAmount;
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && oilMapCaptured) {
|
||||
report.capturedMapImage = oilMapCaptured;
|
||||
}
|
||||
try {
|
||||
await saveReport(report)
|
||||
onSave()
|
||||
@ -99,6 +105,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
let content = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`;
|
||||
|
||||
// OIL 섹션에 실 데이터 삽입
|
||||
if (activeCat === 0) {
|
||||
if (sec.id === 'oil-spread') {
|
||||
const mapImg = oilMapCaptured
|
||||
? `<img src="${oilMapCaptured}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:8px;margin-bottom:12px;" />`
|
||||
: '<div style="height:60px;background:#f5f5f5;border:1px solid #ddd;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#999;margin-bottom:12px;font-size:12px;">[확산예측 지도 미캡처]</div>';
|
||||
const spreadRows = oilPayload
|
||||
? [
|
||||
['KOSPS', oilPayload.spread.kosps],
|
||||
['OpenDrift', oilPayload.spread.openDrift],
|
||||
['POSEIDON', oilPayload.spread.poseidon],
|
||||
]
|
||||
: [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']];
|
||||
const tds = spreadRows.map(r =>
|
||||
`<td style="padding:8px;border:1px solid #ddd;text-align:center;"><b>${r[0]}</b><br/>${r[1]}</td>`
|
||||
).join('');
|
||||
content = `${mapImg}<table style="width:100%;border-collapse:collapse;font-size:12px;"><tr>${tds}</tr></table>`;
|
||||
}
|
||||
}
|
||||
if (activeCat === 0 && oilPayload) {
|
||||
if (sec.id === 'oil-pollution') {
|
||||
const rows = [
|
||||
@ -290,9 +314,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{/* ── 유출유 확산예측 섹션들 ── */}
|
||||
{sec.id === 'oil-spread' && (
|
||||
<>
|
||||
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
|
||||
[확산예측 지도 - 범위 조절 작업]
|
||||
</div>
|
||||
<OilSpreadMapPanel
|
||||
mapData={oilPayload?.mapData ?? null}
|
||||
capturedImage={oilMapCaptured}
|
||||
onCapture={(dataUrl) => setOilMapCaptured(dataUrl)}
|
||||
onReset={() => setOilMapCaptured(null)}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'KOSPS', value: oilPayload?.spread.kosps || sampleOilData.spread.kosps, color: '#06b6d4' },
|
||||
|
||||
@ -130,6 +130,7 @@ export function ReportsView() {
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '6%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '6%' }} />
|
||||
<col style={{ width: '4%' }} />
|
||||
</colgroup>
|
||||
@ -145,6 +146,7 @@ export function ReportsView() {
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">관할</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">상태</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">수정</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">지도</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">다운로드</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean">삭제</th>
|
||||
</tr>
|
||||
@ -177,6 +179,12 @@ export function ReportsView() {
|
||||
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.jurisdiction}</td>
|
||||
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: statusColors[report.status]?.bg, color: statusColors[report.status]?.text }}>{report.status}</span></td>
|
||||
<td className="px-3 py-3 text-center"><button onClick={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setView({ screen: 'edit', data: detail }) } catch { setView({ screen: 'edit', data: { ...report } }) } }} className="text-[11px] text-primary-cyan hover:underline font-korean">수정</button></td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{(report.hasMapCapture || report.capturedMapImage)
|
||||
? <span title="확산예측 지도 캡처 있음" className="text-[14px]">📷</span>
|
||||
: <span className="text-[11px] text-text-3">—</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center"><button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">PDF</button></td>
|
||||
<td className="px-3 py-3 text-center"><button onClick={() => handleDelete(report.id)} className="w-7 h-7 rounded flex items-center justify-center text-status-red hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm">🗑</button></td>
|
||||
</tr>
|
||||
@ -369,6 +377,14 @@ export function ReportsView() {
|
||||
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
|
||||
].filter(Boolean).join('\n') || '—'}
|
||||
</div>
|
||||
{previewReport.capturedMapImage && (
|
||||
<img
|
||||
src={previewReport.capturedMapImage}
|
||||
alt="확산예측 지도 캡처"
|
||||
className="w-full rounded-lg border border-border mt-3"
|
||||
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. 초동조치 / 대응현황 */}
|
||||
|
||||
@ -62,6 +62,7 @@ export interface ApiReportListItem {
|
||||
authorName: string;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string | null;
|
||||
hasMapCapture?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiReportSectionData {
|
||||
@ -74,6 +75,7 @@ export interface ApiReportSectionData {
|
||||
export interface ApiReportDetail extends ApiReportListItem {
|
||||
acdntSn: number | null;
|
||||
sections: ApiReportSectionData[];
|
||||
mapCaptureImg?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiReportListResponse {
|
||||
@ -176,6 +178,7 @@ export async function createReportApi(input: {
|
||||
title: string;
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
mapCaptureImg?: string;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<{ sn: number }> {
|
||||
const res = await api.post<{ sn: number }>('/reports', input);
|
||||
@ -187,6 +190,7 @@ export async function updateReportApi(sn: number, input: {
|
||||
jrsdCd?: string;
|
||||
sttsCd?: string;
|
||||
acdntSn?: number | null;
|
||||
mapCaptureImg?: string | null;
|
||||
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
|
||||
}): Promise<void> {
|
||||
await api.post(`/reports/${sn}/update`, input);
|
||||
@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
sttsCd,
|
||||
mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined,
|
||||
sections,
|
||||
});
|
||||
return existingSn;
|
||||
@ -250,6 +255,7 @@ export async function saveReport(data: OilSpillReportData): Promise<number> {
|
||||
title: data.title || data.incident.name || '보고서',
|
||||
jrsdCd: data.jurisdiction,
|
||||
sttsCd,
|
||||
mapCaptureImg: data.capturedMapImage || undefined,
|
||||
sections,
|
||||
});
|
||||
return result.sn;
|
||||
@ -266,6 +272,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
||||
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
|
||||
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
|
||||
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||
hasMapCapture: item.hasMapCapture,
|
||||
// 목록에서는 섹션 데이터 없음 — 빈 기본값
|
||||
incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||
tide: [], weather: [], spread: [],
|
||||
@ -337,6 +344,10 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
||||
}
|
||||
}
|
||||
|
||||
if (detail.mapCaptureImg) {
|
||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||
}
|
||||
|
||||
return reportData;
|
||||
}
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user