From 8b5a2b713c725c3421f7976aec640452e71e1943 Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 30 Sep 2026 17:53:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?sn:=20=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9E=90=EB=8F=99=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 194 +++++++++++++----- .../001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md | 42 ++++ 2 files changed, 184 insertions(+), 52 deletions(-) create mode 100644 apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 981120398..28d5fec31 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -194,6 +194,7 @@ import { isSbmrgDngrMapRootNode, } from "./components/sbmrgDngrMap/sbmrgDngrMapTypes"; import { + FLOOD_DNGR_MAP_LAYER_NAME, FLOOD_DNGR_MAP_LAYER_TYPE, FLOOD_DNGR_MAP_POPUP_KEY, isFloodDngrMapRootNode, @@ -1176,11 +1177,24 @@ const Map = () => { const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 - // 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다. + // 지도 연계 팝업의 행을 선택할 때 기존 체크를 유지하면서 필요한 업무 레이어를 켜기 위한 요청이다. const [checkedLayerRequest, setCheckedLayerRequest] = useState<{ 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 [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 @@ -1856,6 +1870,74 @@ const Map = () => { [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(); }, []); @@ -2008,58 +2090,23 @@ const Map = () => { ) { return; } - - await select.resetSelectedFeature?.(); - const selectedFeatureIds = new Set(); - let floodLayerVisible = false; - 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 !== sggCd || featureFrequency !== 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); - }); + const selection = { + extent: extent as [number, number, number, number], + sggCd, + frequency, + analysisDateTime, }; + if (await applyFloodPredictionSelection(selection)) return; - map - .getLayers() - .getArray() - .forEach((layer: any) => { - if ( - String(layer.get?.("lyrTyNm") ?? "").trim() !== - FLOOD_DNGR_MAP_LAYER_TYPE - ) - return; - if (layer.getVisible?.() !== false) floodLayerVisible = true; - const source = layer.getSource?.(); - if (!source?.getFeatures) return; - selectLoadedGroupFeatures(source); - source.once?.("featuresloadend", () => - selectLoadedGroupFeatures(source), - ); - }); - if (!floodLayerVisible) { - (event.source as Window | null)?.postMessage?.( - { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, - event.origin, - ); - return; - } - map.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - }); - void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent); + pendingFloodPredictionPopupRef.current = { + 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; } @@ -2098,7 +2145,50 @@ const Map = () => { window.clearInterval(timer); window.removeEventListener("message", handlePopupMessage); }; - }, [loadFloodPredictionGridLayer]); + }, [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]); useEffect(() => { if (shouldShowGisEditSession) return; diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md new file mode 100644 index 000000000..7c5e1187d --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md @@ -0,0 +1,42 @@ +# 침수예측현황 도시침수지도 자동 선택 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택하면 사용자가 레이어 트리에서 직접 조작하지 않아도 도시침수지도 레이어를 자동으로 체크한다. +- 레이어 활성화 후 선택한 시군구와 빈도에 맞는 위치 이동, 피처 선택, 침수예측 오버레이 표시를 이어서 실행한다. + +## 원본 근거 + +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 94행, `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 자동 레이어 선택 비교 화면: 슬라이드 47, `SN_MN040106 조사결과 지도보기` + +## 변경 내용 + +- 하수관망도에 이미 연결된 `checkedLayerRequest`를 사용하여 `도시침수지도(정보제공포털)` 레이어를 기존 체크 상태에 추가한다. +- 도시침수지도 레이어가 활성화되기 전에 도착한 행 선택 정보는 임시 상태에 보관한다. +- 레이어가 준비되면 기존 시군구·빈도 피처 선택, 지도 범위 이동, 침수예측 오버레이 표시 로직을 실행한다. +- 제한 시간 안에 레이어가 준비되지 않으면 기존 수동 선택 안내 메시지를 팝업에 전달한다. + +## 영향 범위 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- 백엔드 API와 메시지 payload 계약은 변경하지 않는다. +- 기존에 사용자가 체크한 다른 지도 레이어는 유지한다. +- 도시침수지도 미제공 빈도에 대한 기존 안내는 유지한다. + +## 검증 + +- `pnpm --filter sn exec tsc --noEmit` 통과 +- `git diff --check` 통과 +- 개발 서버가 3002 포트에서 실행 중이므로 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `map/page.tsx`의 도시침수지도 자동 체크 요청, 보류 상태, 재시도 effect를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다. + +## 남은 확인 + +- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다. +- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다. From 735bb397ec910e15170f1ecd32fee546090825da Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 30 Sep 2026 18:09:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?sn:=20=EC=B9=A8=EC=88=98=EC=98=88=EC=B8=A1?= =?UTF-8?q?=ED=98=84=ED=99=A9=20=EB=B6=84=EC=84=9D=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...FR-011-008-침수예측현황-분석시각초기화-구현기록.md | 39 +++++++++++++++++++ .../ui/FldngPredcPrstPopupContent.tsx | 27 ++++++------- 2 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md new file mode 100644 index 000000000..80e0a5d6b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md @@ -0,0 +1,39 @@ +# 침수예측현황 분석시각 초기화 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업을 처음 열거나 검색조건을 초기화할 때 분석시각 범위를 자동 입력하지 않는다. +- 분석시각 제한 없이 조회한 결과는 최근 분석 데이터부터 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 승인사항: 분석시각 기본 검색조건 제거 및 최근 데이터 우선 표시 + +## 변경 내용 + +- 최초·초기화 검색조건의 `analysisFrom`, `analysisTo`를 빈 값으로 변경했다. +- 목록과 Excel 전체 조회에 `analysisDateTime,desc` 정렬을 명시했다. +- 사용자가 필요할 때 직접 분석시각 범위를 입력할 수 있도록 검색 입력란은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` +- 백엔드 API와 응답 구조는 변경하지 않는다. +- 시도, 시군구, 강우시각, 초과 빈도 검색조건은 기존 동작을 유지한다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `buildInitialFilters`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다. + +## 남은 확인 + +- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다. +- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다. diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index 67249e17f..91e94b3a7 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -17,7 +17,6 @@ import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; -import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; @@ -111,21 +110,16 @@ const HEADS: UiHeadProps[] = [ { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, ]; -/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */ -const buildInitialFilters = (): Filters => { - const range = createPredictionAnalysisTimeRange(); - const toDateTimeLocal = (value: string) => - `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`; - return { - ctpvCd: "0", - sggCd: "0", - rainBgngFrom: "", - rainBgngTo: "", - analysisFrom: toDateTimeLocal(range.analysisFrom), - analysisTo: toDateTimeLocal(range.analysisTo), - frequencyYear: "0", - }; -}; +/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */ +const buildInitialFilters = (): Filters => ({ + ctpvCd: "0", + sggCd: "0", + rainBgngFrom: "", + rainBgngTo: "", + analysisFrom: "", + analysisTo: "", + frequencyYear: "0", +}); const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); @@ -151,6 +145,7 @@ const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => { if (page !== undefined) { params.set("page", String(page - 1)); params.set("size", String(size)); + params.set("sort", "analysisDateTime,desc"); } appendApiSearchParams(params, { ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),