Compare commits

..

3 커밋

13개의 변경된 파일233개의 추가작업 그리고 120개의 파일을 삭제

파일 보기

@ -87,5 +87,7 @@
}, },
"enabledPlugins": { "enabledPlugins": {
"frontend-design@claude-plugins-official": true "frontend-design@claude-plugins-official": true
} },
"deny": [],
"allow": []
} }

파일 보기

@ -1,7 +1,7 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-04-17", "applied_date": "2026-04-20",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true
} }

파일 보기

@ -3,7 +3,6 @@
# commit-msg hook # commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원) # Conventional Commits 형식 검증 (한/영 혼용 지원)
#============================================================================== #==============================================================================
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
COMMIT_MSG_FILE="$1" COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

파일 보기

@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re
try { try {
const { status, substance, search, acdntSn } = req.query const { status, substance, search, acdntSn } = req.query
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
const items = await listAnalyses({ const page = parseInt(req.query.page as string, 10) || 1
const limit = parseInt(req.query.limit as string, 10) || 10
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
res.status(400).json({ error: '유효하지 않은 페이지네이션 파라미터' })
return
}
const result = await listAnalyses({
status: status as string | undefined, status: status as string | undefined,
substance: substance as string | undefined, substance: substance as string | undefined,
search: search as string | undefined, search: search as string | undefined,
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
page,
limit,
}) })
res.json(items) res.json(result)
} catch (err) { } catch (err) {
console.error('[hns] 분석 목록 오류:', err) console.error('[hns] 분석 목록 오류:', err)
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' }) res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })

파일 보기

@ -120,6 +120,8 @@ interface ListAnalysesInput {
substance?: string substance?: string
search?: string search?: string
acdntSn?: number acdntSn?: number
page?: number
limit?: number
} }
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem { function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
@ -147,7 +149,12 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
} }
} }
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> { export async function listAnalyses(input: ListAnalysesInput): Promise<{
items: HnsAnalysisItem[]
total: number
page: number
limit: number
}> {
const conditions: string[] = ["USE_YN = 'Y'"] const conditions: string[] = ["USE_YN = 'Y'"]
const params: (string | number)[] = [] const params: (string | number)[] = []
let idx = 1 let idx = 1
@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
params.push(input.acdntSn) params.push(input.acdntSn)
} }
const whereClause = conditions.join(' AND ')
const countResult = await wingPool.query(
`SELECT COUNT(*) AS cnt FROM HNS_ANALYSIS WHERE ${whereClause}`,
params
)
const total = parseInt(countResult.rows[0].cnt as string, 10)
const page = input.page ?? 1
const limit = input.limit ?? 10
const offset = (page - 1) * limit
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM, WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM RSLT_DATA, REG_DTM
FROM HNS_ANALYSIS FROM HNS_ANALYSIS
WHERE ${conditions.join(' AND ')} WHERE ${whereClause}
ORDER BY ACDNT_DTM DESC NULLS LAST`, ORDER BY ACDNT_DTM DESC NULLS LAST
params LIMIT $${idx++} OFFSET $${idx}`,
[...params, limit, offset]
) )
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r)) return {
items: rows.map((r: Record<string, unknown>) => rowToAnalysis(r)),
total,
page,
limit,
}
} }
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> { export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {

파일 보기

@ -7,6 +7,7 @@
## [2026-04-17] ## [2026-04-17]
### 추가 ### 추가
- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트) - HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
### 변경 ### 변경

파일 보기

@ -233,12 +233,10 @@ function MapCenterTracker({
}; };
update(); update();
map.on('move', update); map.on('moveend', update);
map.on('zoom', update);
return () => { return () => {
map.off('move', update); map.off('moveend', update);
map.off('zoom', update);
}; };
}, [map, onCenterChange]); }, [map, onCenterChange]);
@ -507,6 +505,87 @@ export function MapView({
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0; const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
const wmsOpacity = layerOpacity / 100; const wmsOpacity = layerOpacity / 100;
// HNS 대기확산 히트맵 이미지 (Canvas 렌더링 + PNG 변환은 고비용이므로 별도 메모이제이션)
const heatmapImage = useMemo(() => {
if (!dispersionHeatmap || dispersionHeatmap.length === 0) return null;
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
const minConc = Math.min(
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
);
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
if (filtered.length === 0) return null;
// 경위도 바운드 계산
let minLon = Infinity,
maxLon = -Infinity,
minLat = Infinity,
maxLat = -Infinity;
for (const p of dispersionHeatmap) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
const padLon = (maxLon - minLon) * 0.02;
const padLat = (maxLat - minLat) * 0.02;
minLon -= padLon;
maxLon += padLon;
minLat -= padLat;
maxLat += padLat;
// 캔버스에 농도 이미지 렌더링
const W = 1200,
H = 960;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, W, H);
// 로그 스케일: 농도 범위를 고르게 분포
const logMin = Math.log(minConc);
const logMax = Math.log(maxConc);
const logRange = logMax - logMin || 1;
const stops: [number, number, number, number][] = [
[34, 197, 94, 220], // green (저농도)
[234, 179, 8, 235], // yellow
[249, 115, 22, 245], // orange
[239, 68, 68, 250], // red (고농도)
[185, 28, 28, 255], // dark red (초고농도)
];
for (const p of filtered) {
// 로그 스케일 정규화 (0~1)
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
const t = ratio * (stops.length - 1);
const lo = Math.floor(t);
const hi = Math.min(lo + 1, stops.length - 1);
const f = t - lo;
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fill();
}
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
const imageUrl = canvas.toDataURL('image/png');
return {
imageUrl,
bounds: [minLon, minLat, maxLon, maxLat] as [number, number, number, number],
};
}, [dispersionHeatmap]);
// deck.gl 레이어 구축 // deck.gl 레이어 구축
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers = useMemo((): any[] => { const deckLayers = useMemo((): any[] => {
@ -806,96 +885,17 @@ export function MapView({
} }
} }
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- // --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) ---
if (dispersionHeatmap && dispersionHeatmap.length > 0) { if (heatmapImage) {
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); result.push(
const minConc = Math.min( new BitmapLayer({
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), id: 'hns-dispersion-bitmap',
image: heatmapImage.imageUrl,
bounds: heatmapImage.bounds,
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
); );
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
console.log(
'[MapView] HNS 히트맵:',
dispersionHeatmap.length,
'→ filtered:',
filtered.length,
'maxConc:',
maxConc.toFixed(2),
);
if (filtered.length > 0) {
// 경위도 바운드 계산
let minLon = Infinity,
maxLon = -Infinity,
minLat = Infinity,
maxLat = -Infinity;
for (const p of dispersionHeatmap) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
const padLon = (maxLon - minLon) * 0.02;
const padLat = (maxLat - minLat) * 0.02;
minLon -= padLon;
maxLon += padLon;
minLat -= padLat;
maxLat += padLat;
// 캔버스에 농도 이미지 렌더링
const W = 1200,
H = 960;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, W, H);
// 로그 스케일: 농도 범위를 고르게 분포
const logMin = Math.log(minConc);
const logMax = Math.log(maxConc);
const logRange = logMax - logMin || 1;
const stops: [number, number, number, number][] = [
[34, 197, 94, 220], // green (저농도)
[234, 179, 8, 235], // yellow
[249, 115, 22, 245], // orange
[239, 68, 68, 250], // red (고농도)
[185, 28, 28, 255], // dark red (초고농도)
];
for (const p of filtered) {
// 로그 스케일 정규화 (0~1)
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
const t = ratio * (stops.length - 1);
const lo = Math.floor(t);
const hi = Math.min(lo + 1, stops.length - 1);
const f = t - lo;
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(px, py, 12, 0, Math.PI * 2);
ctx.fill();
}
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
const imageUrl = canvas.toDataURL('image/png');
result.push(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: imageUrl,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
);
}
} }
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
@ -1300,7 +1300,7 @@ export function MapView({
isDrawingBoom, isDrawingBoom,
drawingPoints, drawingPoints,
dispersionResult, dispersionResult,
dispersionHeatmap, heatmapImage,
incidentCoord, incidentCoord,
backtrackReplay, backtrackReplay,
sensitiveResources, sensitiveResources,

파일 보기

@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps {
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void; onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
} }
const PAGE_SIZE = 10;
const RISK_LABEL: Record<string, string> = { const RISK_LABEL: Record<string, string> = {
CRITICAL: '심각', CRITICAL: '심각',
HIGH: '위험', HIGH: '위험',
@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string {
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) { export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]); const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const items = await fetchHnsAnalyses(); const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE });
setAnalyses(items); setAnalyses(res.items);
setTotal(res.total);
} catch (err) { } catch (err) {
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err); console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
// DB 실패 시 localStorage에서 불러오기 // DB 실패 시 localStorage에서 불러오기
@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [page]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke"> <div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
<div> <div>
<h1 className="text-heading-3 font-bold text-fg">HNS </h1> <h1 className="text-heading-3 font-bold text-fg">HNS </h1>
<p className="text-title-3 text-fg-disabled mt-1"> {analyses.length}</p> <p className="text-title-3 text-fg-disabled mt-1"> {total}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
</div> </div>
)} )}
</div> </div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 px-5 py-3 border-t border-stroke">
<button
disabled={page <= 1}
onClick={() => setPage(1)}
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
>
«
</button>
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
.reduce<number[]>((acc, p) => {
if (acc.length > 0 && p - acc[acc.length - 1] > 1) acc.push(-acc.length);
acc.push(p);
return acc;
}, [])
.map((p) =>
p < 0 ? (
<span key={`ellipsis-${p}`} className="px-1.5 text-fg-disabled text-label-1">
</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={`min-w-[32px] px-2.5 py-1.5 text-label-1 rounded-sm ${
p === page
? 'bg-color-accent text-white font-semibold'
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
}`}
>
{p}
</button>
),
)}
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(totalPages)}
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
>
»
</button>
</div>
)}
</div> </div>
); );
} }

