Compare commits
33 커밋
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b669b25f6e | |||
| 3525e22590 | |||
| ea208cbf52 | |||
| 988cc47e9f | |||
| 20d5c08bc7 | |||
| ad24445101 | |||
| cc3e0c5596 | |||
| 5b36ea3991 | |||
| 5489bb0db5 | |||
| 42d749426e | |||
| 7a8e2ddea1 | |||
| 625b15e395 | |||
| c40711cae1 | |||
| 2c0f43962b | |||
| 71cdc634c6 | |||
| d71c43ae5a | |||
| 29477e4e2a | |||
| 94e0837072 | |||
| ebe76176e3 | |||
| bc7e966cb1 | |||
| 6c68d04fc3 | |||
| a55d3c18c2 | |||
| 84fa49189c | |||
| 5f622c7520 | |||
| 7cef385c3a | |||
| 36829b9ff4 | |||
| 16db2e1925 | |||
| 6b9ed4e06e | |||
| 99c2e8d6ae | |||
| 3ad24a6e1a | |||
| 714bac9f24 | |||
| ecca827098 | |||
| dc4be29cfc |
@ -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
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';
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user