Compare commits

..

33 커밋

작성자 SHA1 메시지 날짜
b669b25f6e Merge pull request 'release: 2026-04-17 (320건 커밋)' (#190) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-17 13:42:36 +09:00
3525e22590 Merge pull request 'release: 2026-04-17 (5건 커밋)' (#183) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 19s
2026-04-17 07:23:52 +09:00
ea208cbf52 Merge pull request 'release: 2026-04-16 (294건 커밋)' (#180) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 20s
2026-04-16 18:37:58 +09:00
988cc47e9f Merge pull request 'release: 2026-04-15' (#176) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-15 14:49:19 +09:00
20d5c08bc7 Merge pull request 'release: 2026-04-14 (267건 커밋)' (#171) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-04-14 13:09:56 +09:00
ad24445101 Merge pull request 'release: 2026-04-13 (8�� Ŀ��)' (#167) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-13 16:53:17 +09:00
cc3e0c5596 Merge pull request 'release: 2026-04-09 (247건 커밋)' (#163) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
2026-04-09 14:57:13 +09:00
5b36ea3991 Merge pull request 'release: 2026-04-02 (229건 커밋)' (#159) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-02 16:54:56 +09:00
5489bb0db5 Merge pull request 'release: 2026-04-01 (229건 커밋)' (#156) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-01 19:15:00 +09:00
42d749426e Merge pull request 'release: 2026-04-01 (226�� Ŀ��)' (#154) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 09:26:16 +09:00
7a8e2ddea1 Merge pull request 'release: 2026-04-01.2 (3�� Ŀ��)' (#152) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 09:03:08 +09:00
625b15e395 Merge pull request 'release: 2026-04-01 (5�� Ŀ��)' (#150) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 08:38:15 +09:00
c40711cae1 Merge pull request 'release: 2026-03-31 (6�� Ŀ��)' (#147) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-31 18:06:09 +09:00
2c0f43962b Merge pull request 'release: 2026-03-31 (202건 커밋)' (#144) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
2026-03-31 15:13:25 +09:00
71cdc634c6 Merge pull request 'release: 2026-03-30 (202건 커밋)' (#141) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-30 15:12:52 +09:00
d71c43ae5a Merge pull request 'release: 2026-03-27.3 (5건 커밋)' (#138) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-27 17:46:46 +09:00
29477e4e2a Merge pull request 'release: 2026-03-27.2 (192�� Ŀ��)' (#135) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-27 15:40:30 +09:00
94e0837072 Merge pull request 'release: 2026-03-27 (187�� Ŀ��)' (#131) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-27 15:21:34 +09:00
ebe76176e3 Merge pull request 'release: 2026-03-26 (5건 커밋)' (#128) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-03-26 14:19:33 +09:00
bc7e966cb1 Merge pull request 'release: 2026-03-25 (177�� Ŀ��)' (#125) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-25 18:29:39 +09:00
6c68d04fc3 Merge pull request 'release: 2026-03-25 (11건 커밋)' (#122) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-25 16:11:07 +09:00
a55d3c18c2 Merge pull request 'release: 2026-03-24 (160건 커밋)' (#118) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-24 18:57:51 +09:00
84fa49189c Merge pull request 'release: 2026-03-20.2 (129건 커밋)' (#111) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-03-20 15:24:43 +09:00
5f622c7520 Merge pull request 'release: 2026-03-20 (124건 커밋)' (#108) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-20 10:38:45 +09:00
7cef385c3a Merge pull request 'release: 2026-03-19 (26건 커밋)' (#105) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-19 18:13:18 +09:00
36829b9ff4 Merge pull request 'release: 2026-03-19 (8건 커밋)' (#102) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-19 13:25:27 +09:00
16db2e1925 Merge pull request 'release: 2026-03-18 (92�� Ŀ��)' (#99) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m27s
2026-03-18 18:18:29 +09:00
6b9ed4e06e Merge pull request 'release: 2026-03-17 (3건 커밋)' (#96) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m19s
2026-03-17 18:42:00 +09:00
99c2e8d6ae Merge pull request 'release: 2026-03-16 (81건 커밋)' (#93) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 44s
2026-03-16 18:35:59 +09:00
3ad24a6e1a Merge pull request 'release: 2026-03-13 (51건 커밋)' (#90) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-13 14:58:34 +09:00
714bac9f24 Merge pull request 'release: 2026-03-11.2 (12건 커밋)' (#85) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-03-11 18:37:35 +09:00
ecca827098 Merge pull request 'release: 2026-03-11 (13건 커밋)' (#82) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-11 12:55:33 +09:00
dc4be29cfc Merge pull request 'develop' (#71) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 45s
Reviewed-on: #71
2026-03-06 07:38:45 +09:00
20개의 변경된 파일305개의 추가작업 그리고 555개의 파일을 삭제

파일 보기

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

파일 보기

@ -1,6 +1,6 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-04-20", "applied_date": "2026-04-17",
"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,6 +3,7 @@
# 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")

1
.gitignore vendored
파일 보기

@ -106,7 +106,6 @@ backend/scripts/hns-import/out/
# mcp # mcp
.mcp.json .mcp.json
.playwright-mcp/
# python # python
.venv .venv

파일 보기

@ -14,23 +14,13 @@ 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 page = parseInt(req.query.page as string, 10) || 1 const items = await listAnalyses({
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(result) res.json(items)
} 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,8 +120,6 @@ 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 {
@ -149,12 +147,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
} }
} }
export async function listAnalyses(input: ListAnalysesInput): Promise<{ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
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
@ -177,36 +170,18 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<{
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 ${whereClause} WHERE ${conditions.join(' AND ')}
ORDER BY ACDNT_DTM DESC NULLS LAST ORDER BY ACDNT_DTM DESC NULLS LAST`,
LIMIT $${idx++} OFFSET $${idx}`, params
[...params, limit, offset]
) )
return { return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
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> {

파일 보기

@ -1,10 +0,0 @@
-- 033: SPIL_QTY 정수부 확대 — HNS 대용량 유출량 지원
-- 031에서 NUMERIC(14,10)으로 변경된 결과 정수부가 4자리(|x| < 10^4)로 좁아져
-- HNS 기본 유출량(5000~20000 g) 입력 시 22003 오버플로우 발생.
-- 소수 10자리는 유지하여 이미지 분석 1e-7 정밀도와 호환 유지.
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
ALTER TABLE wing.HNS_ANALYSIS ALTER COLUMN SPIL_QTY TYPE NUMERIC(22,10);
COMMENT ON COLUMN wing.SPIL_DATA.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';
COMMENT ON COLUMN wing.HNS_ANALYSIS.SPIL_QTY IS '유출량 (정수 12자리 + 소수 10자리, |x| < 10^12)';

파일 보기

@ -4,20 +4,9 @@
## [Unreleased] ## [Unreleased]
### 추가
- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어)
- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달
### 변경
- InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer)
### 기타
- DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원)
## [2026-04-17] ## [2026-04-17]
### 추가 ### 추가
- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트) - HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
### 변경 ### 변경

파일 보기

@ -1,176 +0,0 @@
import { LayerTree } from '@components/common/layer/LayerTree';
import { useLayerTree } from '@common/hooks/useLayers';
import type { Layer } from '@common/services/layerService';
interface InfoLayerSectionProps {
expanded: boolean;
onToggle: () => void;
enabledLayers: Set<string>;
onToggleLayer: (layerId: string, enabled: boolean) => void;
layerOpacity: number;
onLayerOpacityChange: (val: number) => void;
layerBrightness: number;
onLayerBrightnessChange: (val: number) => void;
layerColors: Record<string, string>;
onLayerColorChange: (layerId: string, color: string) => void;
}
const InfoLayerSection = ({
expanded,
onToggle,
enabledLayers,
onToggleLayer,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
layerColors,
onLayerColorChange,
}: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
const { data: layerTree, isLoading } = useLayerTree();
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
const effectiveLayers: Layer[] = layerTree ?? [];
return (
<div className="border-b border-stroke">
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
<h3
onClick={onToggle}
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
>
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={(e) => {
e.stopPropagation();
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = [];
layers?.forEach((layer) => {
ids.push(layer.id);
if (layer.children) {
ids.push(...getAllLayerIds(layer.children));
}
});
return ids;
};
const allIds = getAllLayerIds(effectiveLayers);
allIds.forEach((id) => onToggleLayer(id, true));
}}
style={{
padding: '4px 8px',
fontSize: 'var(--font-size-label-2)',
fontWeight: 500,
border: '1px solid var(--stroke-default)',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
color: 'var(--fg-sub)',
cursor: 'pointer',
transition: '0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-surface-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = [];
layers?.forEach((layer) => {
ids.push(layer.id);
if (layer.children) {
ids.push(...getAllLayerIds(layer.children));
}
});
return ids;
};
const allIds = getAllLayerIds(effectiveLayers);
allIds.forEach((id) => onToggleLayer(id, false));
}}
style={{
padding: '4px 8px',
fontSize: 'var(--font-size-label-2)',
fontWeight: 500,
border: '1px solid var(--stroke-default)',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
color: 'var(--fg-sub)',
cursor: 'pointer',
transition: '0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-surface-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
</button>
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
{expanded ? '▼' : '▶'}
</span>
</div>
</div>
{expanded && (
<div className="px-4 pb-2">
{isLoading && effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-default py-2"> ...</p>
) : effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-default py-2"> .</p>
) : (
<LayerTree
layers={effectiveLayers}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onLayerColorChange}
/>
)}
{/* 레이어 스타일 조절 */}
<div className="lyr-style-box">
<div className="lyr-style-label"> </div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0}
max={100}
value={layerOpacity}
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerOpacity}%</span>
</div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0}
max={100}
value={layerBrightness}
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerBrightness}%</span>
</div>
</div>
</div>
)}
</div>
);
};
export default InfoLayerSection;

파일 보기

@ -233,10 +233,12 @@ function MapCenterTracker({
}; };
update(); update();
map.on('moveend', update); map.on('move', update);
map.on('zoom', update);
return () => { return () => {
map.off('moveend', update); map.off('move', update);
map.off('zoom', update);
}; };
}, [map, onCenterChange]); }, [map, onCenterChange]);
@ -505,87 +507,6 @@ 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[] => {
@ -885,18 +806,97 @@ export function MapView({
} }
} }
// --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) --- // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
if (heatmapImage) { 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),
);
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( result.push(
new BitmapLayer({ new BitmapLayer({
id: 'hns-dispersion-bitmap', id: 'hns-dispersion-bitmap',
image: heatmapImage.imageUrl, image: imageUrl,
bounds: heatmapImage.bounds, bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0, opacity: 1.0,
pickable: false, pickable: false,
}) as unknown as DeckLayer, }) as unknown as DeckLayer,
); );
} }
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) { if (dispersionResult && incidentCoord) {
@ -1300,7 +1300,7 @@ export function MapView({
isDrawingBoom, isDrawingBoom,
drawingPoints, drawingPoints,
dispersionResult, dispersionResult,
heatmapImage, dispersionHeatmap,
incidentCoord, incidentCoord,
backtrackReplay, backtrackReplay,
sensitiveResources, sensitiveResources,

파일 보기

@ -8,8 +8,6 @@ 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: '위험',
@ -41,17 +39,12 @@ 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 res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE }); const items = await fetchHnsAnalyses();
setAnalyses(res.items); setAnalyses(items);
setTotal(res.total);
} catch (err) { } catch (err) {
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err); console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
// DB 실패 시 localStorage에서 불러오기 // DB 실패 시 localStorage에서 불러오기
@ -100,7 +93,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page]); }, []);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -112,7 +105,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"> {total}</p> <p className="text-title-3 text-fg-disabled mt-1"> {analyses.length}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
@ -306,66 +299,6 @@ 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>
); );
} }

파일 보기

@ -8,7 +8,6 @@ import type {
import type { ReleaseType } from '@/types/hns/HnsType'; import type { ReleaseType } from '@/types/hns/HnsType';
import { fetchGscAccidents } from '@components/prediction/services/predictionApi'; import { fetchGscAccidents } from '@components/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface'; import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface';
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
interface HNSLeftPanelProps { interface HNSLeftPanelProps {
activeSubTab: 'analysis' | 'list'; activeSubTab: 'analysis' | 'list';
@ -22,14 +21,6 @@ interface HNSLeftPanelProps {
onReset?: () => void; onReset?: () => void;
loadedParams?: Partial<HNSInputParams> | null; loadedParams?: Partial<HNSInputParams> | null;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void; onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
enabledLayers: Set<string>;
onToggleLayer: (layerId: string, enabled: boolean) => void;
layerOpacity: number;
onLayerOpacityChange: (val: number) => void;
layerBrightness: number;
onLayerBrightnessChange: (val: number) => void;
layerColors: Record<string, string>;
onLayerColorChange: (layerId: string, color: string) => void;
} }
/** 십진 좌표 → 도분초 변환 */ /** 십진 좌표 → 도분초 변환 */
@ -54,20 +45,12 @@ export function HNSLeftPanel({
onReset, onReset,
loadedParams, loadedParams,
onFlyToCoord, onFlyToCoord,
enabledLayers,
onToggleLayer,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
layerColors,
onLayerColorChange,
}: HNSLeftPanelProps) { }: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]); const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined); const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined);
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false }); const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
const toggleSection = (key: 'accident' | 'params' | 'infoLayer') => const toggleSection = (key: 'accident' | 'params') =>
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
const [accidentName, setAccidentName] = useState(''); const [accidentName, setAccidentName] = useState('');
@ -708,19 +691,6 @@ export function HNSLeftPanel({
)} )}
</div> </div>
<InfoLayerSection
expanded={expandedSections.infoLayer}
onToggle={() => toggleSection('infoLayer')}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerOpacity={layerOpacity}
onLayerOpacityChange={onLayerOpacityChange}
layerBrightness={layerBrightness}
onLayerBrightnessChange={onLayerBrightnessChange}
layerColors={layerColors}
onLayerColorChange={onLayerColorChange}
/>
{/* 실행 버튼 */} {/* 실행 버튼 */}
<div className="flex flex-col gap-1 px-4 py-3"> <div className="flex flex-col gap-1 px-4 py-3">
<button <button

