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
24개의 변경된 파일367개의 추가작업 그리고 614개의 파일을 삭제

파일 보기

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

파일 보기

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

파일 보기

@ -3,6 +3,7 @@
# 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")

1
.gitignore vendored
파일 보기

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

파일 보기

@ -19,7 +19,7 @@
```bash
cd frontend && npm install # Frontend
npm run dev # Vite dev (localhost:5174)
npm run dev # Vite dev (localhost:5173)
npm run build # tsc -b && vite build
npm run lint # ESLint

파일 보기

@ -14,23 +14,13 @@ 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 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({
const items = 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(result)
res.json(items)
} catch (err) {
console.error('[hns] 분석 목록 오류:', err)
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })

파일 보기

@ -120,8 +120,6 @@ interface ListAnalysesInput {
substance?: string
search?: string
acdntSn?: number
page?: number
limit?: number
}
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<{
items: HnsAnalysisItem[]
total: number
page: number
limit: number
}> {
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"]
const params: (string | number)[] = []
let idx = 1
@ -177,36 +170,18 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<{
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(
`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,
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM
FROM HNS_ANALYSIS
WHERE ${whereClause}
ORDER BY ACDNT_DTM DESC NULLS LAST
LIMIT $${idx++} OFFSET $${idx}`,
[...params, limit, offset]
WHERE ${conditions.join(' AND ')}
ORDER BY ACDNT_DTM DESC NULLS LAST`,
params
)
return {
items: rows.map((r: Record<string, unknown>) => rowToAnalysis(r)),
total,
page,
limit,
}
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
}
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,24 +4,9 @@
## [Unreleased]
### 추가
- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어)
- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달
### 변경
- InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer)
- 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정
### 수정
- 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리
### 기타
- DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원)
## [2026-04-17]
### 추가
- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선
- 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;

파일 보기

@ -139,8 +139,8 @@ function MapOverlayControls({
return (
<>
{/* 측 컨트롤 컬럼 */}
<div className="absolute top-[10px] right-[10px] z-10 flex flex-col gap-1">
{/* 측 컨트롤 컬럼 */}
<div className="absolute top-[80px] left-[10px] z-10 flex flex-col gap-1">
{/* 줌 */}
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
+

파일 보기

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

파일 보기

@ -8,8 +8,6 @@ interface HNSAnalysisListTableProps {
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
}
const PAGE_SIZE = 10;
const RISK_LABEL: Record<string, string> = {
CRITICAL: '심각',
HIGH: '위험',
@ -41,17 +39,12 @@ function substanceTag(sbstNm: string | null): string {
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
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 res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE });
setAnalyses(res.items);
setTotal(res.total);
const items = await fetchHnsAnalyses();
setAnalyses(items);
} catch (err) {
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
// DB 실패 시 localStorage에서 불러오기
@ -100,7 +93,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
} finally {
setLoading(false);
}
}, [page]);
}, []);
useEffect(() => {
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>
<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 className="flex items-center gap-3">
<div className="relative">
@ -306,66 +299,6 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
</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>
);
}

파일 보기

@ -8,7 +8,6 @@ import type {
import type { ReleaseType } from '@/types/hns/HnsType';
import { fetchGscAccidents } from '@components/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@interfaces/prediction/PredictionInterface';
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
interface HNSLeftPanelProps {
activeSubTab: 'analysis' | 'list';
@ -22,14 +21,6 @@ interface HNSLeftPanelProps {
onReset?: () => void;
loadedParams?: Partial<HNSInputParams> | null;
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,
loadedParams,
onFlyToCoord,
enabledLayers,
onToggleLayer,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
layerColors,
onLayerColorChange,
}: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
const [selectedAcdntSn, setSelectedAcdntSn] = useState<number | undefined>(undefined);
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false });
const toggleSection = (key: 'accident' | 'params' | 'infoLayer') =>
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
const toggleSection = (key: 'accident' | 'params') =>
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));
const [accidentName, setAccidentName] = useState('');
@ -708,19 +691,6 @@ export function HNSLeftPanel({
)}
</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">
<button

파일 보기

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

파일 보기

