From 8906ed06802fdd0f3fee16e971b4c9e2f6bfe29e Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 09:11:08 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(hns):=20HNS=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=ED=99=95=EC=82=B0=20=ED=9E=88=ED=8A=B8=EB=A7=B5=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 4 +- .claude/workflow-version.json | 4 +- .githooks/commit-msg | 1 - backend/src/hns/hnsRouter.ts | 14 +- backend/src/hns/hnsService.ts | 35 +++- .../src/components/common/map/MapView.tsx | 188 +++++++++--------- .../hns/components/HNSAnalysisListTable.tsx | 75 ++++++- .../hns/components/HNSScenarioView.tsx | 4 +- .../src/components/hns/services/hnsApi.ts | 13 +- .../components/AnalysisSelectModal.tsx | 4 +- .../components/IncidentsRightPanel.tsx | 8 +- .../incidents/components/IncidentsView.tsx | 2 +- 12 files changed, 232 insertions(+), 120 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 6c2b037..caf8b0f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -87,5 +87,7 @@ }, "enabledPlugins": { "frontend-design@claude-plugins-official": true - } + }, + "deny": [], + "allow": [] } \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 2019a88..9d8304f 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-04-17", + "applied_date": "2026-04-20", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/.githooks/commit-msg b/.githooks/commit-msg index b020ed5..93bb350 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -3,7 +3,6 @@ # commit-msg hook # Conventional Commits 형식 검증 (한/영 혼용 지원) #============================================================================== -export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true COMMIT_MSG_FILE="$1" COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 7985d72..ec4542d 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re try { const { status, substance, search, acdntSn } = req.query const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined - const items = await listAnalyses({ + const page = parseInt(req.query.page as string, 10) || 1 + const limit = parseInt(req.query.limit as string, 10) || 10 + + if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) { + res.status(400).json({ error: '유효하지 않은 페이지네이션 파라미터' }) + return + } + + const result = await listAnalyses({ status: status as string | undefined, substance: substance as string | undefined, search: search as string | undefined, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, + page, + limit, }) - res.json(items) + res.json(result) } catch (err) { console.error('[hns] 분석 목록 오류:', err) res.status(500).json({ error: 'HNS 분석 목록 조회 실패' }) diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index ff10c2e..70b75d3 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -120,6 +120,8 @@ interface ListAnalysesInput { substance?: string search?: string acdntSn?: number + page?: number + limit?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { @@ -147,7 +149,12 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { } } -export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function listAnalyses(input: ListAnalysesInput): Promise<{ + items: HnsAnalysisItem[] + total: number + page: number + limit: number +}> { const conditions: string[] = ["USE_YN = 'Y'"] const params: (string | number)[] = [] let idx = 1 @@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise) => rowToAnalysis(r)) + return { + items: rows.map((r: Record) => rowToAnalysis(r)), + total, + page, + limit, + } } export async function getAnalysis(sn: number): Promise { diff --git a/frontend/src/components/common/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx index 97d4530..4ca7b8a 100644 --- a/frontend/src/components/common/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -233,12 +233,10 @@ function MapCenterTracker({ }; update(); - map.on('move', update); - map.on('zoom', update); + map.on('moveend', update); return () => { - map.off('move', update); - map.off('zoom', update); + map.off('moveend', update); }; }, [map, onCenterChange]); @@ -507,6 +505,87 @@ export function MapView({ const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0; const wmsOpacity = layerOpacity / 100; + // HNS 대기확산 히트맵 이미지 (Canvas 렌더링 + PNG 변환은 고비용이므로 별도 메모이제이션) + const heatmapImage = useMemo(() => { + if (!dispersionHeatmap || dispersionHeatmap.length === 0) return null; + + const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); + const minConc = Math.min( + ...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), + ); + const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001); + + if (filtered.length === 0) return null; + + // 경위도 바운드 계산 + let minLon = Infinity, + maxLon = -Infinity, + minLat = Infinity, + maxLat = -Infinity; + for (const p of dispersionHeatmap) { + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + } + const padLon = (maxLon - minLon) * 0.02; + const padLat = (maxLat - minLat) * 0.02; + minLon -= padLon; + maxLon += padLon; + minLat -= padLat; + maxLat += padLat; + + // 캔버스에 농도 이미지 렌더링 + const W = 1200, + H = 960; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + ctx.clearRect(0, 0, W, H); + + // 로그 스케일: 농도 범위를 고르게 분포 + const logMin = Math.log(minConc); + const logMax = Math.log(maxConc); + const logRange = logMax - logMin || 1; + + const stops: [number, number, number, number][] = [ + [34, 197, 94, 220], // green (저농도) + [234, 179, 8, 235], // yellow + [249, 115, 22, 245], // orange + [239, 68, 68, 250], // red (고농도) + [185, 28, 28, 255], // dark red (초고농도) + ]; + + for (const p of filtered) { + // 로그 스케일 정규화 (0~1) + const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange)); + const t = ratio * (stops.length - 1); + const lo = Math.floor(t); + const hi = Math.min(lo + 1, stops.length - 1); + const f = t - lo; + const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f); + const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f); + const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f); + const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255; + + const px = ((p.lon - minLon) / (maxLon - minLon)) * W; + const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H; + + ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`; + ctx.beginPath(); + ctx.arc(px, py, 12, 0, Math.PI * 2); + ctx.fill(); + } + + // Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피) + const imageUrl = canvas.toDataURL('image/png'); + return { + imageUrl, + bounds: [minLon, minLat, maxLon, maxLat] as [number, number, number, number], + }; + }, [dispersionHeatmap]); + // deck.gl 레이어 구축 // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers = useMemo((): any[] => { @@ -806,96 +885,17 @@ export function MapView({ } } - // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- - if (dispersionHeatmap && dispersionHeatmap.length > 0) { - const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); - const minConc = Math.min( - ...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), + // --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) --- + if (heatmapImage) { + result.push( + new BitmapLayer({ + id: 'hns-dispersion-bitmap', + image: heatmapImage.imageUrl, + bounds: heatmapImage.bounds, + opacity: 1.0, + pickable: false, + }) as unknown as DeckLayer, ); - const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001); - console.log( - '[MapView] HNS 히트맵:', - dispersionHeatmap.length, - '→ filtered:', - filtered.length, - 'maxConc:', - maxConc.toFixed(2), - ); - - if (filtered.length > 0) { - // 경위도 바운드 계산 - let minLon = Infinity, - maxLon = -Infinity, - minLat = Infinity, - maxLat = -Infinity; - for (const p of dispersionHeatmap) { - if (p.lon < minLon) minLon = p.lon; - if (p.lon > maxLon) maxLon = p.lon; - if (p.lat < minLat) minLat = p.lat; - if (p.lat > maxLat) maxLat = p.lat; - } - const padLon = (maxLon - minLon) * 0.02; - const padLat = (maxLat - minLat) * 0.02; - minLon -= padLon; - maxLon += padLon; - minLat -= padLat; - maxLat += padLat; - - // 캔버스에 농도 이미지 렌더링 - const W = 1200, - H = 960; - const canvas = document.createElement('canvas'); - canvas.width = W; - canvas.height = H; - const ctx = canvas.getContext('2d')!; - ctx.clearRect(0, 0, W, H); - - // 로그 스케일: 농도 범위를 고르게 분포 - const logMin = Math.log(minConc); - const logMax = Math.log(maxConc); - const logRange = logMax - logMin || 1; - - const stops: [number, number, number, number][] = [ - [34, 197, 94, 220], // green (저농도) - [234, 179, 8, 235], // yellow - [249, 115, 22, 245], // orange - [239, 68, 68, 250], // red (고농도) - [185, 28, 28, 255], // dark red (초고농도) - ]; - - for (const p of filtered) { - // 로그 스케일 정규화 (0~1) - const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange)); - const t = ratio * (stops.length - 1); - const lo = Math.floor(t); - const hi = Math.min(lo + 1, stops.length - 1); - const f = t - lo; - const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f); - const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f); - const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f); - const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255; - - const px = ((p.lon - minLon) / (maxLon - minLon)) * W; - const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H; - - ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`; - ctx.beginPath(); - ctx.arc(px, py, 12, 0, Math.PI * 2); - ctx.fill(); - } - - // Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피) - const imageUrl = canvas.toDataURL('image/png'); - result.push( - new BitmapLayer({ - id: 'hns-dispersion-bitmap', - image: imageUrl, - bounds: [minLon, minLat, maxLon, maxLat], - opacity: 1.0, - pickable: false, - }) as unknown as DeckLayer, - ); - } } // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- @@ -1300,7 +1300,7 @@ export function MapView({ isDrawingBoom, drawingPoints, dispersionResult, - dispersionHeatmap, + heatmapImage, incidentCoord, backtrackReplay, sensitiveResources, diff --git a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx index 2952be4..b6277fd 100644 --- a/frontend/src/components/hns/components/HNSAnalysisListTable.tsx +++ b/frontend/src/components/hns/components/HNSAnalysisListTable.tsx @@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps { onSelectAnalysis?: (sn: number, localRsltData?: Record) => void; } +const PAGE_SIZE = 10; + const RISK_LABEL: Record = { CRITICAL: '심각', HIGH: '위험', @@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string { export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) { const [analyses, setAnalyses] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const loadData = useCallback(async () => { setLoading(true); try { - const items = await fetchHnsAnalyses(); - setAnalyses(items); + const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE }); + setAnalyses(res.items); + setTotal(res.total); } catch (err) { console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err); // DB 실패 시 localStorage에서 불러오기 @@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly } finally { setLoading(false); } - }, []); + }, [page]); useEffect(() => { loadData(); @@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly

HNS 대기확산 분석 목록

-

총 {analyses.length}건

+

총 {total}건

@@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2) + .reduce((acc, p) => { + if (acc.length > 0 && p - acc[acc.length - 1] > 1) acc.push(-acc.length); + acc.push(p); + return acc; + }, []) + .map((p) => + p < 0 ? ( + + … + + ) : ( + + ), + )} + + +
+ )}
); } diff --git a/frontend/src/components/hns/components/HNSScenarioView.tsx b/frontend/src/components/hns/components/HNSScenarioView.tsx index 6862c79..e945745 100644 --- a/frontend/src/components/hns/components/HNSScenarioView.tsx +++ b/frontend/src/components/hns/components/HNSScenarioView.tsx @@ -288,8 +288,8 @@ export function HNSScenarioView() { useEffect(() => { let cancelled = false; fetchHnsAnalyses() - .then((items) => { - if (!cancelled) setIncidents(items); + .then((res) => { + if (!cancelled) setIncidents(res.items); }) .catch((err) => console.error('[hns] 사고 목록 조회 실패:', err)); return () => { diff --git a/frontend/src/components/hns/services/hnsApi.ts b/frontend/src/components/hns/services/hnsApi.ts index f626dec..c8afa5e 100644 --- a/frontend/src/components/hns/services/hnsApi.ts +++ b/frontend/src/components/hns/services/hnsApi.ts @@ -6,13 +6,22 @@ import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/Hn // ============================================================ +interface HnsAnalysesResponse { + items: HnsAnalysisItem[]; + total: number; + page: number; + limit: number; +} + export async function fetchHnsAnalyses(params?: { status?: string; substance?: string; search?: string; acdntSn?: number; -}): Promise { - const response = await api.get('/hns/analyses', { params }); + page?: number; + limit?: number; +}): Promise { + const response = await api.get('/hns/analyses', { params }); return response.data; } diff --git a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx index a228f82..9e9b43a 100644 --- a/frontend/src/components/incidents/components/AnalysisSelectModal.tsx +++ b/frontend/src/components/incidents/components/AnalysisSelectModal.tsx @@ -156,8 +156,8 @@ export function AnalysisSelectModal({ const items = await fetchPredictionAnalyses(); setPredItems(items); } else if (type === 'hns') { - const items = await fetchHnsAnalyses(); - setHnsItems(items); + const res = await fetchHnsAnalyses(); + setHnsItems(res.items); } else { const items = await fetchRescueOps(); setRescueItems(items); diff --git a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx index e46cc9a..9ade69b 100644 --- a/frontend/src/components/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/components/incidents/components/IncidentsRightPanel.tsx @@ -175,12 +175,12 @@ export function IncidentsRightPanel({ }) .catch(() => setPredItems([])); fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => { - setHnsItems(items); - const allIds = new Set(items.map((i) => String(i.hnsAnlysSn))); + .then((res) => { + setHnsItems(res.items); + const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn))); setCheckedHnsIds(allIds); onCheckedHnsChange?.( - items.map((h) => ({ + res.items.map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn, diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index c8806ef..4a5e4c7 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -221,7 +221,7 @@ export function IncidentsView() { const acdntSn = parseInt(selectedIncidentId, 10); if (Number.isNaN(acdntSn)) return; fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' }) - .then((items) => setHnsAnalyses(items)) + .then((res) => setHnsAnalyses(res.items)) .catch(() => {}); }, [selectedIncidentId]); -- 2.45.2 From 6b3dc8276e73d344c9506faefc955b4a64209a1c Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 09:19:24 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6ad47ba..92a0e81 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,7 @@ ## [Unreleased] ### 추가 +- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선 - HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트) ### 수정 -- 2.45.2 From a3aca449025cde0fa531c9f48dc12084bba56461 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 11:34:30 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(hns):=20HNS=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=ED=8C=A8=EB=84=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer) - HNSLeftPanel에 InfoLayerSection 통합 (레이어 표시/불투명도/밝기/색상) - HNS 분석 생성 시 spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd 전달 - DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 유출량 지원) --- .gitignore | 1 + database/migration/033_spil_qty_expand.sql | 10 + .../common/layer/InfoLayerSection.tsx | 176 +++++++++++++++++ .../hns/components/HNSLeftPanel.tsx | 34 +++- .../src/components/hns/components/HNSView.tsx | 97 +++++++++- .../components/InfoLayerSection.tsx | 177 +----------------- .../prediction/components/LeftPanel.tsx | 2 +- 7 files changed, 312 insertions(+), 185 deletions(-) create mode 100644 database/migration/033_spil_qty_expand.sql create mode 100644 frontend/src/components/common/layer/InfoLayerSection.tsx diff --git a/.gitignore b/.gitignore index 114eb43..812b8ea 100755 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ backend/scripts/hns-import/out/ # mcp .mcp.json +.playwright-mcp/ # python .venv \ No newline at end of file diff --git a/database/migration/033_spil_qty_expand.sql b/database/migration/033_spil_qty_expand.sql new file mode 100644 index 0000000..5571b5b --- /dev/null +++ b/database/migration/033_spil_qty_expand.sql @@ -0,0 +1,10 @@ +-- 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)'; diff --git a/frontend/src/components/common/layer/InfoLayerSection.tsx b/frontend/src/components/common/layer/InfoLayerSection.tsx new file mode 100644 index 0000000..a906a5c --- /dev/null +++ b/frontend/src/components/common/layer/InfoLayerSection.tsx @@ -0,0 +1,176 @@ +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; + onToggleLayer: (layerId: string, enabled: boolean) => void; + layerOpacity: number; + onLayerOpacityChange: (val: number) => void; + layerBrightness: number; + onLayerBrightnessChange: (val: number) => void; + layerColors: Record; + 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 ( +
+
+

+ 정보 레이어 +

+
+ + + + {expanded ? '▼' : '▶'} + +
+
+ + {expanded && ( +
+ {isLoading && effectiveLayers.length === 0 ? ( +

레이어 로딩 중...

+ ) : effectiveLayers.length === 0 ? ( +

레이어 데이터가 없습니다.

+ ) : ( + + )} + + {/* 레이어 스타일 조절 */} +
+
레이어 스타일
+
+ 투명도 + onLayerOpacityChange(Number(e.target.value))} + /> + {layerOpacity}% +
+
+ 밝기 + onLayerBrightnessChange(Number(e.target.value))} + /> + {layerBrightness}% +
+
+
+ )} +
+ ); +}; + +export default InfoLayerSection; diff --git a/frontend/src/components/hns/components/HNSLeftPanel.tsx b/frontend/src/components/hns/components/HNSLeftPanel.tsx index 13a56bc..2a670c1 100644 --- a/frontend/src/components/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/components/hns/components/HNSLeftPanel.tsx @@ -8,6 +8,7 @@ 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'; @@ -21,6 +22,14 @@ interface HNSLeftPanelProps { onReset?: () => void; loadedParams?: Partial | null; onFlyToCoord?: (coord: { lon: number; lat: number }) => void; + enabledLayers: Set; + onToggleLayer: (layerId: string, enabled: boolean) => void; + layerOpacity: number; + onLayerOpacityChange: (val: number) => void; + layerBrightness: number; + onLayerBrightnessChange: (val: number) => void; + layerColors: Record; + onLayerColorChange: (layerId: string, color: string) => void; } /** 십진 좌표 → 도분초 변환 */ @@ -45,12 +54,20 @@ export function HNSLeftPanel({ onReset, loadedParams, onFlyToCoord, + enabledLayers, + onToggleLayer, + layerOpacity, + onLayerOpacityChange, + layerBrightness, + onLayerBrightnessChange, + layerColors, + onLayerColorChange, }: HNSLeftPanelProps) { const [incidents, setIncidents] = useState([]); const [selectedIncidentSn, setSelectedIncidentSn] = useState(''); const [selectedAcdntSn, setSelectedAcdntSn] = useState(undefined); - const [expandedSections, setExpandedSections] = useState({ accident: true, params: true }); - const toggleSection = (key: 'accident' | 'params') => + const [expandedSections, setExpandedSections] = useState({ accident: true, params: true, infoLayer: false }); + const toggleSection = (key: 'accident' | 'params' | 'infoLayer') => setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); const [accidentName, setAccidentName] = useState(''); @@ -691,6 +708,19 @@ export function HNSLeftPanel({ )} + toggleSection('infoLayer')} + enabledLayers={enabledLayers} + onToggleLayer={onToggleLayer} + layerOpacity={layerOpacity} + onLayerOpacityChange={onLayerOpacityChange} + layerBrightness={layerBrightness} + onLayerBrightnessChange={onLayerBrightnessChange} + layerColors={layerColors} + onLayerColorChange={onLayerColorChange} + /> + {/* 실행 버튼 */}
)} @@ -825,7 +907,10 @@ export function HNSView() { isSelectingLocation={isSelectingLocation} onMapClick={handleMapClick} oilTrajectory={[]} - enabledLayers={new Set()} + enabledLayers={enabledLayers} + layerOpacity={layerOpacity} + layerBrightness={layerBrightness} + layerColors={layerColors} dispersionResult={dispersionResult} dispersionHeatmap={heatmapData} mapCaptureRef={mapCaptureRef} diff --git a/frontend/src/components/prediction/components/InfoLayerSection.tsx b/frontend/src/components/prediction/components/InfoLayerSection.tsx index a906a5c..d80a3f5 100644 --- a/frontend/src/components/prediction/components/InfoLayerSection.tsx +++ b/frontend/src/components/prediction/components/InfoLayerSection.tsx @@ -1,176 +1 @@ -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; - onToggleLayer: (layerId: string, enabled: boolean) => void; - layerOpacity: number; - onLayerOpacityChange: (val: number) => void; - layerBrightness: number; - onLayerBrightnessChange: (val: number) => void; - layerColors: Record; - 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 ( -
-
-

- 정보 레이어 -

-
- - - - {expanded ? '▼' : '▶'} - -
-
- - {expanded && ( -
- {isLoading && effectiveLayers.length === 0 ? ( -

레이어 로딩 중...

- ) : effectiveLayers.length === 0 ? ( -

레이어 데이터가 없습니다.

- ) : ( - - )} - - {/* 레이어 스타일 조절 */} -
-
레이어 스타일
-
- 투명도 - onLayerOpacityChange(Number(e.target.value))} - /> - {layerOpacity}% -
-
- 밝기 - onLayerBrightnessChange(Number(e.target.value))} - /> - {layerBrightness}% -
-
-
- )} -
- ); -}; - -export default InfoLayerSection; +export { default } from '@components/common/layer/InfoLayerSection'; diff --git a/frontend/src/components/prediction/components/LeftPanel.tsx b/frontend/src/components/prediction/components/LeftPanel.tsx index ba5c114..9abdc6d 100644 --- a/frontend/src/components/prediction/components/LeftPanel.tsx +++ b/frontend/src/components/prediction/components/LeftPanel.tsx @@ -59,7 +59,7 @@ const CATEGORY_ICON_MAP: Record = { const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' }; import PredictionInputSection from './PredictionInputSection'; -import InfoLayerSection from './InfoLayerSection'; +import InfoLayerSection from '@components/common/layer/InfoLayerSection'; import OilBoomSection from './OilBoomSection'; export type { LeftPanelProps }; -- 2.45.2 From 62feeb53725af4908869a028845ff8ae753ec6a8 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 11:39:51 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 046f11c..f98f283 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,16 @@ ## [Unreleased] +### 추가 +- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어) +- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달 + +### 변경 +- InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer) + +### 기타 +- DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원) + ## [2026-04-17] ### 추가 -- 2.45.2 From 1825bcbb5fcf6376c8740d36773a0288a6648957 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 14:13:03 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor(weather):=20KHOA=20API=20?= =?UTF-8?q?=EB=B3=91=EB=A0=AC=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- docs/RELEASE-NOTES.md | 3 + .../src/components/common/map/BaseMap.tsx | 4 +- .../weather/hooks/useWeatherData.ts | 111 ++++++++---------- .../components/weather/services/khoaApi.ts | 2 +- 5 files changed, 59 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3220db9..40bb9e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ ```bash cd frontend && npm install # Frontend -npm run dev # Vite dev (localhost:5173) +npm run dev # Vite dev (localhost:5174) npm run build # tsc -b && vite build npm run lint # ESLint diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index f98f283..c427286 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -11,6 +11,9 @@ ### 변경 - InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer) +### 수정 +- 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리 + ### 기타 - DB migration 033: SPIL_QTY NUMERIC(22,10) 확장 (대용량 HNS 유출량 지원) diff --git a/frontend/src/components/common/map/BaseMap.tsx b/frontend/src/components/common/map/BaseMap.tsx index 4464e38..62c7705 100644 --- a/frontend/src/components/common/map/BaseMap.tsx +++ b/frontend/src/components/common/map/BaseMap.tsx @@ -139,8 +139,8 @@ function MapOverlayControls({ return ( <> - {/* 좌측 컨트롤 컬럼 */} -
+ {/* 우측 컨트롤 컬럼 */} +
{/* 줌 */} +
+ )) + ) : ( +
검색 결과 없음
+ )} +
+ )} + + ); +} diff --git a/frontend/src/components/hns/components/HNSView.tsx b/frontend/src/components/hns/components/HNSView.tsx index 529e519..7a0c1cd 100644 --- a/frontend/src/components/hns/components/HNSView.tsx +++ b/frontend/src/components/hns/components/HNSView.tsx @@ -76,7 +76,7 @@ export function HNSView() { } | null>(null); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [mapBounds, setMapBounds] = useState(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [enabledLayers, setEnabledLayers] = useState>(new Set()); const [layerOpacity, setLayerOpacity] = useState(50); @@ -915,6 +915,7 @@ export function HNSView() { dispersionHeatmap={heatmapData} mapCaptureRef={mapCaptureRef} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> {/* 시간 슬라이더 (puff/dense_gas 모델용) */} diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index 4a5e4c7..a600dbc 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -42,6 +42,8 @@ import { } from '../utils/dischargeZoneData'; import { useMapStore } from '@common/store/mapStore'; import { FlyToController } from './contents/FlyToController'; +import { FlyToController as VesselFlyToController } from '@components/common/map/FlyToController'; +import { VesselSearchBar } from '@components/common/map/VesselSearchBar'; import { VesselPopupPanel } from './contents/VesselPopupPanel'; import { IncidentPopupContent } from './contents/IncidentPopupContent'; import { VesselDetailModal } from './contents/VesselDetailModal'; @@ -125,7 +127,12 @@ export function IncidentsView() { const [mapBounds, setMapBounds] = useState(null); const [mapZoom, setMapZoom] = useState(10); - const realVessels = useVesselSignals(mapBounds); + const { vessels: realVessels, allVessels: allRealVessels } = useVesselSignals(mapBounds); + const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{ + lng: number; + lat: number; + zoom: number; + } | null>(null); const [vesselStatus, setVesselStatus] = useState(null); useEffect(() => { @@ -790,6 +797,7 @@ export function IncidentsView() { + {/* 사고 팝업 */} {incidentPopup && ( @@ -811,6 +819,14 @@ export function IncidentsView() { )} + {/* 선박 검색 */} + {(allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( + 0 ? allRealVessels : realVessels} + onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + /> + )} + {/* 호버 툴팁 */} {hoverInfo && (
(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); @@ -1329,6 +1329,7 @@ export function OilSpillView() { showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> diff --git a/frontend/src/components/rescue/components/RescueView.tsx b/frontend/src/components/rescue/components/RescueView.tsx index f833d81..7f46ab4 100644 --- a/frontend/src/components/rescue/components/RescueView.tsx +++ b/frontend/src/components/rescue/components/RescueView.tsx @@ -182,7 +182,7 @@ export function RescueView() { const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [mapBounds, setMapBounds] = useState(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); useEffect(() => { fetchGscAccidents() @@ -249,6 +249,7 @@ export function RescueView() { enabledLayers={new Set()} showOverlays={false} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} />
-- 2.45.2 From 559ebd666a62b0a47281892fa5776f93d903286b Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 15:18:16 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(vessel):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=A0=84=EC=B2=B4=EC=97=90=20req?= =?UTF-8?q?uireAuth=20=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /in-area, /all, /status 세 엔드포인트 모두 인증 없이 접근 가능한 상태였음. 모든 라우트에 requireAuth를 적용하여 미인증 요청 시 401 반환. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/vessels/vesselRouter.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts index 3a580b0..d7ca059 100644 --- a/backend/src/vessels/vesselRouter.ts +++ b/backend/src/vessels/vesselRouter.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; import { getVesselsInBounds, getAllVessels, getCacheStatus } from './vesselService.js'; import type { BoundingBox } from './vesselTypes.js'; @@ -6,7 +7,7 @@ const vesselRouter = Router(); // POST /api/vessels/in-area // 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링) -vesselRouter.post('/in-area', (req, res) => { +vesselRouter.post('/in-area', requireAuth, (req, res) => { const { bounds } = req.body as { bounds?: BoundingBox }; if ( @@ -25,13 +26,13 @@ vesselRouter.post('/in-area', (req, res) => { }); // GET /api/vessels/all — 캐시된 전체 선박 목록 반환 (검색용) -vesselRouter.get('/all', (_req, res) => { +vesselRouter.get('/all', requireAuth, (_req, res) => { const vessels = getAllVessels(); res.json(vessels); }); // GET /api/vessels/status — 캐시 상태 확인 (디버그용) -vesselRouter.get('/status', (_req, res) => { +vesselRouter.get('/status', requireAuth, (_req, res) => { const status = getCacheStatus(); res.json(status); }); -- 2.45.2 From e8b9b923898548f161207067e157c6da8642b2f9 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:13:32 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(vessel):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20=EC=A7=80=EB=8F=84=EC=97=90=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=A7=81=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapView, IncidentsView에 searchedVesselMmsi 상태 추가 - 검색된 선박 위치에 pulsing 링 애니메이션 Marker 렌더링 - 선박 클릭 시 하이라이트 초기화 - vsb-highlight-ring CSS 애니메이션 추가 (components.css) --- frontend/src/common/styles/components.css | 34 +++++++++++++++++++ .../src/components/common/map/MapView.tsx | 28 ++++++++++++++- .../incidents/components/IncidentsView.tsx | 32 +++++++++++++++-- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 240358f..6204334 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1675,4 +1675,38 @@ [data-theme='light'] .vsb-item { border-bottom: 1px solid var(--stroke-light); } + + /* 선박 검색 하이라이트 링 */ + .vsb-highlight-ring { + position: relative; + width: 48px; + height: 48px; + border-radius: 50%; + pointer-events: none; + } + + .vsb-highlight-ring::before, + .vsb-highlight-ring::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2.5px solid #38bdf8; + animation: vsb-ring-pulse 2s ease-out infinite; + } + + .vsb-highlight-ring::after { + animation-delay: 1s; + } + + @keyframes vsb-ring-pulse { + 0% { + transform: scale(0.5); + opacity: 1; + } + 100% { + transform: scale(2.8); + opacity: 0; + } + } } diff --git a/frontend/src/components/common/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx index 5ce5a2e..f3e343e 100644 --- a/frontend/src/components/common/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -399,6 +399,7 @@ export function MapView({ const [vesselHover, setVesselHover] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); + const [searchedVesselMmsi, setSearchedVesselMmsi] = useState(null); const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{ lng: number; lat: number; @@ -406,6 +407,14 @@ export function MapView({ } | null>(null); const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; + const searchHighlightVessel = useMemo( + () => + searchedVesselMmsi + ? ((allVessels ?? vessels).find((v) => v.mmsi === searchedVesselMmsi) ?? null) + : null, + [searchedVesselMmsi, allVessels, vessels], + ); + const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { setMapCenter([lat, lng]); setMapZoom(zoom); @@ -1290,6 +1299,7 @@ export function MapView({ onClick: (vessel) => { setSelectedVessel(vessel); setDetailVessel(null); + setSearchedVesselMmsi(null); }, onHover: (vessel, x, y) => { setVesselHover(vessel ? { x, y, vessel } : null); @@ -1406,6 +1416,19 @@ export function MapView({ )} + {/* 선박 검색 하이라이트 링 */} + {searchHighlightVessel && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( + +
+ + )} + {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && @@ -1450,7 +1473,10 @@ export function MapView({ {(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + onFlyTo={(v) => { + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 16 }); + setSearchedVesselMmsi(v.mmsi); + }} /> )} diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index a600dbc..eddc8ca 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Map as MapLibreMap, Popup } from '@vis.gl/react-maplibre'; +import { Map as MapLibreMap, Marker, Popup } from '@vis.gl/react-maplibre'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; @@ -133,6 +133,17 @@ export function IncidentsView() { lat: number; zoom: number; } | null>(null); + const [searchedVesselMmsi, setSearchedVesselMmsi] = useState(null); + + const searchHighlightVessel = useMemo( + () => + searchedVesselMmsi + ? ((allRealVessels.length > 0 ? allRealVessels : realVessels).find( + (v) => v.mmsi === searchedVesselMmsi, + ) ?? null) + : null, + [searchedVesselMmsi, allRealVessels, realVessels], + ); const [vesselStatus, setVesselStatus] = useState(null); useEffect(() => { @@ -624,6 +635,7 @@ export function IncidentsView() { }); setIncidentPopup(null); setDetailVessel(null); + setSearchedVesselMmsi(null); }, onHover: (vessel, x, y) => { if (vessel) { @@ -799,6 +811,19 @@ export function IncidentsView() { + {/* 선박 검색 하이라이트 링 */} + {searchHighlightVessel && !dischargeMode && measureMode === null && ( + +
+ + )} + {/* 사고 팝업 */} {incidentPopup && ( 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( 0 ? allRealVessels : realVessels} - onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + onFlyTo={(v) => { + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 }); + setSearchedVesselMmsi(v.mmsi); + }} /> )} -- 2.45.2 From ffb98e269399cdf4a1d68cccf461370bfbf90249 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:17:28 +0900 Subject: [PATCH 10/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d675852..1b48ea9 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,8 @@ ## [Unreleased] ### 추가 +- 선박: 선박 검색 시 지도에 하이라이트 링 애니메이션 표시 (MapView, IncidentsView) +- 선박: 선박 검색 범위를 전체 캐시 대상으로 확대 - HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어) - HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달 @@ -13,6 +15,7 @@ - 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정 ### 수정 +- 선박: 라우터 전체에 requireAuth 미들웨어 추가 - 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리 ### 기타 -- 2.45.2 From c39594ca14ed26eca3e5314c0921885ede9cb576 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:42:27 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat(hns):=20HNS=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=B7=B0=20UI=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EB=B6=84=EC=84=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/common/map/BaseMap.tsx | 4 +-- .../incidents/components/IncidentsView.tsx | 30 ++++++++++--------- .../weather/components/WeatherView.tsx | 4 +-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/common/map/BaseMap.tsx b/frontend/src/components/common/map/BaseMap.tsx index 62c7705..a18e126 100644 --- a/frontend/src/components/common/map/BaseMap.tsx +++ b/frontend/src/components/common/map/BaseMap.tsx @@ -139,8 +139,8 @@ function MapOverlayControls({ return ( <> - {/* 우측 컨트롤 컬럼 */} -
+ {/* 좌측 컨트롤 컬럼 */} +
{/* 줌 */} + } {/* 오염물 배출 규정 패널 */} - {dischargeMode && dischargeInfo && ( + {isOverlayMode && dischargeMode && dischargeInfo && ( 사고 {filteredIncidents.length}
방제선 {vesselStatus?.bangjeCount ?? 0}
-
+
} {/* Legend */} -
))}
- + } {/* 선박 팝업 패널 */} - {vesselPopup && selectedVessel && !detailVessel && ( + {isOverlayMode && vesselPopup && selectedVessel && !detailVessel && ( { @@ -1015,7 +1017,7 @@ export function IncidentsView() { }} /> )} - {detailVessel && ( + {isOverlayMode && detailVessel && ( setDetailVessel(null)} /> )} diff --git a/frontend/src/components/weather/components/WeatherView.tsx b/frontend/src/components/weather/components/WeatherView.tsx index b041c46..7bb3add 100644 --- a/frontend/src/components/weather/components/WeatherView.tsx +++ b/frontend/src/components/weather/components/WeatherView.tsx @@ -324,7 +324,7 @@ export function WeatherView() { {/* 레이어 컨트롤 */} -
+
기상 레이어
{/* 범례 */} -
+
기상 범례
{/* 바람 */} -- 2.45.2 From 604353cd81246b3da9513bc063e25a257a372177 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:44:18 +0900 Subject: [PATCH 12/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1b48ea9..7c8b587 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -9,10 +9,12 @@ - 선박: 선박 검색 범위를 전체 캐시 대상으로 확대 - HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어) - HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달 +- HNS/사건사고: 분석 전용 뷰 모드에서 지도 오버레이 UI 요소 조건부 숨김 처리 ### 변경 - InfoLayerSection을 공통 컴포넌트로 이동 (prediction → common/layer) - 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정 +- 지도 공통: 컨트롤 버튼 패널(줌 등) 위치 우측 → 좌측으로 변경 ### 수정 - 선박: 라우터 전체에 requireAuth 미들웨어 추가 -- 2.45.2 From 4fd8d4aa1c7b778449553b2ecd48b35d7d98010a Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:48:05 +0900 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-04-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7c8b587..3dd0591 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-04-20] + ### 추가 - 선박: 선박 검색 시 지도에 하이라이트 링 애니메이션 표시 (MapView, IncidentsView) - 선박: 선박 검색 범위를 전체 캐시 대상으로 확대 -- 2.45.2