파일 보기

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

파일 보기

@ -78,10 +78,6 @@ export function HNSView() {
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds); const vessels = useVesselSignals(mapBounds);
const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
const [layerOpacity, setLayerOpacity] = useState(50);
const [layerBrightness, setLayerBrightness] = useState(50);
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [dispersionResult, setDispersionResult] = useState<any>(null); const [dispersionResult, setDispersionResult] = useState<any>(null);
const [recalcModalOpen, setRecalcModalOpen] = useState(false); const [recalcModalOpen, setRecalcModalOpen] = useState(false);
@ -111,18 +107,6 @@ export function HNSView() {
hasRunOnce.current = false; hasRunOnce.current = false;
}, []); }, []);
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers((prev) => {
const next = new Set(prev);
if (enabled) next.add(layerId);
else next.delete(layerId);
return next;
});
};
const handleLayerColorChange = (layerId: string, color: string) =>
setLayerColors((prev) => ({ ...prev, [layerId]: color }));
const handleParamsChange = useCallback((params: HNSInputParams) => { const handleParamsChange = useCallback((params: HNSInputParams) => {
setInputParams(params); setInputParams(params);
}, []); }, []);
@ -357,10 +341,7 @@ export function HNSView() {
params?.accidentDate && params?.accidentTime params?.accidentDate && params?.accidentTime
? `${params.accidentDate}T${params.accidentTime}:00` ? `${params.accidentDate}T${params.accidentTime}:00`
: params?.accidentDate || undefined; : params?.accidentDate || undefined;
const fcstHrNum = parseInt(params?.predictionTime ?? '') || 24; const result = await createHnsAnalysis({
const spilQtyVal =
params?.releaseType === '순간 유출' ? params?.totalRelease : params?.emissionRate;
const created = await createHnsAnalysis({
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`, anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
acdntSn: params?.selectedAcdntSn, acdntSn: params?.selectedAcdntSn,
acdntDtm, acdntDtm,
@ -368,9 +349,6 @@ export function HNSView() {
lat: incidentCoord.lat, lat: incidentCoord.lat,
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`, locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
sbstNm: params?.substance, sbstNm: params?.substance,
spilQty: spilQtyVal,
spilUnitCd: params?.releaseType === '순간 유출' ? 'g' : 'g/s',
fcstHr: fcstHrNum,
windSpd: params?.weather?.windSpeed, windSpd: params?.weather?.windSpeed,
windDir: windDir:
params?.weather?.windDirection != null params?.weather?.windDirection != null
@ -379,66 +357,14 @@ export function HNSView() {
temp: params?.weather?.temperature, temp: params?.weather?.temperature,
humid: params?.weather?.humidity, humid: params?.weather?.humidity,
atmStblCd: params?.weather?.stability, atmStblCd: params?.weather?.stability,
algoCd: params?.algorithm,
critMdlCd: params?.criteriaModel,
analystNm: user?.name || undefined, analystNm: user?.name || undefined,
}); });
// DB 저장 성공 시 SN 업데이트
// 실행 결과를 즉시 DB에 저장하여 목록에 바로 반영
const runZones = [
{ level: 'AEGL-3', color: '#ef4444', radius: resultForZones.aeglDistances.aegl3, angle: meteo.windDirDeg },
{ level: 'AEGL-2', color: '#f97316', radius: resultForZones.aeglDistances.aegl2, angle: meteo.windDirDeg },
{ level: 'AEGL-1', color: '#eab308', radius: resultForZones.aeglDistances.aegl1, angle: meteo.windDirDeg },
].filter((z) => z.radius > 0);
const runRsltData: Record<string, unknown> = {
inputParams: {
substance: params?.substance,
releaseType: params?.releaseType,
emissionRate: params?.emissionRate,
totalRelease: params?.totalRelease,
releaseHeight: params?.releaseHeight,
releaseDuration: params?.releaseDuration,
poolRadius: params?.poolRadius,
algorithm: params?.algorithm,
criteriaModel: params?.criteriaModel,
accidentDate: params?.accidentDate,
accidentTime: params?.accidentTime,
predictionTime: params?.predictionTime,
accidentName: params?.accidentName,
},
coord: { lon: incidentCoord.lon, lat: incidentCoord.lat },
zones: runZones,
aeglDistances: resultForZones.aeglDistances,
aeglAreas: resultForZones.aeglAreas,
maxConcentration: resultForZones.maxConcentration,
modelType: resultForZones.modelType,
weather: {
windSpeed: params?.weather?.windSpeed,
windDirection: params?.weather?.windDirection,
temperature: params?.weather?.temperature,
humidity: params?.weather?.humidity,
stability: params?.weather?.stability,
},
aegl3: (resultForZones.aeglDistances?.aegl3 ?? 0) > 0,
aegl2: (resultForZones.aeglDistances?.aegl2 ?? 0) > 0,
aegl1: (resultForZones.aeglDistances?.aegl1 ?? 0) > 0,
damageRadius: `${((resultForZones.aeglDistances?.aegl1 ?? 0) / 1000).toFixed(1)} km`,
};
let runRiskCd = 'LOW';
if ((resultForZones.aeglDistances?.aegl3 ?? 0) > 0) runRiskCd = 'CRITICAL';
else if ((resultForZones.aeglDistances?.aegl2 ?? 0) > 0) runRiskCd = 'HIGH';
else if ((resultForZones.aeglDistances?.aegl1 ?? 0) > 0) runRiskCd = 'MEDIUM';
// 생성 성공 즉시 SN 기록 — saveHnsAnalysis 실패 시에도 중복 생성 방지
setDispersionResult((prev: Record<string, unknown> | null) => setDispersionResult((prev: Record<string, unknown> | null) =>
prev ? { ...prev, hnsAnlysSn: created.hnsAnlysSn } : prev, prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev,
); );
await saveHnsAnalysis(created.hnsAnlysSn, { } catch {
rsltData: runRsltData, // API 실패 시 무시 (히트맵은 이미 표시됨)
execSttsCd: 'COMPLETED',
riskCd: runRiskCd,
});
} catch (err) {
console.error('[HNS] 분석 DB 저장 실패 (히트맵은 유지됨):', err);
} }
} catch (error) { } catch (error) {
console.error('대기확산 예측 오류:', error); console.error('대기확산 예측 오류:', error);
@ -834,14 +760,6 @@ export function HNSView() {
onReset={handleReset} onReset={handleReset}
loadedParams={loadedParams} loadedParams={loadedParams}
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })} onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
enabledLayers={enabledLayers}
onToggleLayer={handleToggleLayer}
layerOpacity={layerOpacity}
onLayerOpacityChange={setLayerOpacity}
layerBrightness={layerBrightness}
onLayerBrightnessChange={setLayerBrightness}
layerColors={layerColors}
onLayerColorChange={handleLayerColorChange}
/> />
</div> </div>
)} )}
@ -907,10 +825,7 @@ export function HNSView() {
isSelectingLocation={isSelectingLocation} isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={[]} oilTrajectory={[]}
enabledLayers={enabledLayers} enabledLayers={new Set()}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
layerColors={layerColors}
dispersionResult={dispersionResult} dispersionResult={dispersionResult}
dispersionHeatmap={heatmapData} dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef} mapCaptureRef={mapCaptureRef}