@ -78,10 +78,6 @@ export function HNSView() {
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
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
const [dispersionResult, setDispersionResult] = useState<any>(null);
const [recalcModalOpen, setRecalcModalOpen] = useState(false);
@ -111,18 +107,6 @@ export function HNSView() {
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) => {
setInputParams(params);
}, []);
@ -357,10 +341,7 @@ export function HNSView() {
params?.accidentDate && params?.accidentTime
? `${params.accidentDate}T${params.accidentTime}:00`
: params?.accidentDate || undefined;
const fcstHrNum = parseInt(params?.predictionTime ?? '') || 24;
const spilQtyVal =
params?.releaseType === '순간 유출' ? params?.totalRelease : params?.emissionRate;
const created = await createHnsAnalysis({
const result = await createHnsAnalysis({
anlysNm: params?.accidentName || `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
acdntSn: params?.selectedAcdntSn,
acdntDtm,
@ -368,9 +349,6 @@ export function HNSView() {
lat: incidentCoord.lat,
locNm: `${incidentCoord.lat.toFixed(4)} / ${incidentCoord.lon.toFixed(4)}`,
sbstNm: params?.substance,
spilQty: spilQtyVal,
spilUnitCd: params?.releaseType === '순간 유출' ? 'g' : 'g/s',
fcstHr: fcstHrNum,
windSpd: params?.weather?.windSpeed,
windDir:
params?.weather?.windDirection != null
@ -379,66 +357,14 @@ export function HNSView() {
temp: params?.weather?.temperature,
humid: params?.weather?.humidity,
atmStblCd: params?.weather?.stability,
algoCd: params?.algorithm,
critMdlCd: params?.criteriaModel,
analystNm: user?.name || undefined,
});
// 실행 결과를 즉시 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 실패 시에도 중복 생성 방지
// DB 저장 성공 시 SN 업데이트
setDispersionResult((prev: Record<string, unknown> | null) =>
prev ? { ...prev, hnsAnlysSn: created.hnsAnlysSn } : prev,
prev ? { ...prev, hnsAnlysSn: result.hnsAnlysSn } : prev,
);
await saveHnsAnalysis(created.hnsAnlysSn, {
rsltData: runRsltData,
execSttsCd: 'COMPLETED',
riskCd: runRiskCd,
});
} catch (err) {
console.error('[HNS] 분석 DB 저장 실패 (히트맵은 유지됨):', err);
} catch {
// API 실패 시 무시 (히트맵은 이미 표시됨)
}
} catch (error) {
console.error('대기확산 예측 오류:', error);
@ -834,14 +760,6 @@ export function HNSView() {
onReset={handleReset}
loadedParams={loadedParams}
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>
)}
@ -907,10 +825,7 @@ export function HNSView() {
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}
enabledLayers={enabledLayers}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
layerColors={layerColors}
enabledLayers={new Set()}
dispersionResult={dispersionResult}
dispersionHeatmap={heatmapData}
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?: {
status?: string;
substance?: string;
search?: string;
acdntSn?: number;
page?: number;
limit?: number;
}): Promise<HnsAnalysesResponse> {
const response = await api.get<HnsAnalysesResponse>('/hns/analyses', { params });
}): Promise<HnsAnalysisItem[]> {
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
return response.data;
}

파일 보기

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

파일 보기

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

파일 보기

@ -221,7 +221,7 @@ export function IncidentsView() {
const acdntSn = parseInt(selectedIncidentId, 10);
if (Number.isNaN(acdntSn)) return;
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
.then((res) => setHnsAnalyses(res.items))
.then((items) => setHnsAnalyses(items))
.catch(() => {});
}, [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)' };
import PredictionInputSection from './PredictionInputSection';
import InfoLayerSection from '@components/common/layer/InfoLayerSection';
import InfoLayerSection from './InfoLayerSection';
import OilBoomSection from './OilBoomSection';
export type { LeftPanelProps };

파일 보기

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { getRecentObservation, OBS_STATION_CODES, API_KEY } from '../services/khoaApi';
import { getRecentObservation, OBS_STATION_CODES } from '../services/khoaApi';
interface WeatherStation {
id: string;
@ -68,21 +68,21 @@ export function useWeatherData(stations: WeatherStation[]) {
setLoading(true);
setError(null);
if (!API_KEY) {
console.warn('KHOA API 키 미설정 — fallback 데이터를 사용합니다.');
if (isMounted) {
setWeatherStations(stations.map(generateFallbackStation));
setLastUpdate(new Date());
setLoading(false);
}
return;
const enrichedStations: EnrichedWeatherStation[] = [];
let apiFailed = false;
for (const station of stations) {
if (apiFailed) {
enrichedStations.push(generateFallbackStation(station));
continue;
}
const enrichedStations = await Promise.all(
stations.map(async (station): Promise<EnrichedWeatherStation> => {
try {
const obsCode = OBS_STATION_CODES[station.id];
if (!obsCode) return generateFallbackStation(station);
if (!obsCode) {
enrichedStations.push(generateFallbackStation(station));
continue;
}
const obs = await getRecentObservation(obsCode);
@ -93,7 +93,7 @@ export function useWeatherData(stations: WeatherStation[]) {
const waterTemp = r(obs.water_temp ?? 8.0);
const airPres = Math.round(obs.air_pres ?? 1016);
return {
enrichedStations.push({
...station,
wind: {
speed: windSpeed,
@ -111,21 +111,28 @@ export function useWeatherData(stations: WeatherStation[]) {
},
pressure: airPres,
visibility: airPres > 1010 ? 15 + Math.floor(Math.random() * 5) : 10,
};
});
} else {
enrichedStations.push(generateFallbackStation(station));
}
return generateFallbackStation(station);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (stationError) {
console.warn(`관측소 ${station.id} fallback 처리:`, stationError);
return generateFallbackStation(station);
if (!apiFailed) {
console.warn('KHOA API 연결 실패, fallback 데이터를 사용합니다:', stationError);
apiFailed = true;
}
enrichedStations.push(generateFallbackStation(station));
}
}
})
);
if (isMounted) {
setWeatherStations(enrichedStations);
setLastUpdate(new Date());
setLoading(false);
if (apiFailed) {
setError('KHOA API 연결 실패 — fallback 데이터 사용 중');
}
}
} catch (err) {
console.error('기상 데이터 가져오기 오류:', err);

파일 보기

@ -1,7 +1,7 @@
// KHOA (국립해양조사원) API 서비스
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
export const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '';
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '';
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService';
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService';