refactor(map): TimelineControl 분리 및 aerial/hns 컴포넌트 개선
This commit is contained in:
부모
388116aa88
커밋
2082e9a79b
5
.gitignore
vendored
5
.gitignore
vendored
@ -102,4 +102,7 @@ frontend/public/hns-manual/images/
|
|||||||
|
|
||||||
|
|
||||||
# mcp
|
# mcp
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# python
|
||||||
|
.venv
|
||||||
@ -107,6 +107,8 @@ wing/
|
|||||||
- `naming.md` -- 네이밍 규칙
|
- `naming.md` -- 네이밍 규칙
|
||||||
- `testing.md` -- 테스트 규칙
|
- `testing.md` -- 테스트 규칙
|
||||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||||
|
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
||||||
|
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
||||||
|
|
||||||
## 개발 문서 (docs/)
|
## 개발 문서 (docs/)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
listMedia,
|
listMedia,
|
||||||
createMedia,
|
createMedia,
|
||||||
@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
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 라우트
|
// 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 — 원본 이미지 다운로드
|
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
|||||||
// OIL INFERENCE (GPU 서버 프록시)
|
// 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 OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||||
const INFERENCE_TIMEOUT_MS = 10_000;
|
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import type {
|
|||||||
SensitiveResourceFeatureCollection,
|
SensitiveResourceFeatureCollection,
|
||||||
} from '@tabs/prediction/services/predictionApi';
|
} from '@tabs/prediction/services/predictionApi';
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay';
|
import HydrParticleOverlay from './HydrParticleOverlay';
|
||||||
|
import { TimelineControl } from './TimelineControl';
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine';
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine';
|
||||||
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack';
|
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack';
|
||||||
import { createBacktrackLayers } from './BacktrackReplayOverlay';
|
import { createBacktrackLayers } from './BacktrackReplayOverlay';
|
||||||
@ -1417,6 +1418,7 @@ export function MapView({
|
|||||||
onTimeChange={setInternalCurrentTime}
|
onTimeChange={setInternalCurrentTime}
|
||||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||||
onSpeedChange={setPlaybackSpeed}
|
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
|
// 기상 데이터 Mock
|
||||||
function getWeatherData(position: [number, number]) {
|
function getWeatherData(position: [number, number]) {
|
||||||
const [lat, lng] = position;
|
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 { 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 type { AerialMediaItem } from '../services/aerialApi';
|
||||||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||||||
|
|
||||||
@ -54,7 +59,16 @@ export function MediaManagement() {
|
|||||||
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
|
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [previewItem, setPreviewItem] = useState<AerialMediaItem | null>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -82,6 +96,15 @@ export function MediaManagement() {
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [showUpload]);
|
}, [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) => {
|
const filtered = mediaItems.filter((f) => {
|
||||||
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false;
|
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false;
|
||||||
if (typeFilter.size > 0) {
|
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 droneCount = mediaItems.filter((f) => f.equipTpCd === 'drone').length;
|
||||||
const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length;
|
const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length;
|
||||||
const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length;
|
const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length;
|
||||||
@ -224,6 +279,12 @@ export function MediaManagement() {
|
|||||||
<option value="name">이름순</option>
|
<option value="name">이름순</option>
|
||||||
<option value="size">크기순</option>
|
<option value="size">크기순</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -266,7 +327,7 @@ export function MediaManagement() {
|
|||||||
|
|
||||||
{/* File Table */}
|
{/* File Table */}
|
||||||
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
|
<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' }}>
|
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: 36 }} />
|
<col style={{ width: 36 }} />
|
||||||
@ -332,56 +393,74 @@ export function MediaManagement() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sorted.map((f) => (
|
sorted.map((f) => {
|
||||||
<tr
|
const isPhoto = f.mediaTpCd !== '영상';
|
||||||
key={f.aerialMediaSn}
|
return (
|
||||||
onClick={() => toggleId(f.aerialMediaSn)}
|
<tr
|
||||||
className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
key={f.aerialMediaSn}
|
||||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
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" onClick={(e) => e.stopPropagation()}>
|
>
|
||||||
<input
|
<td
|
||||||
type="checkbox"
|
className="px-2 py-2 text-center cursor-pointer"
|
||||||
checked={selectedIds.has(f.aerialMediaSn)}
|
onClick={() => toggleId(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"
|
|
||||||
>
|
>
|
||||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
<input
|
||||||
</button>
|
type="checkbox"
|
||||||
</td>
|
checked={selectedIds.has(f.aerialMediaSn)}
|
||||||
</tr>
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -398,20 +477,20 @@ export function MediaManagement() {
|
|||||||
onClick={toggleAll}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDownload}
|
onClick={handleBulkDownload}
|
||||||
disabled={bulkDownloading || selectedIds.size === 0}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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">
|
<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
|
<button
|
||||||
onClick={() => setShowUpload(false)}
|
onClick={() => {
|
||||||
|
setShowUpload(false);
|
||||||
|
setUploadFile(null);
|
||||||
|
}}
|
||||||
className="text-fg-disabled text-lg hover:text-fg"
|
className="text-fg-disabled text-lg hover:text-fg"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
||||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
onDragOver={(e) => {
|
||||||
<div className="text-title-4 font-semibold mb-1 font-korean">
|
e.preventDefault();
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
setDragOver(true);
|
||||||
</div>
|
}}
|
||||||
<div className="text-label-2 text-fg-disabled font-korean">
|
onDragLeave={() => setDragOver(false)}
|
||||||
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
onDrop={handleFileDrop}
|
||||||
</div>
|
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>
|
||||||
<div className="mb-3">
|
{/* <div className="mb-3">
|
||||||
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
촬영 장비
|
촬영 장비
|
||||||
</label>
|
</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 M300 RTK)</option>
|
||||||
<option>드론 (DJI Mavic 3E)</option>
|
<option>드론 (DJI Mavic 3E)</option>
|
||||||
<option>유인항공기 (CN-235)</option>
|
<option>유인항공기 (CN-235)</option>
|
||||||
@ -505,21 +634,88 @@ export function MediaManagement() {
|
|||||||
<textarea
|
<textarea
|
||||||
className="prd-i w-full h-[60px] resize-y"
|
className="prd-i w-full h-[60px] resize-y"
|
||||||
placeholder="촬영 조건, 비고 등..."
|
placeholder="촬영 조건, 비고 등..."
|
||||||
|
value={uploadMemo}
|
||||||
|
onChange={(e) => setUploadMemo(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
<button
|
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.15)',
|
background: 'rgba(6,182,212,0.15)',
|
||||||
border: '1px solid rgba(6,182,212,0.3)',
|
border: '1px solid rgba(6,182,212,0.3)',
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📤 업로드 실행
|
{uploading ? '업로드 중...' : '업로드 실행'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,7 +239,7 @@ export function OilAreaAnalysis() {
|
|||||||
<div className="flex gap-5 h-full overflow-hidden">
|
<div className="flex gap-5 h-full overflow-hidden">
|
||||||
{/* ── Left Panel ── */}
|
{/* ── Left Panel ── */}
|
||||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
<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 className="text-label-2 text-fg-disabled mb-4 font-korean">
|
||||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||||
</div>
|
</div>
|
||||||
@ -396,12 +396,12 @@ export function OilAreaAnalysis() {
|
|||||||
: { background: 'var(--bg-3)' }
|
: { background: 'var(--bg-3)' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
|
{isAnalyzing ? '⏳ 분석 중...' : '분석 시작'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right Panel ── */}
|
{/* ── 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 이미지 그리드 */}
|
{/* 3×2 이미지 그리드 */}
|
||||||
<div className="text-label-2 font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
<div className="text-label-2 font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
<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="text-label-2 font-bold mb-2 font-korean">합성 결과</div>
|
||||||
<div
|
<div
|
||||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center shrink-0"
|
||||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
style={{ minHeight: '400px' }}
|
||||||
>
|
>
|
||||||
{stitchedPreviewUrl ? (
|
{stitchedPreviewUrl ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@ -102,6 +102,10 @@ export async function createSatRequest(
|
|||||||
return response.data;
|
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> {
|
export async function downloadAerialMedia(sn: number, fileName: string): Promise<void> {
|
||||||
const res = await api.get(`/aerial/media/${sn}/download`, { responseType: 'blob' });
|
const res = await api.get(`/aerial/media/${sn}/download`, { responseType: 'blob' });
|
||||||
const url = URL.createObjectURL(res.data as 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);
|
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을 반환한다.
|
* 여러 이미지 파일을 /aerial/stitch 엔드포인트로 전송해 합성 JPEG Blob을 반환한다.
|
||||||
* FastAPI /stitch → pic_gps.py 스티칭 파이프라인 프록시.
|
* FastAPI /stitch → pic_gps.py 스티칭 파이프라인 프록시.
|
||||||
|
|||||||
@ -50,8 +50,8 @@ interface HnsMaterial {
|
|||||||
|
|
||||||
const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
||||||
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||||
HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)' },
|
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||||
MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' },
|
MEDIUM: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
||||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
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">
|
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveView(1)}
|
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>
|
||||||
<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 className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
||||||
📄 보고서
|
보고서
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -509,7 +509,7 @@ export function HNSScenarioView() {
|
|||||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||||
{/* View Tabs */}
|
{/* View Tabs */}
|
||||||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
||||||
{['📋 시나리오 상세', '📊 비교 차트', '🗺 확산범위 오버레이'].map((label, i) => (
|
{['시나리오 상세', '비교 차트', '확산범위 오버레이'].map((label, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setActiveView(i as ViewTab)}
|
onClick={() => setActiveView(i as ViewTab)}
|
||||||
@ -607,29 +607,29 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Threat Zones */}
|
{/* Threat Zones */}
|
||||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
<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">
|
<div className="flex flex-col gap-1.5">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
label: 'IDLH (즉시위험)',
|
label: 'IDLH (즉시위험)',
|
||||||
value: scenario.zones.idlh,
|
value: scenario.zones.idlh,
|
||||||
color: 'var(--color-danger)',
|
color: 'var(--color-fg)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'ERPG-2 (대피권고)',
|
label: 'ERPG-2 (대피권고)',
|
||||||
value: scenario.zones.erpg2,
|
value: scenario.zones.erpg2,
|
||||||
color: 'var(--color-warning)',
|
color: 'var(--fg-sub)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'ERPG-1 (주의권고)',
|
label: 'ERPG-1 (주의권고)',
|
||||||
value: scenario.zones.erpg1,
|
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) => (
|
].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 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}
|
{z.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -639,14 +639,14 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
<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">
|
<div className="flex flex-col gap-1.5">
|
||||||
{scenario.actions.map((action, i) => (
|
{scenario.actions.map((action, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -656,7 +656,7 @@ function ScenarioDetail({ scenario }: { scenario: HnsScenario }) {
|
|||||||
|
|
||||||
{/* Weather */}
|
{/* Weather */}
|
||||||
<div className="rounded-md border border-stroke bg-bg-elevated p-[14px]">
|
<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)' }}>
|
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||||
{[
|
{[
|
||||||
{ label: '풍향', value: scenario.weather.dir, icon: '🌬' },
|
{ label: '풍향', value: scenario.weather.dir, icon: '🌬' },
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { HNSLeftPanel } from './HNSLeftPanel';
|
|||||||
import type { HNSInputParams } from './HNSLeftPanel';
|
import type { HNSInputParams } from './HNSLeftPanel';
|
||||||
import { HNSRightPanel } from './HNSRightPanel';
|
import { HNSRightPanel } from './HNSRightPanel';
|
||||||
import { MapView } from '@common/components/map/MapView';
|
import { MapView } from '@common/components/map/MapView';
|
||||||
|
import { TimelineControl } from '@common/components/map/TimelineControl';
|
||||||
import { HNSAnalysisListTable } from './HNSAnalysisListTable';
|
import { HNSAnalysisListTable } from './HNSAnalysisListTable';
|
||||||
import { HNSTheoryView } from './HNSTheoryView';
|
import { HNSTheoryView } from './HNSTheoryView';
|
||||||
import { HNSSubstanceView } from './HNSSubstanceView';
|
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 ─── */
|
/* ─── 메인 HNSView ─── */
|
||||||
export function HNSView() {
|
export function HNSView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||||
@ -698,20 +635,50 @@ export function HNSView() {
|
|||||||
} else {
|
} else {
|
||||||
// DB에서 조회
|
// DB에서 조회
|
||||||
const analysis = await fetchHnsAnalysis(sn);
|
const analysis = await fetchHnsAnalysis(sn);
|
||||||
if (!analysis.rsltData) {
|
if (analysis.rsltData) {
|
||||||
alert('저장된 분석 결과가 없습니다.');
|
rslt = analysis.rsltData as Record<string, unknown>;
|
||||||
return;
|
} 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 savedParams = rslt.inputParams as Record<string, unknown> | undefined;
|
||||||
const savedCoord = rslt.coord as { lon: number; lat: number } | undefined;
|
const savedCoord = rslt.coord as { lon: number; lat: number } | undefined;
|
||||||
|
|
||||||
// 좌표 복원
|
if (!savedCoord) {
|
||||||
if (savedCoord) {
|
alert('좌표 정보가 없어 분석을 실행할 수 없습니다.');
|
||||||
setIncidentCoord(savedCoord);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 좌표 복원
|
||||||
|
setIncidentCoord(savedCoord);
|
||||||
|
|
||||||
// 입력 파라미터 복원 → HNSLeftPanel에 전달
|
// 입력 파라미터 복원 → HNSLeftPanel에 전달
|
||||||
if (savedParams) {
|
if (savedParams) {
|
||||||
setLoadedParams({
|
setLoadedParams({
|
||||||
@ -731,86 +698,80 @@ export function HNSView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 탭 전환 → analysis
|
// 계산 파라미터 구성 (defaults 포함)
|
||||||
setActiveSubTab('analysis');
|
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 동기화 대기)
|
setInputParams(params);
|
||||||
setTimeout(() => {
|
|
||||||
if (savedParams && savedCoord) {
|
try {
|
||||||
const savedWeather = rslt.weather as Record<string, unknown> | undefined;
|
const { tox, meteo, resultForZones, substanceName } = runComputation(params, savedCoord);
|
||||||
const params: HNSInputParams = {
|
hasRunOnce.current = true;
|
||||||
substance: (savedParams.substance as string) || '톨루엔 (Toluene)',
|
|
||||||
releaseType: (savedParams.releaseType as HNSInputParams['releaseType']) || '연속 유출',
|
setDispersionResult({
|
||||||
emissionRate: (savedParams.emissionRate as number) || 10,
|
hnsAnlysSn: sn,
|
||||||
totalRelease: (savedParams.totalRelease as number) || 5000,
|
zones: [
|
||||||
releaseHeight: (savedParams.releaseHeight as number) || 0.5,
|
{
|
||||||
releaseDuration: (savedParams.releaseDuration as number) || 300,
|
level: 'AEGL-3',
|
||||||
poolRadius: (savedParams.poolRadius as number) || 5,
|
color: 'rgba(59,130,246,0.4)',
|
||||||
algorithm: (savedParams.algorithm as string) || 'ALOHA (EPA)',
|
radius: resultForZones.aeglDistances.aegl3,
|
||||||
criteriaModel: (savedParams.criteriaModel as string) || 'AEGL',
|
angle: meteo.windDirDeg,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
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);
|
// 마지막에 탭 전환 — 계산 결과가 이미 준비되어 있어 맵이 즉시 시각화
|
||||||
|
setActiveSubTab('analysis');
|
||||||
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);
|
|
||||||
} catch {
|
} catch {
|
||||||
alert('분석 불러오기에 실패했습니다.');
|
alert('분석 불러오기에 실패했습니다.');
|
||||||
}
|
}
|
||||||
@ -914,7 +875,7 @@ export function HNSView() {
|
|||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -935,7 +896,7 @@ export function HNSView() {
|
|||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
right: 0,
|
right: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -973,13 +934,21 @@ export function HNSView() {
|
|||||||
/>
|
/>
|
||||||
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}
|
||||||
{allTimeFrames.length > 1 && (
|
{allTimeFrames.length > 1 && (
|
||||||
<DispersionTimeSlider
|
<TimelineControl
|
||||||
currentFrame={currentFrame}
|
currentTime={currentFrame * 30}
|
||||||
totalFrames={allTimeFrames.length}
|
maxTime={(allTimeFrames.length - 1) * 30}
|
||||||
isPlaying={isPuffPlaying}
|
isPlaying={isPuffPlaying}
|
||||||
onFrameChange={handleFrameChange}
|
playbackSpeed={1}
|
||||||
|
onTimeChange={(t) => handleFrameChange(Math.round(t / 30))}
|
||||||
onPlayPause={() => setIsPuffPlaying(!isPuffPlaying)}
|
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 (
|
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 */}
|
{/* Search */}
|
||||||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@ -641,7 +641,7 @@ export function IncidentsView() {
|
|||||||
{/* Left panel toggle button */}
|
{/* Left panel toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -660,7 +660,7 @@ export function IncidentsView() {
|
|||||||
{/* Right panel toggle button */}
|
{/* Right panel toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
right: 0,
|
right: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
|
|||||||
@ -1222,7 +1222,7 @@ export function OilSpillView() {
|
|||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -1243,7 +1243,7 @@ export function OilSpillView() {
|
|||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
right: 0,
|
right: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
|
|||||||
@ -230,7 +230,7 @@ export function PreScatView() {
|
|||||||
{/* Left panel toggle button */}
|
{/* Left panel toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setLeftCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -249,7 +249,7 @@ export function PreScatView() {
|
|||||||
{/* Right panel toggle button */}
|
{/* Right panel toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightCollapsed((v) => !v)}
|
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={{
|
style={{
|
||||||
right: 0,
|
right: 0,
|
||||||
width: 18,
|
width: 18,
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"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