diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 2eb372f24..a3ef2efa7 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -60,7 +60,6 @@ import { usePathname, useSearchParams } from "next/navigation"; import Collection from "ol/Collection.js"; import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; -import Overlay from "ol/Overlay.js"; import CircleGeom from "ol/geom/Circle.js"; import Draw from "ol/interaction/Draw.js"; import Snap from "ol/interaction/Snap.js"; @@ -131,7 +130,7 @@ import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayer import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel"; import LayerSettingCard from "./components/layerSetting/LayerSettingCard"; import { getExmnMapLayer } from "./components/exmn/exmnMapApi"; -import { getFloodRiskGradeClassName } from "@/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent"; +import { useFloodPredictionMap } from "@/features/fldngPredcPrst/lib/use-flood-prediction-map"; import { EMPTY_EXMN_MAP_FILTERS, EXMN_RESULT_MNHL_LAYER_ID, @@ -241,15 +240,6 @@ const EXMN_GRADE_COLORS: Record = { }; const EXMN_RESULT_MANHOLE_ICON_SRC = "/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3"; -const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL"; - -/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */ -const formatFloodPredictionDateTime = (value: unknown) => { - const text = String(value ?? "").trim(); - return /^\d{12}$/.test(text) - ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}` - : text || "-"; -}; const getExmnGradeColor = (feature: any) => { const grade = Number(feature?.get?.("structuralGrade") ?? 0); @@ -275,7 +265,6 @@ const createExmnManholeStyle = () => }), }); -/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */ // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_SRS = "EPSG:5179"; @@ -1124,21 +1113,20 @@ const Map = () => { requestKey: number; layerNames: string[]; } | null>(null); - // 침수예측현황에서 선택한 행은 도시침수지도 레이어가 켜진 뒤 지도 이동에 사용한다. - const [pendingFloodPredictionSelection, setPendingFloodPredictionSelection] = - useState<{ - extent: [number, number, number, number]; - sggCd: string; - frequency: string; - analysisDateTime: string; - } | null>(null); - // 팝업 Window 객체는 렌더링 대상이 아니므로 자동 레이어 선택 실패 안내용 ref로 보관한다. - const pendingFloodPredictionPopupRef = useRef<{ - source: Window | null; - origin: string; - } | null>(null); const [layerLoadError, setLayerLoadError] = useState(null); const gisMaps = useRef(null); + /** 침수예측현황 행 선택 시 기존 체크를 유지하면서 도시침수지도 레이어 활성화를 요청한다. */ + const requestFloodPredictionLayer = useCallback(() => { + setCheckedLayerRequest((current) => ({ + requestKey: (current?.requestKey ?? 0) + 1, + layerNames: [FLOOD_DNGR_MAP_LAYER_NAME], + })); + }, []); + const { handleFloodPredictionPopupSelection } = useFloodPredictionMap({ + mapRef: gisMaps, + floodLayerType: FLOOD_DNGR_MAP_LAYER_TYPE, + onRequestFloodLayer: requestFloodPredictionLayer, + }); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. @@ -1422,8 +1410,6 @@ const Map = () => { const waterRiskPipeSourceRef = useRef(null); const waterRiskManholeLayerRef = useRef | null>(null); const waterRiskManholeSourceRef = useRef(null); - // 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다. - const floodPredictionGridLayerRef = useRef(null); const gisEditDrawRef = useRef(null); const gisEditDrawSnapRef = useRef(null); const gisEditTranslateRef = useRef(null); @@ -1701,185 +1687,6 @@ const Map = () => { } }, []); - /** 기존 침수예측 중앙 오버레이를 제거한다. */ - const removeFloodPredictionGridLayer = useCallback(() => { - const core = GisApp.Module.core as any; - core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); - const map = gisMaps.current; - if (map && floodPredictionGridLayerRef.current) { - map.removeOverlay(floodPredictionGridLayerRef.current); - } - floodPredictionGridLayerRef.current = null; - }, []); - - /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ - const loadFloodPredictionGridLayer = useCallback( - async (sggCd: string, analysisDateTime: string, extent: number[]) => { - const map = gisMaps.current; - if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime)) - return; - removeFloodPredictionGridLayer(); - try { - const response = await getRawJson( - `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, - ); - const payload = response?.data ?? response; - const row = payload && !Array.isArray(payload) ? payload : null; - if ( - !row || - extent.length !== 4 || - extent.some((value) => !Number.isFinite(value)) - ) - return; - const center = [ - (extent[0]! + extent[2]!) / 2, - (extent[1]! + extent[3]!) / 2, - ]; - const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed( - 1, - ); - const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed( - 1, - ); - const container = document.createElement("div"); - container.style.cssText = - "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;"; - const close = document.createElement("button"); - close.type = "button"; - close.textContent = "×"; - close.title = "침수예측 오버레이 닫기"; - close.style.cssText = - "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; - close.addEventListener("click", () => removeFloodPredictionGridLayer()); - const table = document.createElement("table"); - table.style.cssText = "border-collapse:collapse; width:100%;"; - const title = table.createTHead().insertRow(); - const titleCell = title.insertCell(); - titleCell.colSpan = 2; - titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; - titleCell.style.cssText = - "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; - const body = table.createTBody(); - [ - [ - "분석시각", - formatFloodPredictionDateTime( - row.analysisDateTime ?? analysisDateTime, - ), - ], - [ - "강우시작시각", - formatFloodPredictionDateTime(row.rainBeginningDateTime), - ], - ["관측 누적강수량(mm)", observed], - ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], - ["예측 누적강수량(mm)", predicted], - ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], - ["침수위험도", row.floodRiskGrade ?? "-"], - ].forEach(([label, value]) => { - const tr = body.insertRow(); - const key = tr.insertCell(); - key.textContent = label; - key.style.cssText = - "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; - const val = tr.insertCell(); - val.style.cssText = - "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; - if (label === "침수위험도") { - // 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다. - const badge = document.createElement("span"); - badge.textContent = value; - badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`; - val.append(badge); - } else { - val.textContent = value; - } - }); - container.append(close, table); - const overlay = new Overlay({ - element: container, - positioning: "center-center", - stopEvent: true, - }); - overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); - overlay.setPosition(center); - map.addOverlay(overlay); - floodPredictionGridLayerRef.current = overlay; - } catch (error) { - console.error("침수예측지도 오버레이 표시 실패:", error); - } - }, - [removeFloodPredictionGridLayer], - ); - - /** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */ - const applyFloodPredictionSelection = useCallback( - async (selection: { - extent: [number, number, number, number]; - sggCd: string; - frequency: string; - analysisDateTime: string; - }) => { - const map = gisMaps.current; - const select = GisApp.Module.select as any; - if (!map?.getView || !select?.selectFocusedBoundaryFeature) return false; - - const floodLayers = map - .getLayers() - .getArray() - .filter( - (layer: any) => - String(layer.get?.("lyrTyNm") ?? "").trim() === - FLOOD_DNGR_MAP_LAYER_TYPE && layer.getVisible?.() !== false, - ); - if (floodLayers.length === 0) return false; - - await select.resetSelectedFeature?.(); - const selectedFeatureIds = new Set(); - const selectLoadedGroupFeatures = (source: any) => { - const features = source?.getFeatures?.() ?? []; - features.forEach((feature: any) => { - const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); - const featureFrequency = String( - feature.get?.("sbmrg_frq_expln") ?? "", - ).trim(); - if ( - featureSggCd !== selection.sggCd || - featureFrequency !== selection.frequency - ) - return; - const featureId = String( - feature.getId?.() ?? feature.get?.("fid") ?? "", - ); - if (featureId && selectedFeatureIds.has(featureId)) return; - if (featureId) selectedFeatureIds.add(featureId); - select.selectFocusedBoundaryFeature(feature, true); - }); - }; - - floodLayers.forEach((layer: any) => { - const source = layer.getSource?.(); - if (!source?.getFeatures) return; - selectLoadedGroupFeatures(source); - source.once?.("featuresloadend", () => - selectLoadedGroupFeatures(source), - ); - }); - map.getView().fit(selection.extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - }); - void loadFloodPredictionGridLayer( - selection.sggCd, - selection.analysisDateTime, - selection.extent, - ); - return true; - }, - [loadFloodPredictionGridLayer], - ); - useEffect(() => { fetchData(); }, []); @@ -2012,43 +1819,10 @@ const Map = () => { if ( data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP ) { - const extent = Array.isArray(data.payload?.extent) - ? data.payload.extent.map(Number) - : []; - const sggCd = String(data.payload?.sggCd ?? "").trim(); - const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim(); - const analysisDateTime = String( - data.payload?.analysisDateTime ?? "", - ).trim(); - const map = gisMaps.current; - const select = GisApp.Module.select as any; - if ( - extent.length !== 4 || - extent.some((value: number) => !Number.isFinite(value)) || - !/^\d{5}$/.test(sggCd) || - !/^(030|050|080|100)$/.test(frequency) || - !map?.getView || - !select?.selectFocusedBoundaryFeature - ) { - return; - } - const selection = { - extent: extent as [number, number, number, number], - sggCd, - frequency, - analysisDateTime, - }; - if (await applyFloodPredictionSelection(selection)) return; - - pendingFloodPredictionPopupRef.current = { + await handleFloodPredictionPopupSelection(data.payload, { source: event.source as Window | null, origin: event.origin, - }; - setPendingFloodPredictionSelection(selection); - setCheckedLayerRequest((current) => ({ - requestKey: (current?.requestKey ?? 0) + 1, - layerNames: [FLOOD_DNGR_MAP_LAYER_NAME], - })); + }); return; } @@ -2087,50 +1861,7 @@ const Map = () => { window.clearInterval(timer); window.removeEventListener("message", handlePopupMessage); }; - }, [applyFloodPredictionSelection]); - - useEffect(() => { - if (!pendingFloodPredictionSelection) return; - - let retryTimer: number | null = null; - let retryCount = 0; - const maxRetryCount = 20; - - /** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */ - const applyPendingSelection = async () => { - if ( - await applyFloodPredictionSelection(pendingFloodPredictionSelection) - ) { - setPendingFloodPredictionSelection((current) => - current === pendingFloodPredictionSelection ? null : current, - ); - pendingFloodPredictionPopupRef.current = null; - return; - } - - retryCount += 1; - if (retryCount >= maxRetryCount) { - const popup = pendingFloodPredictionPopupRef.current; - popup?.source?.postMessage( - { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, - popup.origin, - ); - pendingFloodPredictionPopupRef.current = null; - setPendingFloodPredictionSelection((current) => - current === pendingFloodPredictionSelection ? null : current, - ); - return; - } - retryTimer = window.setTimeout(() => { - void applyPendingSelection(); - }, 200); - }; - - void applyPendingSelection(); - return () => { - if (retryTimer !== null) window.clearTimeout(retryTimer); - }; - }, [applyFloodPredictionSelection, pendingFloodPredictionSelection]); + }, [handleFloodPredictionPopupSelection]); useEffect(() => { if (shouldShowGisEditSession) return; diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md new file mode 100644 index 000000000..31f23e41b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md @@ -0,0 +1,46 @@ +# 침수예측지도 오버레이 시각 표시 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택했을 때 지도 오버레이의 분석시각과 예측 관련 시각을 생략 없이 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 요청사항: 지도 오버레이에서 월 뒤로 생략되는 분석시각 값을 전체 표시 + +## 원인 + +- SN 목록 테이블의 데이터 셀에 적용되는 전역 말줄임 규칙이 지도 오버레이 내부 테이블에도 적용되었다. +- 오버레이의 기존 날짜 변환 함수는 `YYYY-MM-DD HH:mm` 전체 값을 만들고 있었으나, 좁은 셀에서 화면 표시만 생략되었다. + +## 변경 내용 + +- 지도 오버레이 폭을 기존과 동일한 `230px`로 유지했다. +- 고정된 폭 안에서 라벨 열 55%, 값 열 45%로 배분하도록 `colgroup`과 `table-layout: fixed`를 적용했다. +- 오버레이의 라벨·값 셀을 목록용 전역 말줄임 대상에서 제외했다. +- 전체 날짜와 시각이 한 줄에 표시되도록 값 셀의 줄바꿈과 말줄임을 해제했다. +- 기존 날짜 변환 형식과 침수위험도 배지 동작은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- 침수예측 격자 선택 후 표시되는 지도 오버레이에만 적용한다. +- 일반 목록 그리드의 말줄임 정책은 변경하지 않는다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 200 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- 오버레이 셀의 `data-sn-cell-wrap` 속성과 표·셀 너비 스타일을 제거하고 기존 최소 너비를 복원한다. + +## 남은 확인 + +- 침수예측현황 행 선택 후 분석시각, 강우시작시각, 예측시각이 `YYYY-MM-DD HH:mm` 형식으로 모두 보이는지 확인한다. +- 지도의 오른쪽 경계 부근에서도 오버레이가 화면 안에 정상적으로 표시되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md new file mode 100644 index 000000000..2fab0e07a --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md @@ -0,0 +1,52 @@ +# 침수예측지도 기능 분리 구현기록 + +## 작업 목적 + +- 하수관망도 `map/page.tsx`에 포함된 침수예측현황 전용 로직을 기능 디렉터리로 이동한다. +- 지도 페이지에는 공통 지도 구성과 침수예측 기능 연결 코드만 남겨 기능별 책임을 분명히 한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 사용자 요청사항: `map/page.tsx`의 침수예측현황 관련 함수와 상태를 `features` 하위 해당 기능으로 이동 + +## 변경 내용 + +- 침수예측 팝업 메시지 payload 검증을 기능 훅으로 이동했다. +- 도시침수지도 자동 활성화 요청 이후의 재시도 상태와 팝업 응답 대상을 기능 훅에서 관리한다. +- 도시침수지도 피처 선택, 지도 범위 이동, 분석결과 API 조회와 오버레이 생성·삭제를 기능 훅으로 이동했다. +- 침수위험도 배지 색상 계산을 별도 기능 유틸리티로 분리하여 팝업 목록과 지도 오버레이가 함께 사용하도록 했다. +- `map/page.tsx`에는 도시침수지도 레이어 활성화 요청과 팝업 메시지 위임만 남겼다. + +## 수정 대상 파일 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- `apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts` +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` + +## 영향 범위 + +- 침수예측현황 행 선택 후 도시침수지도 자동 선택·이동·분석 오버레이 표시 흐름 +- 침수예측현황 목록과 지도 오버레이의 위험도 배지 색상 +- API 경로, 팝업 메시지 타입, 레이어명·레이어 타입과 사용자 동작은 변경하지 않는다. + +## 검증 + +- 신규 기능 파일 ESLint 검사 통과 +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 확인 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `useFloodPredictionMap`의 상태·콜백·재시도 effect를 `map/page.tsx`로 되돌린다. +- 오버레이 DOM 생성 함수와 날짜 포맷터를 `map/page.tsx`로 되돌린다. +- 위험도 배지 유틸리티를 팝업 컴포넌트 내부 함수로 복원한다. + +## 남은 리스크 + +- 실제 로그인 세션에서 팝업 행을 선택해 레이어 자동 체크, 지도 이동, 오버레이 표시가 연속으로 유지되는지 최종 화면 확인이 필요하다. diff --git a/apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts b/apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts new file mode 100644 index 000000000..a0f7e2419 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts @@ -0,0 +1,16 @@ +/** 침수위험도 등급을 목록과 지도 오버레이에서 사용하는 고정 의미색으로 변환한다. */ +export const getFloodRiskGradeClassName = (value: string) => { + const grade = String(value ?? "") + .trim() + .toUpperCase(); + if (grade.includes("위험") || grade === "DANGER") { + return "bg-[#dc2626] text-white"; + } + if (grade.includes("경계") || grade === "WARNING") { + return "bg-[#f97316] text-white"; + } + if (grade.includes("주의") || grade === "CAUTION") { + return "bg-[#facc15] text-[#713f12]"; + } + return "sn-popup-badge-normal"; +}; diff --git a/apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts b/apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts new file mode 100644 index 000000000..f37164900 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts @@ -0,0 +1,406 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import Overlay from "ol/Overlay.js"; + +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +import { getRawJson } from "@/components/lib/api"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; + +import { getFloodRiskGradeClassName } from "./flood-risk-grade"; + +const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL"; +const FLOOD_PREDICTION_LAYER_RETRY_LIMIT = 20; +const FLOOD_PREDICTION_LAYER_RETRY_DELAY = 200; + +/** 침수예측현황 행 선택 후 지도에 적용할 위치·행 식별 정보다. */ +type FloodPredictionSelection = { + extent: [number, number, number, number]; + sggCd: string; + frequency: string; + analysisDateTime: string; +}; + +/** 자동 레이어 선택 실패 시 안내를 돌려보낼 침수예측 팝업 정보다. */ +type FloodPredictionPopupContext = { + source: Window | null; + origin: string; +}; + +type FloodPredictionFeature = { + get?: (key: string) => unknown; + getId?: () => string | number | undefined; +}; + +type FloodPredictionSource = { + getFeatures?: () => FloodPredictionFeature[]; + once?: (eventName: string, listener: () => void) => void; +}; + +type FloodPredictionLayer = { + get?: (key: string) => unknown; + getVisible?: () => boolean; + getSource?: () => FloodPredictionSource | null; +}; + +type FloodPredictionMap = { + addOverlay: (overlay: Overlay) => void; + removeOverlay: (overlay: Overlay) => void; + getLayers: () => { getArray: () => FloodPredictionLayer[] }; + getView: () => { + fit: ( + extent: [number, number, number, number], + options: { + duration: number; + padding: [number, number, number, number]; + maxZoom: number; + }, + ) => void; + }; +}; + +type FloodPredictionOverlayRow = Record & { + analysisDateTime?: unknown; + ctpvCd?: unknown; + ctpvNm?: unknown; + exceededFrequencyYear?: unknown; + floodRiskGrade?: unknown; + observedAccumulatedRainfall?: unknown; + predictedAccumulatedRainfall?: unknown; + predictionDateTime?: unknown; + rainBeginningDateTime?: unknown; + sggCd?: unknown; + sggNm?: unknown; +}; + +type FloodSelectionModule = { + resetSelectedFeature?: () => void | Promise; + selectFocusedBoundaryFeature?: ( + feature: FloodPredictionFeature, + append: boolean, + ) => void; +}; + +type UseFloodPredictionMapOptions = { + mapRef: { current: FloodPredictionMap | null }; + floodLayerType: string; + onRequestFloodLayer: () => void; +}; + +/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */ +const formatFloodPredictionDateTime = (value: unknown) => { + const text = String(value ?? "").trim(); + return /^\d{12}$/.test(text) + ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}` + : text || "-"; +}; + +/** 팝업 메시지 payload를 검증하여 지도 선택 정보로 변환한다. */ +const parseFloodPredictionSelection = ( + payload: unknown, +): FloodPredictionSelection | null => { + const message = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + const extent = Array.isArray(message.extent) + ? message.extent.map(Number) + : []; + const sggCd = String(message.sggCd ?? "").trim(); + const frequency = String(message.sbmrgFrqExpln ?? "").trim(); + const analysisDateTime = String(message.analysisDateTime ?? "").trim(); + + if ( + extent.length !== 4 || + extent.some((value: number) => !Number.isFinite(value)) || + !/^\d{5}$/.test(sggCd) || + !/^(030|050|080|100)$/.test(frequency) + ) { + return null; + } + + return { + extent: extent as [number, number, number, number], + sggCd, + frequency, + analysisDateTime, + }; +}; + +/** 선택 행의 분석값을 표시할 지도 오버레이 DOM을 생성한다. */ +const createFloodPredictionOverlayElement = ( + row: FloodPredictionOverlayRow, + analysisDateTime: string, + onClose: () => void, +) => { + const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1); + const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1); + const container = document.createElement("div"); + container.style.cssText = + "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; width:230px;"; + + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "×"; + close.title = "침수예측 오버레이 닫기"; + close.style.cssText = + "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; + close.addEventListener("click", onClose); + + const table = document.createElement("table"); + table.style.cssText = + "border-collapse:collapse; table-layout:fixed; width:100%;"; + const colgroup = document.createElement("colgroup"); + const keyColumn = document.createElement("col"); + const valueColumn = document.createElement("col"); + keyColumn.style.width = "55%"; + valueColumn.style.width = "45%"; + colgroup.append(keyColumn, valueColumn); + table.append(colgroup); + + const title = table.createTHead().insertRow(); + const titleCell = title.insertCell(); + titleCell.colSpan = 2; + titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; + titleCell.style.cssText = + "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; + + const body = table.createTBody(); + const overlayRows: Array<[string, string]> = [ + [ + "분석시각", + formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime), + ], + [ + "강우시작시각", + formatFloodPredictionDateTime(row.rainBeginningDateTime), + ], + ["관측 누적강수량(mm)", observed], + ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], + ["예측 누적강수량(mm)", predicted], + ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], + ["침수위험도", String(row.floodRiskGrade ?? "-")], + ]; + overlayRows.forEach(([label, value]) => { + const tr = body.insertRow(); + const key = tr.insertCell(); + key.textContent = label; + // 목록용 전역 말줄임 규칙이 지도 오버레이 셀에는 적용되지 않게 한다. + key.setAttribute("data-sn-cell-wrap", "true"); + key.style.cssText = + "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + + const val = tr.insertCell(); + val.setAttribute("data-sn-cell-wrap", "true"); + val.style.cssText = + "font-weight:400; overflow:visible; text-overflow:clip; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + if (label === "침수위험도") { + // 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다. + const badge = document.createElement("span"); + badge.textContent = value; + badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`; + val.append(badge); + } else { + val.textContent = value; + } + }); + + container.append(close, table); + return container; +}; + +/** 침수예측현황 팝업과 도시침수지도의 선택·이동·오버레이 흐름을 관리한다. */ +export const useFloodPredictionMap = ({ + mapRef, + floodLayerType, + onRequestFloodLayer, +}: UseFloodPredictionMapOptions) => { + // 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다. + const [pendingSelection, setPendingSelection] = + useState(null); + // 팝업 Window 객체는 렌더링 대상이 아니므로 자동 선택 실패 안내용 ref로 보관한다. + const pendingPopupRef = useRef(null); + // 행을 다시 선택할 때 기존 오버레이를 제거할 수 있도록 OpenLayers 객체를 ref로 보관한다. + const overlayRef = useRef(null); + + /** 기존 침수예측 중앙 오버레이를 제거한다. */ + const removeFloodPredictionOverlay = useCallback(() => { + const core = GisApp.Module.core as unknown as + | { deleteLayer?: (layerId: string) => void } + | undefined; + core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); + const map = mapRef.current; + if (map && overlayRef.current) { + map.removeOverlay(overlayRef.current); + } + overlayRef.current = null; + }, [mapRef]); + + /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ + const loadFloodPredictionOverlay = useCallback( + async (selection: FloodPredictionSelection) => { + const { sggCd, analysisDateTime, extent } = selection; + const map = mapRef.current; + if (!map || !/^\d{12}$/.test(analysisDateTime)) return; + removeFloodPredictionOverlay(); + + try { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, + ); + const payload = response?.data ?? response; + const row = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as FloodPredictionOverlayRow) + : null; + if (!row) return; + + const center = [ + (extent[0] + extent[2]) / 2, + (extent[1] + extent[3]) / 2, + ]; + const overlay = new Overlay({ + element: createFloodPredictionOverlayElement( + row, + analysisDateTime, + removeFloodPredictionOverlay, + ), + positioning: "center-center", + stopEvent: true, + }); + overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); + overlay.setPosition(center); + map.addOverlay(overlay); + overlayRef.current = overlay; + } catch (error) { + console.error("침수예측지도 오버레이 표시 실패:", error); + } + }, + [mapRef, removeFloodPredictionOverlay], + ); + + /** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */ + const applyFloodPredictionSelection = useCallback( + async (selection: FloodPredictionSelection) => { + const map = mapRef.current; + const select = GisApp.Module.select as unknown as + | FloodSelectionModule + | undefined; + const selectFocusedBoundaryFeature = + select?.selectFocusedBoundaryFeature; + if (!map || !selectFocusedBoundaryFeature) return false; + + const floodLayers = map + .getLayers() + .getArray() + .filter( + (layer) => + String(layer.get?.("lyrTyNm") ?? "").trim() === floodLayerType && + layer.getVisible?.() !== false, + ); + if (floodLayers.length === 0) return false; + + await select.resetSelectedFeature?.(); + const selectedFeatureIds = new Set(); + /** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */ + const selectLoadedGroupFeatures = (source: FloodPredictionSource) => { + const features = source?.getFeatures?.() ?? []; + features.forEach((feature) => { + const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); + const featureFrequency = String( + feature.get?.("sbmrg_frq_expln") ?? "", + ).trim(); + if ( + featureSggCd !== selection.sggCd || + featureFrequency !== selection.frequency + ) { + return; + } + + const featureId = String( + feature.getId?.() ?? feature.get?.("fid") ?? "", + ); + if (featureId && selectedFeatureIds.has(featureId)) return; + if (featureId) selectedFeatureIds.add(featureId); + selectFocusedBoundaryFeature(feature, true); + }); + }; + + floodLayers.forEach((layer) => { + const source = layer.getSource?.(); + if (!source?.getFeatures) return; + selectLoadedGroupFeatures(source); + source.once?.("featuresloadend", () => + selectLoadedGroupFeatures(source), + ); + }); + map.getView().fit(selection.extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + void loadFloodPredictionOverlay(selection); + return true; + }, + [floodLayerType, loadFloodPredictionOverlay, mapRef], + ); + + /** 침수예측현황 행 선택 메시지를 검증하고, 필요하면 도시침수지도 레이어 활성화를 요청한다. */ + const handleFloodPredictionPopupSelection = useCallback( + async (payload: unknown, popupContext: FloodPredictionPopupContext) => { + const selection = parseFloodPredictionSelection(payload); + if (!selection) return; + if (await applyFloodPredictionSelection(selection)) return; + + pendingPopupRef.current = popupContext; + setPendingSelection(selection); + onRequestFloodLayer(); + }, + [applyFloodPredictionSelection, onRequestFloodLayer], + ); + + useEffect(() => { + if (!pendingSelection) return; + + let retryTimer: number | null = null; + let retryCount = 0; + + /** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */ + const applyPendingSelection = async () => { + if (await applyFloodPredictionSelection(pendingSelection)) { + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + pendingPopupRef.current = null; + return; + } + + retryCount += 1; + if (retryCount >= FLOOD_PREDICTION_LAYER_RETRY_LIMIT) { + const popup = pendingPopupRef.current; + popup?.source?.postMessage( + { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, + popup.origin, + ); + pendingPopupRef.current = null; + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + return; + } + + retryTimer = window.setTimeout(() => { + void applyPendingSelection(); + }, FLOOD_PREDICTION_LAYER_RETRY_DELAY); + }; + + void applyPendingSelection(); + return () => { + if (retryTimer !== null) window.clearTimeout(retryTimer); + }; + }, [applyFloodPredictionSelection, pendingSelection]); + + return { handleFloodPredictionPopupSelection }; +}; diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index 91e94b3a7..e13e98b00 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -19,6 +19,7 @@ import { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; +import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade"; type ApiRow = { sggCd: string; @@ -189,23 +190,6 @@ const validateFilters = (filters: Filters) => { const buildRowId = (row: ApiRow) => `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`; -/** 침수위험도 등급을 목록 배지의 고정 의미색으로 변환한다. */ -export const getFloodRiskGradeClassName = (value: string) => { - const grade = String(value ?? "") - .trim() - .toUpperCase(); - if (grade.includes("위험") || grade === "DANGER") { - return "bg-[#dc2626] text-white"; - } - if (grade.includes("경계") || grade === "WARNING") { - return "bg-[#f97316] text-white"; - } - if (grade.includes("주의") || grade === "CAUTION") { - return "bg-[#facc15] text-[#713f12]"; - } - return "sn-popup-badge-normal"; -}; - export default function FldngPredcPrstPopupContent() { const alert = useAlertStore((state) => state.setMessage); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();