파일 보기

@ -288,8 +288,8 @@ export function HNSScenarioView() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetchHnsAnalyses() fetchHnsAnalyses()
.then((items) => { .then((res) => {
if (!cancelled) setIncidents(items); if (!cancelled) setIncidents(res.items);
}) })
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err)); .catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
return () => { return () => {

파일 보기

@ -6,13 +6,22 @@ import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/Hn
// ============================================================ // ============================================================
interface HnsAnalysesResponse {
items: HnsAnalysisItem[];
total: number;
page: number;
limit: number;
}
export async function fetchHnsAnalyses(params?: { export async function fetchHnsAnalyses(params?: {
status?: string; status?: string;
substance?: string; substance?: string;
search?: string; search?: string;
acdntSn?: number; acdntSn?: number;
}): Promise<HnsAnalysisItem[]> { page?: number;
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params }); limit?: number;
}): Promise<HnsAnalysesResponse> {
const response = await api.get<HnsAnalysesResponse>('/hns/analyses', { params });
return response.data; return response.data;
} }

파일 보기

@ -156,8 +156,8 @@ export function AnalysisSelectModal({
const items = await fetchPredictionAnalyses(); const items = await fetchPredictionAnalyses();
setPredItems(items); setPredItems(items);
} else if (type === 'hns') { } else if (type === 'hns') {
const items = await fetchHnsAnalyses(); const res = await fetchHnsAnalyses();
setHnsItems(items); setHnsItems(res.items);
} else { } else {
const items = await fetchRescueOps(); const items = await fetchRescueOps();
setRescueItems(items); setRescueItems(items);

파일 보기

@ -175,12 +175,12 @@ export function IncidentsRightPanel({
}) })
.catch(() => setPredItems([])); .catch(() => setPredItems([]));
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
.then((items) => { .then((res) => {
setHnsItems(items); setHnsItems(res.items);
const allIds = new Set(items.map((i) => String(i.hnsAnlysSn))); const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn)));
setCheckedHnsIds(allIds); setCheckedHnsIds(allIds);
onCheckedHnsChange?.( onCheckedHnsChange?.(
items.map((h) => ({ res.items.map((h) => ({
id: String(h.hnsAnlysSn), id: String(h.hnsAnlysSn),
hnsAnlysSn: h.hnsAnlysSn, hnsAnlysSn: h.hnsAnlysSn,
acdntSn: h.acdntSn, acdntSn: h.acdntSn,

파일 보기

@ -221,7 +221,7 @@ export function IncidentsView() {
const acdntSn = parseInt(selectedIncidentId, 10); const acdntSn = parseInt(selectedIncidentId, 10);
if (Number.isNaN(acdntSn)) return; if (Number.isNaN(acdntSn)) return;
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
.then((items) => setHnsAnalyses(items)) .then((res) => setHnsAnalyses(res.items))
.catch(() => {}); .catch(() => {});
}, [selectedIncidentId]); }, [selectedIncidentId]);