From 8b5a2b713c725c3421f7976aec640452e71e1943 Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 30 Sep 2026 17:53:50 +0900 Subject: [PATCH] =?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를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다. + +## 남은 확인 + +- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다. +- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다.