diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx index fa2e8cf84..2077532aa 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -174,9 +174,16 @@ const postMessageToOpener = ( const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; -/** 날짜까지 포함해 과거 이력에서도 결과 행 선택을 유일하게 식별한다. */ +/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */ const buildResultRowKey = (row: WaterRiskResultRow) => - `${row.analysisDate ?? ""}:${row.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`; + [ + row.analysisDate ?? "", + row.rainStartedAt ?? "", + row.analysisBaseAt ?? "", + row.ctpvCd ?? "", + row.sggCd ?? "", + row.areaId, + ].join(":"); /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { @@ -433,6 +440,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); + setResultSort(undefined); setResultPage(EMPTY_RESULT_PAGE); setSelectedResultKey(""); }; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index f5cb5e3b6..002da74ed 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -434,6 +434,7 @@ const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [ 50, 50, 50, 50, ]; const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; +const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info"; type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; @@ -546,6 +547,21 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { }); }; +// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다. +const normalizeRainObservationLayerInteraction = (data: any[]): any[] => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + const children = Array.isArray(item.children) + ? normalizeRainObservationLayerInteraction(item.children) + : item.children; + + return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE + ? { ...item, children, clickPsblYn: "N" } + : { ...item, children }; + }); +}; + // 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. const getFiniteNumber = (value: unknown) => { if (value === null || value === undefined || String(value).trim() === "") @@ -3416,6 +3432,17 @@ const Map = () => { [selectedWaterRiskApiResult], ); + // 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다. + const selectedWaterRiskDisplayStatus = useMemo< + WaterRiskResultRow["riskStatus"] + >( + () => + selectedWaterRiskMapView?.riskStatus ?? + selectedWaterRiskResultRow?.riskStatus ?? + "NORMAL", + [selectedWaterRiskMapView?.riskStatus, selectedWaterRiskResultRow?.riskStatus], + ); + /** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */ const syncWaterRiskInfoCardPosition = useCallback(() => { const map = gisMaps.current; @@ -3590,6 +3617,7 @@ const Map = () => { await select.resetSelectedFeature?.(); const selectedFeatureIds = new Set(); + let floodLayerVisible = false; const selectLoadedGroupFeatures = (source: any) => { const features = source?.getFeatures?.() ?? []; features.forEach((feature: any) => { @@ -3617,6 +3645,7 @@ const Map = () => { FLOOD_DNGR_MAP_LAYER_TYPE ) return; + if (layer.getVisible?.() !== false) floodLayerVisible = true; const source = layer.getSource?.(); if (!source?.getFeatures) return; selectLoadedGroupFeatures(source); @@ -3624,6 +3653,13 @@ const Map = () => { selectLoadedGroupFeatures(source), ); }); + if (!floodLayerVisible) { + event.source?.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], @@ -3793,7 +3829,11 @@ const Map = () => { appendExmnResultControlNodes( appendAgedPipeControlNode( appendSinkholeControlNode( - normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)), + normalizeTreeRowSpacing( + normalizeRainObservationLayerInteraction( + normalizeFlowDirectionLayerInfos(data), + ), + ), ), ), exmnMapEntryContext.jobType, @@ -8765,20 +8805,24 @@ const Map = () => { {buildWaterRiskStatusLabel( - selectedWaterRiskResultRow.riskStatus, + selectedWaterRiskDisplayStatus, )}
- capacity 0.8 초과 관로 존재 시{" "} - 위험, 없으면{" "} - 정상 +
+ GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 +
+
+ 위험, 없으면{" "} + 정상 +
diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md index ee5442281..81231107a 100644 --- a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -145,7 +145,7 @@ - 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다. - 행 클릭 성공 안내문 제거와 오류 메시지 조건부 렌더링을 확인했다. - 보령 동대궁촌의 목록 최대 침수유량과 지도카드 값이 모두 `121.48m³/s`로 일치함을 실 API로 확인했다. -- 지도카드 위험/정상 판정 설명과 `capacity > 0.8` 서비스 기준이 일치한다. +- 지도카드 안내는 GIS 매핑 관로의 `Capacity >= 0.8` 지역 위험 기준만 표시한다. - 페이지별 데이터 길이·스크롤바에 따른 전체표 확대축소를 제거하고 `1320px` 고정폭·10개 열 비율·긴 행정명 말줄임 처리를 확인했다. - 팝업 컴포넌트에 `window.resizeTo` 호출이 없고 창 크기는 `popupDefinitions`의 `1380×900`으로만 결정되는 것을 정적 확인했다. 검색·결과영역의 overflow 숨김과 결과영역 좌우 여백 보정은 유지한다. @@ -192,3 +192,8 @@ - 과거 종료결과에서 실제 `2026-07-01 01:00`, `03:00`, `05:00` 등의 분석기준시간이 반환됐다. - 과거 종료결과 전체의 상단 최신 분석기준시간은 `2026-07-06 08:00`으로 반환됐다. - Java Service 테스트와 FE 대상 ESLint가 통과했다. + +## 14. 2026-08-26 과거분석이력 최신순·중복 키 검증 + +- 정렬 파라미터가 없는 기본 요청도 백엔드 `RAIN_BGNG_DT DESC`에 따라 2026-08-26 07:00, 2026-08-25 13:00, 10:00, 06:00 순으로 반환된다. +- 2026-08-25 경기 가평 지역 2건은 기존 키가 1개로 충돌했지만 강우시작시간·분석기준시간을 포함한 신규 키는 2개 모두 고유하다. 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 index bb425c07b..3c30fb406 100644 --- 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 @@ -36,7 +36,7 @@ - 행 선택 시 기존 도시침수지도 group extent API를 호출한다. - 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다. -- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다. +- 도시침수지도 빈도는 30년 이하를 `030`, 100년 초과를 `100`으로 보정하고 그 외 미지원 빈도는 안내한다. ## 검증 diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md index e0e0b5521..9dc07998d 100644 --- a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -216,7 +216,7 @@ - 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다. - `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다. - 행 선택 성공 후 왼쪽 아래 안내문은 제거하고 오류 메시지만 조건부 표시한다. -- 지도 요약카드에는 capacity 0.8 초과 관로 존재 여부가 위험/정상을 결정한다는 설명을 추가했다. +- 지도 요약카드에는 GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험이라는 기준만 간결하게 표시한다. - 결과표 `No.`를 `No`로 바꾸고 표 전체를 `1320px` 고정폭으로 지정한 뒤 `table-fixed`·`colgroup` 비율을 적용해 1·2페이지의 절대 열 너비를 동일하게 고정했다. - 공통 그리드 교체 후 검색영역과 결과영역의 가로·세로 overflow를 숨기고 결과영역 좌우 여백을 보정해 팝업 내부 스크롤바가 나타나지 않게 했다. @@ -245,3 +245,9 @@ - 중점관리지역 아래 capacity 설명 자체에는 구분선을 두지 않고 문장 안의 `위험`은 빨간색, `정상`은 파란색 텍스트로 강조한다. 설명과 수치표의 경계인 표 상단선은 유지한다. - 기존 위험상태 배지와 카드 `290px` 폭을 유지한다. - 팝업은 `pagehide`·`beforeunload`에서 `WATER_RISK_CLOSED` 메시지를 부모로 전달한다. 부모는 메시지 수신 즉시 중점관리지역·관로·맨홀 레이어, capacity/Flooding 범례, 정보카드와 선택상태를 모두 초기화하며 300ms close watcher는 예비 감시로 유지한다. + +## 17. 2026-08-26 과거분석이력 최신순 기본 정렬·행 키 보완 + +- 팝업 최초 조회와 현재·과거 탭 전환 시 정렬 파라미터를 비워 백엔드 기본 강우시작시간 최신순을 적용한다. +- 사용자가 다른 정렬 열을 선택하면 기존 서버 정렬을 유지하고, 탭을 전환하면 다시 강우시작시간 최신순으로 돌아간다. +- 동일 날짜·지역에 여러 강우이벤트가 존재해도 React 행 키가 겹치지 않도록 분석일·강우시작시간·분석기준시간·행정코드·지역번호를 조합한다. diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index a1c782952..b7aa57bc5 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -8,7 +8,7 @@ import { } from "@common/business_components"; import { Button } from "@common/components"; -import { getBlobData, getRawJson } from "@/components/lib/api"; +import { getBlobData, getData, getRawJson } from "@/components/lib/api"; import { fileDownload } from "@/components/lib/utils/fileDownload"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; @@ -57,18 +57,26 @@ const PAGE_SIZE = 10; const MAP_FREQUENCIES = new Set([30, 50, 80, 100]); const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const; +/** 도시침수지도 DB의 3자리 빈도 코드로 변환한다. 30년 이하는 030, 100년 초과는 100으로 제한한다. */ +const toFloodMapFrequencyCode = (value: number) => { + if (!Number.isFinite(value) || value <= 0) return null; + const normalized = value <= 30 ? 30 : value > 100 ? 100 : value; + if (!MAP_FREQUENCIES.has(normalized)) return null; + return String(normalized).padStart(3, "0"); +}; + const HEADS: UiHeadProps[] = [ { id: "rowNo", title: "순번", widthPercent: 6 }, { id: "ctpvNm", title: "시도", widthPercent: 8 }, { id: "sggNm", title: "시군구", widthPercent: 9 }, { - id: "rainBeginningDateTimeDisplay", - title: "강우시각", + id: "analysisDateTimeDisplay", + title: "분석시각", widthPercent: 13, }, { - id: "analysisDateTimeDisplay", - title: "분석시각", + id: "rainBeginningDateTimeDisplay", + title: "강우시각", widthPercent: 13, }, { @@ -208,6 +216,21 @@ export default function FldngPredcPrstPopupContent() { // 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다. const didInitialLoadRef = useRef(false); + // 부모 지도에서 도시침수지도 레이어가 비활성 상태임을 알려오면 팝업에서 안내한다. + useEffect(() => { + const handleMapMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if ( + event.data?.type === + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED + ) { + alert("지도의 좌측 도시침수지도를 선택하여야 지도에 보입니다."); + } + }; + window.addEventListener("message", handleMapMessage); + return () => window.removeEventListener("message", handleMapMessage); + }, [alert]); + useEffect(() => { if (!ctpv.length) void fetchCtpv(); if (!sgg.length) void fetchSgg(); @@ -297,7 +320,8 @@ export default function FldngPredcPrstPopupContent() { /** 선택 행과 동일한 시군구·빈도의 도시침수지도 범위를 부모 지도에 전달한다. */ const handleRowClick = async (row: TableRow) => { const frequencyYear = Number(row.exceededFrequencyYear ?? 0); - if (!MAP_FREQUENCIES.has(frequencyYear)) { + const sbmrgFrqExpln = toFloodMapFrequencyCode(frequencyYear); + if (!sbmrgFrqExpln) { alert( `${frequencyYear || "해당"}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80·100년 빈도만 제공)`, ); @@ -315,12 +339,12 @@ export default function FldngPredcPrstPopupContent() { setSelectedRowId(buildRowId(row)); setLoading(true); try { - const sbmrgFrqExpln = `${frequencyYear}년`; const params = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln, }); - const extentData = await getRawJson( + // 백엔드 공통 응답({ data: ... })의 data 영역을 반환하는 API 헬퍼를 사용한다. + const extentData = await getData( `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, ); const extent = [ @@ -345,7 +369,7 @@ export default function FldngPredcPrstPopupContent() { alert( error instanceof Error && error.message.includes("위치 정보") ? error.message - : "도시침수지도 이동에 실패했습니다.", + : "도시침수지도 이동에 실패하였습니다.", ); } finally { setLoading(false); @@ -471,8 +495,8 @@ export default function FldngPredcPrstPopupContent() { - {renderDateTimeRange("강우시각", "rainBgngFrom", "rainBgngTo")} {renderDateTimeRange("분석시각", "analysisFrom", "analysisTo")} + {renderDateTimeRange("강우시각", "rainBgngFrom", "rainBgngTo")}