diff --git a/.claude/settings.json b/.claude/settings.json index 6c2b037..caf8b0f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -87,5 +87,7 @@ }, "enabledPlugins": { "frontend-design@claude-plugins-official": true - } + }, + "deny": [], + "allow": [] } \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 2019a88..9d8304f 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-04-17", + "applied_date": "2026-04-20", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/.githooks/commit-msg b/.githooks/commit-msg index b020ed5..93bb350 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -3,7 +3,6 @@ # commit-msg hook # 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=$(cat "$COMMIT_MSG_FILE") diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 7985d72..ec4542d 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re try { const { status, substance, search, acdntSn } = req.query 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, substance: substance as string | undefined, search: search as string | undefined, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, + page, + limit, }) - res.json(items) + res.json(result) } catch (err) { console.error('[hns] 분석 목록 오류:', err) res.status(500).json({ error: 'HNS 분석 목록 조회 실패' }) diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index ff10c2e..70b75d3 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -120,6 +120,8 @@ interface ListAnalysesInput { substance?: string search?: string acdntSn?: number + page?: number + limit?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { @@ -147,7 +149,12 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { } } -export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function listAnalyses(input: ListAnalysesInput): Promise<{ + items: HnsAnalysisItem[] + total: number + page: number + limit: number +}> { const conditions: string[] = ["USE_YN = 'Y'"] const params: (string | number)[] = [] let idx = 1 @@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise) => rowToAnalysis(r)) + return { + items: rows.map((r: Record) => rowToAnalysis(r)), + total, + page, + limit, + } } export async function getAnalysis(sn: number): Promise { diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 4bf4279..046f11c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -7,6 +7,7 @@ ## [2026-04-17] ### 추가 +- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선 - HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트) ### 변경 diff --git a/frontend/src/components/common/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx index 97d4530..4ca7b8a 100644 --- a/frontend/src/components/common/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -233,12 +233,10 @@ function MapCenterTracker({ }; update(); - map.on('move', update); - map.on('zoom', update); + map.on('moveend', update); return () => { - map.off('move', update); - map.off('zoom', update); + map.off('moveend', update); }; }, [map, onCenterChange]); @@ -507,6 +505,87 @@ export function MapView({ const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0; 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 레이어 구축 // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers = useMemo((): any[] => { @@ -806,96 +885,17 @@ export function MapView({ } } - // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- - if (dispersionHeatmap && dispersionHeatmap.length > 0) { - const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); - const minConc = Math.min( - ...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), + // --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) --- + if (heatmapImage) { + result.push( + new BitmapLayer({ + 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 단위) --- @@ -1300,7 +1300,7 @@ export function MapView({ isDrawingBoom, drawingPoints, dispersionResult, - dispersionHeatmap, + heatmapImage, incidentCoord, backtrackReplay, sensitiveResources, diff --git a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx index 2952be4..b6277fd 100644 --- a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx +++ b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx @@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps { onSelectAnalysis?: (sn: number, localRsltData?: Record) => void; } +const PAGE_SIZE = 10; + const RISK_LABEL: Record = { CRITICAL: '심각', HIGH: '위험', @@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string { export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) { const [analyses, setAnalyses] = useState([]); 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 () => { setLoading(true); try { - const items = await fetchHnsAnalyses(); - setAnalyses(items); + const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE }); + setAnalyses(res.items); + setTotal(res.total); } catch (err) { console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err); // DB 실패 시 localStorage에서 불러오기 @@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly } finally { setLoading(false); } - }, []); + }, [page]); useEffect(() => { loadData(); @@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly

HNS 대기확산 분석 목록

-

총 {analyses.length}건

+

총 {total}건

@@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2) + .reduce((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 ? ( + + … + + ) : ( + + ), + )} + + +
+ )}
); } diff --git a/frontend/src/components/hns/components/HNSScenarioView.tsx b/frontend/src/components/hns/components/HNSScenarioView.tsx index 6862c79..e945745 100644 --- a/frontend/src/components/hns/components/HNSScenarioView.tsx +++ b/frontend/src/components/hns/components/HNSScenarioView.tsx @@ -288,8 +288,8 @@ export function HNSScenarioView() { useEffect(() => { let cancelled = false; fetchHnsAnalyses() - .then((items) => { - if (!cancelled) setIncidents(items); + .then((res) => { + if (!cancelled) setIncidents(res.items); }) .catch((err) => console.error('[hns] 사고 목록 조회 실패:', err)); return () => { diff --git a/frontend/src/components/hns/services/hnsApi.ts b/frontend/src/components/hns/services/hnsApi.ts index f626dec..c8afa5e 100644 --- a/frontend/src/components/hns/services/hnsApi.ts +++ b/frontend/src/components/hns/services/hnsApi.ts @@ -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?: { status?: string; substance?: string; search?: string; acdntSn?: number; -}): Promise { - const response = await api.get('/hns/analyses', { params }); + page?: number; + limit?: number; +}): Promise { + const response = await api.get('/hns/analyses', { params }); return response.data; } diff --git a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx index a228f82..9e9b43a 100644 --- a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx +++ b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx @@ -156,8 +156,8 @@ export function AnalysisSelectModal({ const items = await fetchPredictionAnalyses(); setPredItems(items); } else if (type === 'hns') { - const items = await fetchHnsAnalyses(); - setHnsItems(items); + const res = await fetchHnsAnalyses(); + setHnsItems(res.items); } else { const items = await fetchRescueOps(); setRescueItems(items); diff --git a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx index e46cc9a..9ade69b 100644 --- a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx @@ -175,12 +175,12 @@ export function IncidentsRightPanel({ }) .catch(() => setPredItems([])); fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => { - setHnsItems(items); - const allIds = new Set(items.map((i) => String(i.hnsAnlysSn))); + .then((res) => { + setHnsItems(res.items); + const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn))); setCheckedHnsIds(allIds); onCheckedHnsChange?.( - items.map((h) => ({ + res.items.map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn, diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index c8806ef..4a5e4c7 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -221,7 +221,7 @@ export function IncidentsView() { const acdntSn = parseInt(selectedIncidentId, 10); if (Number.isNaN(acdntSn)) return; fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => setHnsAnalyses(items)) + .then((res) => setHnsAnalyses(res.items)) .catch(() => {}); }, [selectedIncidentId]);