release: 2026-04-16 (294건 커밋) #180

병합
dnlee develop 에서 main 로 14 commits 를 머지했습니다 2026-04-16 18:37:59 +09:00
25개의 변경된 파일2434개의 추가작업 그리고 1438개의 파일을 삭제
Showing only changes of commit 2082e9a79b - Show all commits

5
.gitignore vendored
파일 보기

@ -102,4 +102,7 @@ frontend/public/hns-manual/images/
# mcp
.mcp.json
.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;

파일 보기

@ -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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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,

파일 보기

@ -0,0 +1,6 @@
{
"enabledMcpjsonServers": [
"stitch"
],
"enableAllProjectMcpServers": true
}

파일 보기

@ -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` 재실행.

파일 보기

@ -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()

파일 보기

@ -0,0 +1,2 @@
weasyprint>=62.0
jinja2>=3.1.0

파일 보기

@ -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;
}

파일 보기

@ -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>