refactor(map): TimelineControl 분리 및 aerial/hns 컴포넌트 개선
This commit is contained in:
부모
388116aa88
커밋
2082e9a79b
3
.gitignore
vendored
3
.gitignore
vendored
@ -103,3 +103,6 @@ frontend/public/hns-manual/images/
|
||||
|
||||
# mcp
|
||||
.mcp.json
|
||||
|
||||
# python
|
||||
.venv
|
||||
@ -107,6 +107,8 @@ wing/
|
||||
- `naming.md` -- 네이밍 규칙
|
||||
- `testing.md` -- 테스트 규칙
|
||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
||||
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
||||
|
||||
## 개발 문서 (docs/)
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import express from 'express';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
listMedia,
|
||||
createMedia,
|
||||
@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||
const router = express.Router();
|
||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
const mediaUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = path.resolve('uploads', 'aerial');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i;
|
||||
if (allowed.test(path.extname(file.originalname))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('허용되지 않는 파일 형식입니다.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// AERIAL_MEDIA 라우트
|
||||
// ============================================================
|
||||
@ -73,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록
|
||||
router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: '파일이 필요합니다.' });
|
||||
return;
|
||||
}
|
||||
const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as {
|
||||
equipTpCd?: string;
|
||||
equipNm?: string;
|
||||
mediaTpCd?: string;
|
||||
acdntSn?: string;
|
||||
memo?: string;
|
||||
};
|
||||
|
||||
const isVideo = file.mimetype.startsWith('video/');
|
||||
const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진');
|
||||
const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
|
||||
const result = await createMedia({
|
||||
fileNm: file.filename,
|
||||
orgnlNm: file.originalname,
|
||||
filePath: file.path,
|
||||
equipTpCd: equipTpCd ?? 'drone',
|
||||
equipNm: equipNm ?? '기타',
|
||||
mediaTpCd: detectedMediaType,
|
||||
fileSz: fileSzMb,
|
||||
acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined,
|
||||
locDc: memo ?? undefined,
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error('[aerial] 미디어 업로드 오류:', err);
|
||||
res.status(500).json({ error: '미디어 업로드 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시)
|
||||
router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params['sn'] as string, 10);
|
||||
if (!isValidNumber(sn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
|
||||
return;
|
||||
}
|
||||
|
||||
const media = await getMediaBySn(sn);
|
||||
if (!media) {
|
||||
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 로컬 업로드 파일이면 직접 서빙
|
||||
if (media.filePath) {
|
||||
const absPath = path.resolve(media.filePath);
|
||||
if (existsSync(absPath)) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.tif': 'image/tiff', '.tiff': 'image/tiff',
|
||||
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
||||
};
|
||||
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.sendFile(absPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = media.fileNm.substring(0, 36);
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
|
||||
res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = await fetchOriginalImage(media.equipNm, fileId);
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[aerial] 이미지 뷰어 오류:', err);
|
||||
res.status(502).json({ error: '이미지 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
||||
// OIL INFERENCE (GPU 서버 프록시)
|
||||
// ============================================================
|
||||
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
|
||||
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
|
||||
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
SensitiveResourceFeatureCollection,
|
||||
} from '@tabs/prediction/services/predictionApi';
|
||||
import HydrParticleOverlay from './HydrParticleOverlay';
|
||||
import { TimelineControl } from './TimelineControl';
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine';
|
||||
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack';
|
||||
import { createBacktrackLayers } from './BacktrackReplayOverlay';
|
||||
@ -1417,6 +1418,7 @@ export function MapView({
|
||||
onTimeChange={setInternalCurrentTime}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
onSpeedChange={setPlaybackSpeed}
|
||||
simulationStartTime={simulationStartTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1676,130 +1678,6 @@ function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoo
|
||||
);
|
||||
}
|
||||
|
||||
// 타임라인 컨트롤
|
||||
interface TimelineControlProps {
|
||||
currentTime: number;
|
||||
maxTime: number;
|
||||
isPlaying: boolean;
|
||||
playbackSpeed: number;
|
||||
onTimeChange: (time: number) => void;
|
||||
onPlayPause: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
function TimelineControl({
|
||||
currentTime,
|
||||
maxTime,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
onTimeChange,
|
||||
onPlayPause,
|
||||
onSpeedChange,
|
||||
}: TimelineControlProps) {
|
||||
const progressPercent = (currentTime / maxTime) * 100;
|
||||
|
||||
const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6));
|
||||
const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6));
|
||||
const handleStart = () => onTimeChange(0);
|
||||
const handleEnd = () => onTimeChange(maxTime);
|
||||
|
||||
const toggleSpeed = () => {
|
||||
const speeds = [1, 2, 4];
|
||||
const currentIndex = speeds.indexOf(playbackSpeed);
|
||||
onSpeedChange(speeds[(currentIndex + 1) % speeds.length]);
|
||||
};
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))));
|
||||
};
|
||||
|
||||
const timeLabels = [];
|
||||
for (let t = 0; t <= maxTime; t += 6) {
|
||||
timeLabels.push(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tlb">
|
||||
<div className="tlc">
|
||||
<div className="tb" onClick={handleStart}>
|
||||
⏮
|
||||
</div>
|
||||
<div className="tb" onClick={handleRewind}>
|
||||
◀
|
||||
</div>
|
||||
<div className={`tb ${isPlaying ? 'on' : ''}`} onClick={onPlayPause}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</div>
|
||||
<div className="tb" onClick={handleForward}>
|
||||
▶▶
|
||||
</div>
|
||||
<div className="tb" onClick={handleEnd}>
|
||||
⏭
|
||||
</div>
|
||||
<div className="w-2" />
|
||||
<div className="tb" onClick={toggleSpeed}>
|
||||
{playbackSpeed}×
|
||||
</div>
|
||||
</div>
|
||||
<div className="tlt">
|
||||
<div className="tlls">
|
||||
{timeLabels.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
>
|
||||
{t}h
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="tlsw" onClick={handleTimelineClick}>
|
||||
<div className="tlr">
|
||||
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
||||
{timeLabels.map((t) => (
|
||||
<div
|
||||
key={`marker-${t}`}
|
||||
className={`tlm ${t % 12 === 0 ? 'mj' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="tli">
|
||||
{/* eslint-disable-next-line react-hooks/purity */}
|
||||
<div className="tlct">
|
||||
+{currentTime.toFixed(0)}h —{' '}
|
||||
{(() => {
|
||||
const base = simulationStartTime ? new Date(simulationStartTime) : new Date();
|
||||
const d = new Date(base.getTime() + currentTime * 3600 * 1000);
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
|
||||
})()}
|
||||
</div>
|
||||
<div className="tlss">
|
||||
<div className="tls">
|
||||
<span className="tlsl">진행률</span>
|
||||
<span className="tlsv">{progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="tls">
|
||||
<span className="tlsl">속도</span>
|
||||
<span className="tlsv">{playbackSpeed}×</span>
|
||||
</div>
|
||||
<div className="tls">
|
||||
<span className="tlsl">시간</span>
|
||||
<span className="tlsv">
|
||||
{currentTime.toFixed(0)}/{maxTime}h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기상 데이터 Mock
|
||||
function getWeatherData(position: [number, number]) {
|
||||
const [lat, lng] = position;
|
||||
|
||||
152
frontend/src/common/components/map/TimelineControl.tsx
Normal file
152
frontend/src/common/components/map/TimelineControl.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
export interface TimelineControlProps {
|
||||
currentTime: number;
|
||||
maxTime: number;
|
||||
isPlaying: boolean;
|
||||
playbackSpeed: number;
|
||||
onTimeChange: (time: number) => void;
|
||||
onPlayPause: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
simulationStartTime?: string;
|
||||
stepSize?: number;
|
||||
tickInterval?: number;
|
||||
majorTickEvery?: number;
|
||||
timeUnitLabel?: string;
|
||||
formatOffset?: (t: number) => string;
|
||||
formatAbsolute?: (t: number, base: Date) => string;
|
||||
showSpeedToggle?: boolean;
|
||||
}
|
||||
|
||||
export function TimelineControl({
|
||||
currentTime,
|
||||
maxTime,
|
||||
isPlaying,
|
||||
playbackSpeed,
|
||||
onTimeChange,
|
||||
onPlayPause,
|
||||
onSpeedChange,
|
||||
simulationStartTime,
|
||||
stepSize = 6,
|
||||
tickInterval = 6,
|
||||
majorTickEvery = 12,
|
||||
timeUnitLabel = 'h',
|
||||
formatOffset,
|
||||
formatAbsolute,
|
||||
showSpeedToggle = true,
|
||||
}: TimelineControlProps) {
|
||||
const progressPercent = maxTime > 0 ? (currentTime / maxTime) * 100 : 0;
|
||||
|
||||
const handleRewind = () => onTimeChange(Math.max(0, currentTime - stepSize));
|
||||
const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + stepSize));
|
||||
const handleStart = () => onTimeChange(0);
|
||||
const handleEnd = () => onTimeChange(maxTime);
|
||||
|
||||
const toggleSpeed = () => {
|
||||
const speeds = [1, 2, 4];
|
||||
const currentIndex = speeds.indexOf(playbackSpeed);
|
||||
onSpeedChange(speeds[(currentIndex + 1) % speeds.length]);
|
||||
};
|
||||
|
||||
const handleTimelineClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))));
|
||||
};
|
||||
|
||||
const timeLabels: number[] = [];
|
||||
for (let t = 0; t <= maxTime; t += tickInterval) {
|
||||
timeLabels.push(t);
|
||||
}
|
||||
|
||||
const defaultOffset = (t: number) => `+${t.toFixed(0)}${timeUnitLabel}`;
|
||||
const defaultAbsolute = (t: number, base: Date) => {
|
||||
const d = new Date(base.getTime() + t * 3600 * 1000);
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
|
||||
};
|
||||
|
||||
const offsetStr = (formatOffset ?? defaultOffset)(currentTime);
|
||||
const baseDate = simulationStartTime ? new Date(simulationStartTime) : new Date();
|
||||
const absoluteStr = (formatAbsolute ?? defaultAbsolute)(currentTime, baseDate);
|
||||
|
||||
return (
|
||||
<div className="tlb">
|
||||
<div className="tlc">
|
||||
<div className="tb" onClick={handleStart}>
|
||||
⏮
|
||||
</div>
|
||||
<div className="tb" onClick={handleRewind}>
|
||||
◀
|
||||
</div>
|
||||
<div className={`tb ${isPlaying ? 'on' : ''}`} onClick={onPlayPause}>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</div>
|
||||
<div className="tb" onClick={handleForward}>
|
||||
▶▶
|
||||
</div>
|
||||
<div className="tb" onClick={handleEnd}>
|
||||
⏭
|
||||
</div>
|
||||
{showSpeedToggle && (
|
||||
<>
|
||||
<div className="w-2" />
|
||||
<div className="tb" onClick={toggleSpeed}>
|
||||
{playbackSpeed}×
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="tlt">
|
||||
<div className="tlls">
|
||||
{timeLabels.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
>
|
||||
{t}
|
||||
{timeUnitLabel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="tlsw" onClick={handleTimelineClick}>
|
||||
<div className="tlr">
|
||||
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
||||
{timeLabels.map((t) => (
|
||||
<div
|
||||
key={`marker-${t}`}
|
||||
className={`tlm ${t % majorTickEvery === 0 ? 'mj' : ''}`}
|
||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="tli">
|
||||
<div className="tlct">
|
||||
{offsetStr} — {absoluteStr}
|
||||
</div>
|
||||
<div className="tlss">
|
||||
<div className="tls">
|
||||
<span className="tlsl">진행률</span>
|
||||
<span className="tlsv">{progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
{showSpeedToggle && (
|
||||
<div className="tls">
|
||||
<span className="tlsl">속도</span>
|
||||
<span className="tlsv">{playbackSpeed}×</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tls">
|
||||
<span className="tlsl">시간</span>
|
||||
<span className="tlsv">
|
||||
{currentTime.toFixed(0)}/{maxTime}
|
||||
{timeUnitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1187
frontend/src/common/data/chapters.json
Normal file
1187
frontend/src/common/data/chapters.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
30
frontend/src/common/data/manualChapters.ts
Normal file
30
frontend/src/common/data/manualChapters.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import chaptersJson from './chapters.json';
|
||||
|
||||
export interface InputItem {
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface ScreenItem {
|
||||
id: string;
|
||||
name: string;
|
||||
menuPath: string;
|
||||
imageIndex: number;
|
||||
overview: string;
|
||||
description?: string;
|
||||
procedure?: string[];
|
||||
inputs?: InputItem[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
screens: ScreenItem[];
|
||||
}
|
||||
|
||||
export const CHAPTERS = chaptersJson as Chapter[];
|
||||
@ -1,5 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi';
|
||||
import {
|
||||
fetchAerialMedia,
|
||||
downloadAerialMedia,
|
||||
getAerialMediaViewUrl,
|
||||
uploadAerialMedia,
|
||||
} from '../services/aerialApi';
|
||||
import type { AerialMediaItem } from '../services/aerialApi';
|
||||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||||
|
||||
@ -54,7 +59,16 @@ export function MediaManagement() {
|
||||
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [previewItem, setPreviewItem] = useState<AerialMediaItem | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
// const [uploadEquip, setUploadEquip] = useState('drone');
|
||||
// const [uploadEquipNm, setUploadEquipNm] = useState('드론 (DJI M300 RTK)');
|
||||
// const [uploadMemo, setUploadMemo] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -82,6 +96,15 @@ export function MediaManagement() {
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewItem) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setPreviewItem(null);
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [previewItem]);
|
||||
|
||||
const filtered = mediaItems.filter((f) => {
|
||||
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false;
|
||||
if (typeFilter.size > 0) {
|
||||
@ -164,6 +187,38 @@ export function MediaManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSubmit = async () => {
|
||||
if (!uploadFile || uploading) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadAerialMedia(uploadFile, {
|
||||
// equipTpCd: uploadEquip,
|
||||
// equipNm: uploadEquipNm,
|
||||
// memo: uploadMemo,
|
||||
});
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
// setUploadMemo('');
|
||||
await loadData();
|
||||
} catch {
|
||||
alert('업로드 실패: 다시 시도해주세요.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) setUploadFile(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setUploadFile(file);
|
||||
};
|
||||
|
||||
const droneCount = mediaItems.filter((f) => f.equipTpCd === 'drone').length;
|
||||
const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length;
|
||||
const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length;
|
||||
@ -224,6 +279,12 @@ export function MediaManagement() {
|
||||
<option value="name">이름순</option>
|
||||
<option value="size">크기순</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
|
||||
>
|
||||
+ 이미지 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -266,7 +327,7 @@ export function MediaManagement() {
|
||||
|
||||
{/* File Table */}
|
||||
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="overflow-auto flex-1 scrollbar-thin">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 36 }} />
|
||||
@ -332,56 +393,74 @@ export function MediaManagement() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((f) => (
|
||||
<tr
|
||||
key={f.aerialMediaSn}
|
||||
onClick={() => toggleId(f.aerialMediaSn)}
|
||||
className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(f.aerialMediaSn)}
|
||||
onChange={() => toggleId(f.aerialMediaSn)}
|
||||
className="accent-primary-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
||||
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
|
||||
{f.acdntSn != null ? String(f.acdntSn) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-caption text-color-accent font-mono truncate">
|
||||
{f.locDc ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-label-2 font-semibold text-fg font-korean truncate">
|
||||
{f.fileNm}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`text-caption font-semibold font-korean ${equipTagCls()}`}>
|
||||
{f.equipNm}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`text-caption font-semibold font-korean ${mediaTagCls()}`}>
|
||||
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, f)}
|
||||
disabled={downloadingId === f.aerialMediaSn}
|
||||
className="px-2 py-1 text-caption rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors disabled:opacity-50"
|
||||
sorted.map((f) => {
|
||||
const isPhoto = f.mediaTpCd !== '영상';
|
||||
return (
|
||||
<tr
|
||||
key={f.aerialMediaSn}
|
||||
className={`border-b border-stroke transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
<td
|
||||
className="px-2 py-2 text-center cursor-pointer"
|
||||
onClick={() => toggleId(f.aerialMediaSn)}
|
||||
>
|
||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(f.aerialMediaSn)}
|
||||
onChange={() => toggleId(f.aerialMediaSn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="accent-primary-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
||||
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
|
||||
{f.acdntSn != null ? String(f.acdntSn) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-caption text-fg font-mono truncate">
|
||||
{f.locDc ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-label-2 text-fg font-korean truncate">
|
||||
{f.fileNm}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`text-caption font-korean ${equipTagCls()}`}>
|
||||
{f.equipNm}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isPhoto ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewItem(f)}
|
||||
className={`text-caption font-semibold font-korean hover:underline cursor-pointer ${mediaTagCls()}`}
|
||||
>
|
||||
📷 {f.mediaTpCd}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`text-caption font-semibold font-korean ${mediaTagCls()}`}
|
||||
>
|
||||
🎬 {f.mediaTpCd}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, f)}
|
||||
disabled={downloadingId === f.aerialMediaSn}
|
||||
className="px-2 py-1 text-caption disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -398,20 +477,20 @@ export function MediaManagement() {
|
||||
onClick={toggleAll}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
||||
>
|
||||
☑ 전체선택
|
||||
전체선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDownload}
|
||||
disabled={bulkDownloading || selectedIds.size === 0}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
|
||||
>
|
||||
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
||||
{bulkDownloading ? '⏳ 다운로드 중...' : '선택 다운로드'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
|
||||
>
|
||||
🔬 유출유확산예측으로 →
|
||||
유출유확산예측으로 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -457,28 +536,78 @@ export function MediaManagement() {
|
||||
className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-base font-bold font-korean">📤 영상·사진 업로드</span>
|
||||
<span className="text-base font-bold font-korean">영상·사진 업로드</span>
|
||||
<button
|
||||
onClick={() => setShowUpload(false)}
|
||||
onClick={() => {
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
}}
|
||||
className="text-fg-disabled text-lg hover:text-fg"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
|
||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||
<div className="text-title-4 font-semibold mb-1 font-korean">
|
||||
파일을 드래그하거나 클릭하여 업로드
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-md py-8 px-4 text-center mb-4 cursor-pointer transition-colors ${
|
||||
dragOver
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.06)]'
|
||||
: uploadFile
|
||||
? 'border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.04)]'
|
||||
: 'border-stroke-light hover:border-[rgba(6,182,212,0.4)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.tif,.tiff,.mp4,.mov"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadFile ? (
|
||||
<>
|
||||
<div className="text-3xl mb-2">✅</div>
|
||||
<div className="text-title-4 font-semibold mb-1 font-korean truncate px-4">
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
{(uploadFile.size / (1024 * 1024)).toFixed(2)} MB · 클릭하여 변경
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||
<div className="text-title-4 font-semibold mb-1 font-korean">
|
||||
파일을 드래그하거나 클릭하여 업로드
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{/* <div className="mb-3">
|
||||
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||
촬영 장비
|
||||
</label>
|
||||
<select className="prd-i w-full">
|
||||
<select
|
||||
className="prd-i w-full"
|
||||
value={uploadEquipNm}
|
||||
onChange={(e) => {
|
||||
setUploadEquipNm(e.target.value);
|
||||
const v = e.target.value;
|
||||
if (v.startsWith('드론')) setUploadEquip('drone');
|
||||
else if (v.startsWith('유인')) setUploadEquip('plane');
|
||||
else if (v.startsWith('위성')) setUploadEquip('satellite');
|
||||
else setUploadEquip('drone');
|
||||
}}
|
||||
>
|
||||
<option>드론 (DJI M300 RTK)</option>
|
||||
<option>드론 (DJI Mavic 3E)</option>
|
||||
<option>유인항공기 (CN-235)</option>
|
||||
@ -505,21 +634,88 @@ export function MediaManagement() {
|
||||
<textarea
|
||||
className="prd-i w-full h-[60px] resize-y"
|
||||
placeholder="촬영 조건, 비고 등..."
|
||||
value={uploadMemo}
|
||||
onChange={(e) => setUploadMemo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
<button
|
||||
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
|
||||
onClick={handleUploadSubmit}
|
||||
disabled={!uploadFile || uploading}
|
||||
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.15)',
|
||||
border: '1px solid rgba(6,182,212,0.3)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
📤 업로드 실행
|
||||
{uploading ? '업로드 중...' : '업로드 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
{previewItem && (
|
||||
<div
|
||||
className="fixed inset-0 z-[250] bg-black/70 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={() => setPreviewItem(null)}
|
||||
>
|
||||
<div
|
||||
ref={previewRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-bg-surface border border-stroke rounded-md w-[720px] max-w-[90vw] max-h-[85vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-stroke">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-label-1 font-bold font-korean text-fg truncate">
|
||||
{previewItem.fileNm}
|
||||
</span>
|
||||
<span className="text-caption text-fg-disabled font-mono">
|
||||
{formatDtm(previewItem.takngDtm)} · {previewItem.equipNm}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewItem(null)}
|
||||
className="text-fg-disabled text-lg hover:text-fg ml-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden bg-black/30 relative min-h-[320px]">
|
||||
<img
|
||||
src={getAerialMediaViewUrl(previewItem.aerialMediaSn)}
|
||||
alt={previewItem.orgnlNm ?? previewItem.fileNm}
|
||||
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] text-fg-disabled">📷</div>
|
||||
<div className="text-label-1 text-fg-disabled font-korean">
|
||||
이미지를 불러올 수 없습니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-4 py-2.5 border-t border-stroke">
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, previewItem)}
|
||||
disabled={downloadingId === previewItem.aerialMediaSn}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === previewItem.aerialMediaSn ? '⏳ 다운로드 중...' : '📥 다운로드'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewItem(null)}
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -239,7 +239,7 @@ export function OilAreaAnalysis() {
|
||||
<div className="flex gap-5 h-full overflow-hidden">
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-body-2 font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||
<div className="text-body-2 font-bold mb-1 font-korean">영상사진합성</div>
|
||||
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||
</div>
|
||||
@ -396,12 +396,12 @@ export function OilAreaAnalysis() {
|
||||
: { background: 'var(--bg-3)' }
|
||||
}
|
||||
>
|
||||
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
|
||||
{isAnalyzing ? '⏳ 분석 중...' : '분석 시작'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel ── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin min-w-0">
|
||||
{/* 3×2 이미지 그리드 */}
|
||||
<div className="text-label-2 font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
@ -458,8 +458,8 @@ export function OilAreaAnalysis() {
|
||||
{/* 합성 결과 */}
|
||||
<div className="text-label-2 font-bold mb-2 font-korean">합성 결과</div>
|
||||
<div
|
||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center shrink-0"
|
||||
style={{ minHeight: '400px' }}
|
||||
>
|
||||
{stitchedPreviewUrl ? (
|
||||
<img
|
||||
|
||||
@ -102,6 +102,10 @@ export async function createSatRequest(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function getAerialMediaViewUrl(sn: number): string {
|
||||
return `/api/aerial/media/${sn}/view`;
|
||||
}
|
||||
|
||||
export async function downloadAerialMedia(sn: number, fileName: string): Promise<void> {
|
||||
const res = await api.get(`/aerial/media/${sn}/download`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
@ -114,6 +118,30 @@ export async function downloadAerialMedia(sn: number, fileName: string): Promise
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function uploadAerialMedia(
|
||||
file: File,
|
||||
metadata: {
|
||||
equipTpCd?: string;
|
||||
equipNm?: string;
|
||||
mediaTpCd?: string;
|
||||
acdntSn?: string;
|
||||
memo?: string;
|
||||
},
|
||||
): Promise<{ aerialMediaSn: number }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (metadata.equipTpCd) form.append('equipTpCd', metadata.equipTpCd);
|
||||
if (metadata.equipNm) form.append('equipNm', metadata.equipNm);
|
||||
if (metadata.mediaTpCd) form.append('mediaTpCd', metadata.mediaTpCd);
|
||||
if (metadata.acdntSn) form.append('acdntSn', metadata.acdntSn);
|
||||
if (metadata.memo) form.append('memo', metadata.memo);
|
||||
const response = await api.post<{ aerialMediaSn: number }>('/aerial/media/upload', form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
timeout: 300_000,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 이미지 파일을 /aerial/stitch 엔드포인트로 전송해 합성 JPEG Blob을 반환한다.
|
||||
* FastAPI /stitch → pic_gps.py 스티칭 파이프라인 프록시.
|
||||
|
||||
@ -50,8 +50,8 @@ interface HnsMaterial {
|
||||
|
||||
const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||
HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)' },
|
||||
MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' },
|
||||
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||
MEDIUM: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
||||
};
|
||||
|
||||
@ -495,12 +495,12 @@ export function HNSScenarioView() {
|
||||
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
||||
<button
|
||||
onClick={() => setActiveView(1)}
|
||||
className="flex-1 cursor-pointer font-bold text-static-white text-label-2 p-2 rounded-sm bg-color-navy hover:bg-color-navy-hover"
|
||||
className="flex-1 cursor-pointer font-semibold text-fg-sub text-label-2 p-2 rounded-sm bg-bg-card border border-stroke hover:bg-color-accent hover:text-fg"
|
||||
>
|
||||
📊 선택 시나리오 비교
|
||||
선택 시나리오 비교
|
||||
</button>
|
||||
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
||||
📄 보고서
|
||||
보고서
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -509,7 +509,7 @@ export function HNSScenarioView() {
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{/* View Tabs */}
|
||||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
||||
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].map((label, i) => (
|
||||
{['시나리오 상세', '비교 차트', '확산범위 오버레이'].map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveView(i as ViewTab)}
|
||||
@ -607,29 +607,29 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Threat Zones */}
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-label-1 font-bold mb-2.5">⚠️ 위험 구역</h4>
|
||||
<h4 className="text-label-1 font-bold mb-2.5">위험 구역</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{
|
||||
label: 'IDLH (즉시위험)',
|
||||
value: scenario.zones.idlh,
|
||||
color: 'var(--color-danger)',
|
||||
color: 'var(--color-fg)',
|
||||
},
|
||||
{
|
||||
label: 'ERPG-2 (대피권고)',
|
||||
value: scenario.zones.erpg2,
|
||||
color: 'var(--color-warning)',
|
||||
color: 'var(--fg-sub)',
|
||||
},
|
||||
{
|
||||
label: 'ERPG-1 (주의권고)',
|
||||
value: scenario.zones.erpg1,
|
||||
color: 'var(--color-caution)',
|
||||
color: 'var(--fg-sub)',
|
||||
},
|
||||
{ label: 'TWA (작업허용)', value: scenario.zones.twa, color: 'var(--color-success)' },
|
||||
{ label: 'TWA (작업허용)', value: scenario.zones.twa, color: 'var(--fg-sub)' },
|
||||
].map((z, i) => (
|
||||
<div key={i} className="flex justify-between items-center bg-bg-base rounded-sm">
|
||||
<div key={i} className="flex justify-between items-center bg-bg-base rounded-sm p-2">
|
||||
<span className="text-label-2 text-fg-sub">{z.label}</span>
|
||||
<span className="text-label-2 font-bold font-mono" style={{ color: z.color }}>
|
||||
<span className="text-label-2 font-mono" style={{ color: z.color }}>
|
||||
{z.value}
|
||||
</span>
|
||||
</div>
|
||||
@ -639,14 +639,14 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-label-1 font-bold mb-2.5">🛡 대응 권고 사항</h4>
|
||||
<h4 className="text-label-1 font-bold mb-2.5">대응 권고 사항</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{scenario.actions.map((action, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-1.5 text-label-2 text-fg-sub bg-bg-base rounded-sm leading-[1.4] py-[5px] px-2"
|
||||
className="flex items-center text-label-2 text-fg-sub bg-bg-base rounded-sm p-2"
|
||||
>
|
||||
<span className="text-color-accent font-bold shrink-0">•</span>
|
||||
{/* <span className="text-color-accent font-bold shrink-0">•</span> */}
|
||||
{action}
|
||||
</div>
|
||||
))}
|
||||
@ -656,7 +656,7 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
||||
|
||||
{/* Weather */}
|
||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
||||
<h4 className="text-label-1 font-bold mb-2.5">🌊 기상 조건</h4>
|
||||
<h4 className="text-label-1 font-bold mb-2.5">기상 조건</h4>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
{[
|
||||
{ label: '풍향', value: scenario.weather.dir, icon: '🌬' },
|
||||
|
||||
@ -3,6 +3,7 @@ import { HNSLeftPanel } from './HNSLeftPanel';
|
||||
import type { HNSInputParams } from './HNSLeftPanel';
|
||||
import { HNSRightPanel } from './HNSRightPanel';
|
||||
import { MapView } from '@common/components/map/MapView';
|
||||
import { TimelineControl } from '@common/components/map/TimelineControl';
|
||||
import { HNSAnalysisListTable } from './HNSAnalysisListTable';
|
||||
import { HNSTheoryView } from './HNSTheoryView';
|
||||
import { HNSSubstanceView } from './HNSSubstanceView';
|
||||
@ -194,70 +195,6 @@ function HNSManualViewer() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 시간 슬라이더 컴포넌트 ─── */
|
||||
function DispersionTimeSlider({
|
||||
currentFrame,
|
||||
totalFrames,
|
||||
isPlaying,
|
||||
onFrameChange,
|
||||
onPlayPause,
|
||||
dt,
|
||||
}: {
|
||||
currentFrame: number;
|
||||
totalFrames: number;
|
||||
isPlaying: boolean;
|
||||
onFrameChange: (frame: number) => void;
|
||||
onPlayPause: () => void;
|
||||
dt: number;
|
||||
}) {
|
||||
const currentTime = (currentFrame + 1) * dt;
|
||||
const endTime = totalFrames * dt;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2.5 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(10,22,40,0.92)',
|
||||
border: '1px solid rgba(6,182,212,0.25)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
minWidth: '360px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onPlayPause}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-title-3"
|
||||
style={{
|
||||
background: isPlaying ? 'rgba(59,130,246,0.2)' : 'rgba(6,182,212,0.2)',
|
||||
border: `1px solid ${isPlaying ? 'rgba(59,130,246,0.4)' : 'rgba(6,182,212,0.4)'}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-caption">
|
||||
<span className="text-color-accent font-mono font-bold">t = {currentTime}s</span>
|
||||
<span className="text-fg-disabled font-mono">{endTime}s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={totalFrames - 1}
|
||||
value={currentFrame}
|
||||
onChange={(e) => onFrameChange(parseInt(e.target.value))}
|
||||
className="w-full h-1 accent-[var(--color-accent)]"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-caption text-fg-disabled font-mono whitespace-nowrap">
|
||||
{currentFrame + 1}/{totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 메인 HNSView ─── */
|
||||
export function HNSView() {
|
||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||
@ -698,20 +635,50 @@ export function HNSView() {
|
||||
} else {
|
||||
// DB에서 조회
|
||||
const analysis = await fetchHnsAnalysis(sn);
|
||||
if (!analysis.rsltData) {
|
||||
alert('저장된 분석 결과가 없습니다.');
|
||||
return;
|
||||
if (analysis.rsltData) {
|
||||
rslt = analysis.rsltData as Record<string, unknown>;
|
||||
} else {
|
||||
// rsltData가 없으면 메타데이터로 재구성하여 실행
|
||||
const [dateStr, timeRaw] = (analysis.acdntDtm ?? '').split('T');
|
||||
const timeStr = timeRaw ? timeRaw.slice(0, 5) : '';
|
||||
const coord =
|
||||
analysis.lon != null && analysis.lat != null
|
||||
? { lon: analysis.lon, lat: analysis.lat }
|
||||
: null;
|
||||
if (!coord) {
|
||||
alert('좌표 정보가 없어 분석을 실행할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
rslt = {
|
||||
coord,
|
||||
inputParams: {
|
||||
substance: analysis.sbstNm ?? '톨루엔 (Toluene)',
|
||||
totalRelease: analysis.spilQty ?? 5000,
|
||||
algorithm: analysis.algoCd ?? 'ALOHA (EPA)',
|
||||
criteriaModel: analysis.critMdlCd ?? 'AEGL',
|
||||
accidentDate: dateStr ?? '',
|
||||
accidentTime: timeStr,
|
||||
predictionTime: analysis.fcstHr ? `${analysis.fcstHr}시간` : '24시간',
|
||||
accidentName: analysis.anlysNm,
|
||||
},
|
||||
weather: {
|
||||
windSpeed: analysis.windSpd ?? 5.0,
|
||||
windDirection: analysis.windDir ? parseFloat(analysis.windDir) : 270,
|
||||
},
|
||||
};
|
||||
}
|
||||
rslt = analysis.rsltData as Record<string, unknown>;
|
||||
}
|
||||
const savedParams = rslt.inputParams as Record<string, unknown> | undefined;
|
||||
const savedCoord = rslt.coord as { lon: number; lat: number } | undefined;
|
||||
|
||||
// 좌표 복원
|
||||
if (savedCoord) {
|
||||
setIncidentCoord(savedCoord);
|
||||
if (!savedCoord) {
|
||||
alert('좌표 정보가 없어 분석을 실행할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 좌표 복원
|
||||
setIncidentCoord(savedCoord);
|
||||
|
||||
// 입력 파라미터 복원 → HNSLeftPanel에 전달
|
||||
if (savedParams) {
|
||||
setLoadedParams({
|
||||
@ -731,86 +698,80 @@ export function HNSView() {
|
||||
});
|
||||
}
|
||||
|
||||
// 탭 전환 → analysis
|
||||
setActiveSubTab('analysis');
|
||||
// 계산 파라미터 구성 (defaults 포함)
|
||||
const savedWeather = rslt.weather as Record<string, unknown> | undefined;
|
||||
const sp = (savedParams ?? {}) as Record<string, unknown>;
|
||||
const params: HNSInputParams = {
|
||||
substance: (sp.substance as string) || '톨루엔 (Toluene)',
|
||||
releaseType: (sp.releaseType as HNSInputParams['releaseType']) || '연속 유출',
|
||||
emissionRate: (sp.emissionRate as number) || 10,
|
||||
totalRelease: (sp.totalRelease as number) || 5000,
|
||||
releaseHeight: (sp.releaseHeight as number) || 0.5,
|
||||
releaseDuration: (sp.releaseDuration as number) || 300,
|
||||
poolRadius: (sp.poolRadius as number) || 5,
|
||||
algorithm: (sp.algorithm as string) || 'ALOHA (EPA)',
|
||||
criteriaModel: (sp.criteriaModel as string) || 'AEGL',
|
||||
accidentDate: (sp.accidentDate as string) || '',
|
||||
accidentTime: (sp.accidentTime as string) || '',
|
||||
predictionTime: (sp.predictionTime as string) || '24시간',
|
||||
accidentName: (sp.accidentName as string) || '',
|
||||
weather: {
|
||||
windSpeed: (savedWeather?.windSpeed as number) ?? 5.0,
|
||||
windDirection: (savedWeather?.windDirection as number) ?? 270,
|
||||
temperature: (savedWeather?.temperature as number) ?? 15,
|
||||
humidity: (savedWeather?.humidity as number) ?? 60,
|
||||
stability: ((savedWeather?.stability as string) ??
|
||||
'D') as HNSInputParams['weather']['stability'],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
},
|
||||
};
|
||||
|
||||
// 약간의 딜레이 후 계산 실행 (loadedParams → inputParams 동기화 대기)
|
||||
setTimeout(() => {
|
||||
if (savedParams && savedCoord) {
|
||||
const savedWeather = rslt.weather as Record<string, unknown> | undefined;
|
||||
const params: HNSInputParams = {
|
||||
substance: (savedParams.substance as string) || '톨루엔 (Toluene)',
|
||||
releaseType: (savedParams.releaseType as HNSInputParams['releaseType']) || '연속 유출',
|
||||
emissionRate: (savedParams.emissionRate as number) || 10,
|
||||
totalRelease: (savedParams.totalRelease as number) || 5000,
|
||||
releaseHeight: (savedParams.releaseHeight as number) || 0.5,
|
||||
releaseDuration: (savedParams.releaseDuration as number) || 300,
|
||||
poolRadius: (savedParams.poolRadius as number) || 5,
|
||||
algorithm: (savedParams.algorithm as string) || 'ALOHA (EPA)',
|
||||
criteriaModel: (savedParams.criteriaModel as string) || 'AEGL',
|
||||
accidentDate: (savedParams.accidentDate as string) || '',
|
||||
accidentTime: (savedParams.accidentTime as string) || '',
|
||||
predictionTime: (savedParams.predictionTime as string) || '24시간',
|
||||
accidentName: (savedParams.accidentName as string) || '',
|
||||
weather: {
|
||||
windSpeed: (savedWeather?.windSpeed as number) ?? 5.0,
|
||||
windDirection: (savedWeather?.windDirection as number) ?? 270,
|
||||
temperature: (savedWeather?.temperature as number) ?? 15,
|
||||
humidity: (savedWeather?.humidity as number) ?? 60,
|
||||
stability: ((savedWeather?.stability as string) ??
|
||||
'D') as HNSInputParams['weather']['stability'],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
setInputParams(params);
|
||||
|
||||
try {
|
||||
const { tox, meteo, resultForZones, substanceName } = runComputation(params, savedCoord);
|
||||
hasRunOnce.current = true;
|
||||
|
||||
setDispersionResult({
|
||||
hnsAnlysSn: sn,
|
||||
zones: [
|
||||
{
|
||||
level: 'AEGL-3',
|
||||
color: 'rgba(59,130,246,0.4)',
|
||||
radius: resultForZones.aeglDistances.aegl3,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
};
|
||||
{
|
||||
level: 'AEGL-2',
|
||||
color: 'rgba(6,182,212,0.3)',
|
||||
radius: resultForZones.aeglDistances.aegl2,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
{
|
||||
level: 'AEGL-1',
|
||||
color: 'rgba(6,182,212,0.25)',
|
||||
radius: resultForZones.aeglDistances.aegl1,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
].filter((z: { radius: number }) => z.radius > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
windDirection: meteo.windDirDeg,
|
||||
substance: substanceName,
|
||||
concentration: {
|
||||
'AEGL-3': `${tox.aegl3} ppm`,
|
||||
'AEGL-2': `${tox.aegl2} ppm`,
|
||||
'AEGL-1': `${tox.aegl1} ppm`,
|
||||
},
|
||||
maxConcentration: resultForZones.maxConcentration,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[HNS] 분석 재계산 실패:', err);
|
||||
}
|
||||
|
||||
setInputParams(params);
|
||||
|
||||
try {
|
||||
const { tox, meteo, resultForZones, substanceName } = runComputation(
|
||||
params,
|
||||
savedCoord,
|
||||
);
|
||||
hasRunOnce.current = true;
|
||||
|
||||
setDispersionResult({
|
||||
hnsAnlysSn: sn,
|
||||
zones: [
|
||||
{
|
||||
level: 'AEGL-3',
|
||||
color: 'rgba(59,130,246,0.4)',
|
||||
radius: resultForZones.aeglDistances.aegl3,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
{
|
||||
level: 'AEGL-2',
|
||||
color: 'rgba(6,182,212,0.3)',
|
||||
radius: resultForZones.aeglDistances.aegl2,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
{
|
||||
level: 'AEGL-1',
|
||||
color: 'rgba(6,182,212,0.25)',
|
||||
radius: resultForZones.aeglDistances.aegl1,
|
||||
angle: meteo.windDirDeg,
|
||||
},
|
||||
].filter((z: { radius: number }) => z.radius > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
windDirection: meteo.windDirDeg,
|
||||
substance: substanceName,
|
||||
concentration: {
|
||||
'AEGL-3': `${tox.aegl3} ppm`,
|
||||
'AEGL-2': `${tox.aegl2} ppm`,
|
||||
'AEGL-1': `${tox.aegl1} ppm`,
|
||||
},
|
||||
maxConcentration: resultForZones.maxConcentration,
|
||||
});
|
||||
} catch {
|
||||
// 재계산 실패 시 무시
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
// 마지막에 탭 전환 — 계산 결과가 이미 준비되어 있어 맵이 즉시 시각화
|
||||
setActiveSubTab('analysis');
|
||||
} catch {
|
||||
alert('분석 불러오기에 실패했습니다.');
|
||||
}
|
||||
@ -914,7 +875,7 @@ export function HNSView() {
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
@ -935,7 +896,7 @@ export function HNSView() {
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
@ -973,13 +934,21 @@ export function HNSView() {
|
||||
/>
|
||||
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||
{allTimeFrames.length > 1 && (
|
||||
<DispersionTimeSlider
|
||||
currentFrame={currentFrame}
|
||||
totalFrames={allTimeFrames.length}
|
||||
<TimelineControl
|
||||
currentTime={currentFrame * 30}
|
||||
maxTime={(allTimeFrames.length - 1) * 30}
|
||||
isPlaying={isPuffPlaying}
|
||||
onFrameChange={handleFrameChange}
|
||||
playbackSpeed={1}
|
||||
onTimeChange={(t) => handleFrameChange(Math.round(t / 30))}
|
||||
onPlayPause={() => setIsPuffPlaying(!isPuffPlaying)}
|
||||
dt={30}
|
||||
onSpeedChange={() => {}}
|
||||
stepSize={30}
|
||||
tickInterval={60}
|
||||
majorTickEvery={300}
|
||||
timeUnitLabel="s"
|
||||
formatOffset={(t) => `+${t.toFixed(0)}s`}
|
||||
formatAbsolute={() => `${currentFrame + 1}/${allTimeFrames.length} 프레임`}
|
||||
showSpeedToggle={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -211,7 +211,7 @@ export function IncidentsLeftPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-surface border-r border-stroke overflow-hidden shrink-0 w-[360px]">
|
||||
<div className="flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden shrink-0 w-[360px]">
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||||
<div className="relative">
|
||||
|
||||
@ -641,7 +641,7 @@ export function IncidentsView() {
|
||||
{/* Left panel toggle button */}
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
@ -660,7 +660,7 @@ export function IncidentsView() {
|
||||
{/* Right panel toggle button */}
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
|
||||
@ -1222,7 +1222,7 @@ export function OilSpillView() {
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
@ -1243,7 +1243,7 @@ export function OilSpillView() {
|
||||
{activeSubTab === 'analysis' && (
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
|
||||
@ -230,7 +230,7 @@ export function PreScatView() {
|
||||
{/* Left panel toggle button */}
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
left: 0,
|
||||
width: 18,
|
||||
@ -249,7 +249,7 @@ export function PreScatView() {
|
||||
{/* Right panel toggle button */}
|
||||
<button
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
className="absolute z-[50] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||
style={{
|
||||
right: 0,
|
||||
width: 18,
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
6
scripts/generate_manual_pdfs/.claude/settings.local.json
Normal file
6
scripts/generate_manual_pdfs/.claude/settings.local.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"stitch"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
39
scripts/generate_manual_pdfs/README.md
Normal file
39
scripts/generate_manual_pdfs/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# 사용자 매뉴얼 PDF 생성 스크립트
|
||||
|
||||
`frontend/src/common/data/chapters.json` 데이터를 기반으로 챕터별 PDF를 생성하여 `frontend/public/manual/pdfs/chXX.pdf`로 저장한다.
|
||||
|
||||
## 의존성
|
||||
|
||||
### 시스템 패키지 (WSL Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b \
|
||||
libcairo2 libgdk-pixbuf-2.0-0 \
|
||||
fonts-noto-cjk fonts-noto-cjk-extra
|
||||
```
|
||||
|
||||
### Python 패키지
|
||||
|
||||
```bash
|
||||
cd scripts/generate_manual_pdfs
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
python generate.py
|
||||
```
|
||||
|
||||
## 결과
|
||||
|
||||
`frontend/public/manual/pdfs/ch01.pdf ~ ch08.pdf` 생성.
|
||||
`UserManualPopup` 의 "PDF 다운로드" 버튼이 이 파일들을 참조한다.
|
||||
|
||||
## 데이터 갱신 시
|
||||
|
||||
`frontend/src/common/data/chapters.json` 수정 후 `python generate.py` 재실행.
|
||||
73
scripts/generate_manual_pdfs/generate.py
Normal file
73
scripts/generate_manual_pdfs/generate.py
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WING-OPS 사용자 매뉴얼 챕터별 PDF 생성.
|
||||
|
||||
`frontend/src/common/data/chapters.json`을 읽어
|
||||
`frontend/public/manual/pdfs/ch01.pdf ~ ch08.pdf`를 생성한다.
|
||||
|
||||
사용법:
|
||||
pip install -r requirements.txt
|
||||
python generate.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent.parent
|
||||
DATA_FILE = REPO_ROOT / 'frontend' / 'src' / 'common' / 'data' / 'chapters.json'
|
||||
PUBLIC_DIR = REPO_ROOT / 'frontend' / 'public'
|
||||
OUT_DIR = PUBLIC_DIR / 'manual' / 'pdfs'
|
||||
|
||||
|
||||
def load_chapters() -> list[dict]:
|
||||
with DATA_FILE.open(encoding='utf-8') as fp:
|
||||
return json.load(fp)
|
||||
|
||||
|
||||
def render_pdf(chapter: dict, env: Environment) -> None:
|
||||
template = env.get_template('template.html')
|
||||
html_str = template.render(chapter=chapter)
|
||||
|
||||
# base_url: 템플릿 내부의 상대경로(manual/imageN.png, style.css)가
|
||||
# frontend/public 및 scripts/generate_manual_pdfs 양쪽을 참조하므로
|
||||
# public 을 base 로 두고 style.css 는 절대경로로 포함한다.
|
||||
style_path = SCRIPT_DIR / 'style.css'
|
||||
html_with_style = html_str.replace(
|
||||
'href="style.css"',
|
||||
f'href="file://{style_path}"',
|
||||
)
|
||||
|
||||
out_path = OUT_DIR / f'{chapter["id"]}.pdf'
|
||||
HTML(string=html_with_style, base_url=str(PUBLIC_DIR)).write_pdf(str(out_path))
|
||||
print(f' {out_path.relative_to(REPO_ROOT)} ({out_path.stat().st_size // 1024} KB)')
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not DATA_FILE.exists():
|
||||
raise SystemExit(f'chapters.json not found: {DATA_FILE}')
|
||||
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
chapters = load_chapters()
|
||||
print(f'Loaded {len(chapters)} chapters from {DATA_FILE.relative_to(REPO_ROOT)}')
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(str(SCRIPT_DIR)),
|
||||
autoescape=select_autoescape(['html']),
|
||||
)
|
||||
|
||||
for chapter in chapters:
|
||||
print(f'Rendering CH {chapter["number"]} · {chapter["title"]} ({len(chapter["screens"])} screens)')
|
||||
render_pdf(chapter, env)
|
||||
|
||||
print(f'\nDone. Output: {OUT_DIR.relative_to(REPO_ROOT)}/')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2
scripts/generate_manual_pdfs/requirements.txt
Normal file
2
scripts/generate_manual_pdfs/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
weasyprint>=62.0
|
||||
jinja2>=3.1.0
|
||||
262
scripts/generate_manual_pdfs/style.css
Normal file
262
scripts/generate_manual_pdfs/style.css
Normal file
@ -0,0 +1,262 @@
|
||||
@font-face {
|
||||
font-family: 'Pretendard GOV';
|
||||
src: url('../../frontend/public/fonts/PretendardGOV-Regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pretendard GOV';
|
||||
src: url('../../frontend/public/fonts/PretendardGOV-Medium.otf') format('opentype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pretendard GOV';
|
||||
src: url('../../frontend/public/fonts/PretendardGOV-SemiBold.otf') format('opentype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pretendard GOV';
|
||||
src: url('../../frontend/public/fonts/PretendardGOV-Bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 15mm 18mm 15mm;
|
||||
|
||||
@top-right {
|
||||
content: string(chapter-title);
|
||||
font-family: 'Pretendard GOV', sans-serif;
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@bottom-center {
|
||||
content: counter(page) ' / ' counter(pages);
|
||||
font-family: 'Pretendard GOV', sans-serif;
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@page :first {
|
||||
margin: 0;
|
||||
@top-right { content: none; }
|
||||
@bottom-center { content: none; }
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Pretendard GOV', sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Cover page ── */
|
||||
.cover {
|
||||
page: cover;
|
||||
page-break-after: always;
|
||||
height: 297mm;
|
||||
box-sizing: border-box;
|
||||
padding: 60mm 30mm;
|
||||
background: linear-gradient(135deg, #0a1628 0%, #16213e 100%);
|
||||
color: #e2e8f0;
|
||||
string-set: chapter-title attr(data-title);
|
||||
}
|
||||
|
||||
.cover .brand {
|
||||
font-size: 14pt;
|
||||
color: #06b6d4;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10mm;
|
||||
}
|
||||
|
||||
.cover .ch-num {
|
||||
display: inline-block;
|
||||
padding: 4mm 10mm;
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||
border-radius: 4mm;
|
||||
color: #06b6d4;
|
||||
font-family: 'Pretendard GOV', monospace;
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8mm;
|
||||
}
|
||||
|
||||
.cover .title {
|
||||
font-size: 32pt;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4mm 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20mm;
|
||||
}
|
||||
|
||||
.cover .meta {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.3);
|
||||
padding-top: 6mm;
|
||||
font-size: 10pt;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cover .meta .screens-count {
|
||||
color: #06b6d4;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Screen sections ── */
|
||||
.screen {
|
||||
page-break-before: always;
|
||||
string-set: chapter-title attr(data-chapter);
|
||||
}
|
||||
|
||||
.screen-header {
|
||||
border-bottom: 2px solid #06b6d4;
|
||||
padding-bottom: 3mm;
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
|
||||
.screen-index {
|
||||
display: inline-block;
|
||||
background: #06b6d4;
|
||||
color: #fff;
|
||||
font-family: 'Pretendard GOV', monospace;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
padding: 1mm 3mm;
|
||||
border-radius: 1mm;
|
||||
margin-right: 3mm;
|
||||
}
|
||||
|
||||
.screen-name {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.menu-path {
|
||||
font-size: 9.5pt;
|
||||
color: #64748b;
|
||||
margin-top: 1.5mm;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 4mm 0 6mm 0;
|
||||
text-align: center;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
padding: 3mm;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
max-width: 100%;
|
||||
max-height: 110mm;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
h3.section-label {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #06b6d4;
|
||||
margin: 5mm 0 2mm 0;
|
||||
padding-left: 2mm;
|
||||
border-left: 3px solid #06b6d4;
|
||||
}
|
||||
|
||||
p.overview,
|
||||
p.description {
|
||||
margin: 2mm 0 3mm 0;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
p.overview {
|
||||
background: #f1f5f9;
|
||||
padding: 3mm 4mm;
|
||||
border-radius: 1.5mm;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
ol.procedure,
|
||||
ul.notes {
|
||||
margin: 2mm 0 4mm 0;
|
||||
padding-left: 6mm;
|
||||
}
|
||||
|
||||
ol.procedure li,
|
||||
ul.notes li {
|
||||
margin-bottom: 1.5mm;
|
||||
}
|
||||
|
||||
ul.notes li {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
padding-left: 4mm;
|
||||
}
|
||||
|
||||
ul.notes li::before {
|
||||
content: '⚠';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
table.inputs {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2mm 0 4mm 0;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
table.inputs th,
|
||||
table.inputs td {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 1.5mm 2.5mm;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table.inputs thead th {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table.inputs td.required-yes {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.inputs td.required-no {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notes-box {
|
||||
background: #fffbeb;
|
||||
border-left: 3px solid #f59e0b;
|
||||
padding: 3mm 4mm;
|
||||
margin: 2mm 0 4mm 0;
|
||||
border-radius: 0 1.5mm 1.5mm 0;
|
||||
}
|
||||
|
||||
.notes-box h3.section-label {
|
||||
color: #b45309;
|
||||
border-left-color: #f59e0b;
|
||||
margin-top: 0;
|
||||
}
|
||||
92
scripts/generate_manual_pdfs/template.html
Normal file
92
scripts/generate_manual_pdfs/template.html
Normal file
@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ chapter.title }}</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="cover" data-title="CH {{ chapter.number }} · {{ chapter.title }}">
|
||||
<div class="brand">WING-OPS USER MANUAL</div>
|
||||
<div class="ch-num">CHAPTER {{ chapter.number }}</div>
|
||||
<h1 class="title">{{ chapter.title }}</h1>
|
||||
<div class="subtitle">{{ chapter.subtitle }}</div>
|
||||
<div class="meta">
|
||||
<span class="screens-count">{{ chapter.screens | length }}</span> 개 화면 ·
|
||||
총 {{ chapter.screens | length }} 섹션
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for screen in chapter.screens %}
|
||||
<section class="screen" data-chapter="CH {{ chapter.number }} · {{ chapter.title }}">
|
||||
<div class="screen-header">
|
||||
<div>
|
||||
<span class="screen-index">{{ screen.id }}</span>
|
||||
<span class="screen-name">{{ screen.name }}</span>
|
||||
</div>
|
||||
<div class="menu-path">📍 {{ screen.menuPath }}</div>
|
||||
</div>
|
||||
|
||||
<div class="screenshot">
|
||||
<img src="manual/image{{ screen.imageIndex }}.png" alt="{{ screen.name }}">
|
||||
</div>
|
||||
|
||||
<h3 class="section-label">개요</h3>
|
||||
<p class="overview">{{ screen.overview }}</p>
|
||||
|
||||
{% if screen.description %}
|
||||
<h3 class="section-label">화면 설명</h3>
|
||||
<p class="description">{{ screen.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if screen.procedure %}
|
||||
<h3 class="section-label">사용 절차</h3>
|
||||
<ol class="procedure">
|
||||
{% for step in screen.procedure %}
|
||||
<li>{{ step }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
|
||||
{% if screen.inputs %}
|
||||
<h3 class="section-label">입력 항목</h3>
|
||||
<table class="inputs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22%;">항목</th>
|
||||
<th style="width: 18%;">유형</th>
|
||||
<th style="width: 10%;">필수</th>
|
||||
<th>설명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inp in screen.inputs %}
|
||||
<tr>
|
||||
<td><strong>{{ inp.label }}</strong></td>
|
||||
<td>{{ inp.type }}</td>
|
||||
<td class="{% if inp.required %}required-yes{% else %}required-no{% endif %}">
|
||||
{% if inp.required %}●{% else %}○{% endif %}
|
||||
</td>
|
||||
<td>{{ inp.desc }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if screen.notes %}
|
||||
<div class="notes-box">
|
||||
<h3 class="section-label">주의사항</h3>
|
||||
<ul class="notes">
|
||||
{% for note in screen.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
불러오는 중...
Reference in New Issue
Block a user