파일 보기

@ -6,22 +6,13 @@ 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;
page?: number; }): Promise<HnsAnalysisItem[]> {
limit?: number; const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
}): 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 res = await fetchHnsAnalyses(); const items = await fetchHnsAnalyses();
setHnsItems(res.items); setHnsItems(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((res) => { .then((items) => {
setHnsItems(res.items); setHnsItems(items);
const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn))); const allIds = new Set(items.map((i) => String(i.hnsAnlysSn)));
setCheckedHnsIds(allIds); setCheckedHnsIds(allIds);
onCheckedHnsChange?.( onCheckedHnsChange?.(
res.items.map((h) => ({ 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((res) => setHnsAnalyses(res.items)) .then((items) => setHnsAnalyses(items))
.catch(() => {}); .catch(() => {});
}, [selectedIncidentId]); }, [selectedIncidentId]);

파일 보기

@ -1 +1,176 @@
export { default } from '@components/common/layer/InfoLayerSection'; import { LayerTree } from '@components/common/layer/LayerTree';
import { useLayerTree } from '@common/hooks/useLayers';
import type { Layer } from '@common/services/layerService';
interface InfoLayerSectionProps {
expanded: boolean;
onToggle: () => void;
enabledLayers: Set<string>;
onToggleLayer: (layerId: string, enabled: boolean) => void;
layerOpacity: number;
onLayerOpacityChange: (val: number) => void;
layerBrightness: number;
onLayerBrightnessChange: (val: number) => void;
layerColors: Record<string, string>;
onLayerColorChange: (layerId: string, color: string) => void;
}
const InfoLayerSection = ({
expanded,
onToggle,
enabledLayers,
onToggleLayer,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
layerColors,
onLayerColorChange,
}: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
const { data: layerTree, isLoading } = useLayerTree();
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
const effectiveLayers: Layer[] = layerTree ?? [];
return (
<div className="border-b border-stroke">
<div className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]">
<h3
onClick={onToggle}
className="text-title-4 font-bold text-fg-default font-korean cursor-pointer"
>
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={(e) => {
e.stopPropagation();
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = [];
layers?.forEach((layer) => {
ids.push(layer.id);
if (layer.children) {
ids.push(...getAllLayerIds(layer.children));
}
});
return ids;
};
const allIds = getAllLayerIds(effectiveLayers);
allIds.forEach((id) => onToggleLayer(id, true));
}}
style={{
padding: '4px 8px',
fontSize: 'var(--font-size-label-2)',
fontWeight: 500,
border: '1px solid var(--stroke-default)',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
color: 'var(--fg-sub)',
cursor: 'pointer',
transition: '0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-surface-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = [];
layers?.forEach((layer) => {
ids.push(layer.id);
if (layer.children) {
ids.push(...getAllLayerIds(layer.children));
}
});
return ids;
};
const allIds = getAllLayerIds(effectiveLayers);
allIds.forEach((id) => onToggleLayer(id, false));
}}
style={{
padding: '4px 8px',
fontSize: 'var(--font-size-label-2)',
fontWeight: 500,
border: '1px solid var(--stroke-default)',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
color: 'var(--fg-sub)',
cursor: 'pointer',
transition: '0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-surface-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
</button>
<span onClick={onToggle} className="text-label-2 text-fg-default cursor-pointer">
{expanded ? '▼' : '▶'}
</span>
</div>
</div>
{expanded && (
<div className="px-4 pb-2">
{isLoading && effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-default py-2"> ...</p>
) : effectiveLayers.length === 0 ? (
<p className="text-label-2 text-fg-default py-2"> .</p>
) : (
<LayerTree
layers={effectiveLayers}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={onLayerColorChange}
/>
)}
{/* 레이어 스타일 조절 */}
<div className="lyr-style-box">
<div className="lyr-style-label"> </div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0}
max={100}
value={layerOpacity}
onChange={(e) => onLayerOpacityChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerOpacity}%</span>
</div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0}
max={100}
value={layerBrightness}
onChange={(e) => onLayerBrightnessChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerBrightness}%</span>
</div>
</div>
</div>
)}
</div>
);
};
export default InfoLayerSection;

파일 보기

@ -59,7 +59,7 @@ const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' }; const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
import PredictionInputSection from './PredictionInputSection'; import PredictionInputSection from './PredictionInputSection';
import InfoLayerSection from '@components/common/layer/InfoLayerSection'; import InfoLayerSection from './InfoLayerSection';
import OilBoomSection from './OilBoomSection'; import OilBoomSection from './OilBoomSection';
export type { LeftPanelProps }; export type { LeftPanelProps };