diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
index 582016955..fa6aa96c3 100644
--- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
+++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
@@ -12,6 +12,8 @@ import {
TriangleAlert,
Waves,
} from "lucide-react";
+import { getRawJson } from "@/components/lib/api";
+import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
import {
windowPopupDefinitions,
type WindowPopupKey,
@@ -120,6 +122,32 @@ export default function AnalysisPopupMenu({
const [activeKey, setActiveKey] = useState<
WindowPopupKey | "infoSearch" | null
>(initialActiveKey);
+ const [seriousPredictionCount, setSeriousPredictionCount] = useState(0);
+
+ // 침수예측현황 팝업의 기본 분석시각 범위와 동일한 범위로 메뉴 건수를 조회한다.
+ useEffect(() => {
+ const range = createPredictionAnalysisTimeRange();
+ const params = new URLSearchParams({
+ analysisFrom: range.analysisFrom,
+ analysisTo: range.analysisTo,
+ });
+ let cancelled = false;
+ void getRawJson(
+ `/sewerage-service/api/v1/fldng/predc/prst/count?${params.toString()}`,
+ )
+ .then((response) => {
+ if (cancelled) return;
+ const payload = response?.data ?? response;
+ const count = Number(payload);
+ setSeriousPredictionCount(Number.isFinite(count) ? count : 0);
+ })
+ .catch(() => {
+ if (!cancelled) setSeriousPredictionCount(0);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
@@ -171,6 +199,10 @@ export default function AnalysisPopupMenu({
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
+ const label =
+ item.key === "fldngPredcPrst"
+ ? `침수예측현황(${seriousPredictionCount}건)`
+ : item.label;
return (
) : null}
- {item.label}
+ {label}
);
})}
diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx
index d90df246e..521bfecc7 100644
--- a/apps/sn/app/(map-layout)/map/page.tsx
+++ b/apps/sn/app/(map-layout)/map/page.tsx
@@ -203,6 +203,15 @@ 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);
@@ -228,6 +237,24 @@ const createExmnManholeStyle = () =>
}),
});
+/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */
+const createFloodPredictionOverlayStyle = (feature: any) => {
+ const risk = String(feature?.get?.("floodRiskGrade") ?? "-");
+ const strokeColor = risk === "심각" ? "#dc2626" : risk === "경계" ? "#f97316" : "#2563eb";
+ return new Style({
+ image: new CircleStyle({ radius: 8, fill: new Fill({ color: strokeColor }), stroke: new Stroke({ color: "#ffffff", width: 2 }) }),
+ text: new Text({
+ text: String(feature?.get?.("floodPredictionLabel") ?? ""),
+ font: "bold 12px sans-serif",
+ textAlign: "center",
+ textBaseline: "middle",
+ fill: new Fill({ color: "#111827" }),
+ stroke: new Stroke({ color: "#ffffff", width: 4 }),
+ overflow: true,
+ }),
+ });
+};
+
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
@@ -3209,6 +3236,9 @@ const Map = () => {
const waterRiskPipeSourceRef = useRef(null);
const waterRiskManholeLayerRef = useRef | null>(null);
const waterRiskManholeSourceRef = useRef(null);
+ // 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다.
+ const floodPredictionGridLayerRef = useRef | null>(null);
+ const floodPredictionGridSourceRef = useRef(null);
const gisEditDrawRef = useRef(null);
const gisEditDrawSnapRef = useRef(null);
const gisEditTranslateRef = useRef(null);
@@ -3483,6 +3513,56 @@ const Map = () => {
}
}, []);
+ /** 기존 침수예측 중앙 오버레이를 제거한다. */
+ const removeFloodPredictionGridLayer = useCallback(() => {
+ const core = GisApp.Module.core as any;
+ core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID);
+ floodPredictionGridLayerRef.current = null;
+ floodPredictionGridSourceRef.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/grid-map?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 source = new VectorSource();
+ const center = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
+ const feature = new Feature({ geometry: new Point(center) });
+ const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1);
+ const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1);
+ feature.set("floodRiskGrade", row.floodRiskGrade ?? "-");
+ feature.set("floodPredictionLabel", `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"}\n분석시각 ${formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime)}\n강우시작시각 ${formatFloodPredictionDateTime(row.rainBeginningDateTime)}\n관측 누적강수량 ${observed}mm\n예측시각 ${formatFloodPredictionDateTime(row.predictionDateTime)}\n예측 누적강수량 ${predicted}mm\n초과 빈도 ${row.exceededFrequencyYear ?? "-"}년\n침수위험도 ${row.floodRiskGrade ?? "-"}`);
+ source.addFeature(feature);
+ const layer = new VectorLayer({
+ source,
+ zIndex: 9997,
+ style: createFloodPredictionOverlayStyle,
+ updateWhileAnimating: false,
+ updateWhileInteracting: false,
+ });
+ layer.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
+ layer.set("lyrNm", "침수예측지도 중앙 오버레이");
+ layer.set("lyrTyNm", "SN_FLDNG_PREDICTION_OVERLAY");
+ layer.set("clickPsblYn", "N");
+ map.addLayer(layer);
+ floodPredictionGridLayerRef.current = layer;
+ floodPredictionGridSourceRef.current = source;
+ } catch (error) {
+ console.error("침수예측지도 오버레이 표시 실패:", error);
+ }
+ },
+ [removeFloodPredictionGridLayer],
+ );
+
useEffect(() => {
fetchData();
}, []);
@@ -3602,6 +3682,7 @@ const Map = () => {
: [];
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 (
@@ -3665,6 +3746,7 @@ const Map = () => {
padding: [50, 50, 50, 50],
maxZoom: 16,
});
+ void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent);
return;
}
@@ -3703,7 +3785,7 @@ const Map = () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
- }, []);
+ }, [loadFloodPredictionGridLayer]);
useEffect(() => {
if (shouldShowGisEditSession) return;
diff --git a/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md
new file mode 100644
index 000000000..6fdc82af7
--- /dev/null
+++ b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md
@@ -0,0 +1,7 @@
+# SFR-011-008 침수예측지도 격자 표시 검증계획
+
+- 침수예측현황 행 클릭 시 기존 지도 extent 이동 확인
+- `grid-map` API의 시군구·분석시각 파라미터와 WFS `latc_cd` 필터 확인
+- 각 격자 중앙 `latc_cd`·침수위험도·현재/예측 누적강우 표시 확인
+- 행 재선택 시 기존 overlay 제거 및 신규 격자 갱신 확인
+- BE 단위 테스트·컴파일 및 SN 브라우저 콘솔 오류 확인
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 3c30fb406..e8cd9a94f 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
@@ -31,6 +31,9 @@
- `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다.
- Excel은 현재 적용 검색조건을 동일하게 전달한다.
- 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다.
+- 결과표는 `시도, 시군구, 분석시각, 강우시작시각, 관측 누적강수량, 예측시간, 예측 누적강수량, 초과 빈도(년), 침수위험도` 순서로 표시한다.
+- 예측시간은 `N시간 후`, 관측·예측 누적강수량은 소수점 한 자리로 표시한다.
+- 지도화면 분석도구는 공용 분석시각 범위와 `심각` 조건의 count API를 조회하여 `침수예측현황(x건)`으로 표시한다.
## 지도 연계
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..8dbbdd0a2
--- /dev/null
+++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md
@@ -0,0 +1,22 @@
+# SFR-011-008 침수예측지도 중앙 오버레이 표시 FE 작업계획
+
+## 목적
+
+침수예측현황 선택행으로 지도 이동 시 선택한 도시침수지도 중앙에 시도, 시군구, 분석시각, 강우시작시각, 관측 누적강수량(mm), 예측시각, 예측 누적강수량(mm), 초과 빈도(년), 침수위험도를 포함한 단일 오버레이를 표시한다. 격자별 라벨은 표시하지 않는다.
+
+## 구현 방법
+
+- 현황 팝업 메시지에 `analysisDateTime`을 추가한다.
+- 지도 페이지에서 BE `grid-map` API를 호출한다.
+- 기존 도시침수지도 extent 중심 좌표에 위 9개 표시 항목을 줄바꿈 라벨로 렌더링한다.
+- 초단기예보격자망 WFS 폴리곤은 기존 레이어 표현을 유지한다.
+- 다음 선택 시 전용 중앙 overlay를 삭제하고 새 응답으로 갱신한다.
+
+현재·예측 누적강우는 FE에서 격자별로 계산하지 않는다. BE가 반환하는 선택 시군구 분석행의 `obsrvn_acml_rn`, `predc_acml_rn`을 사용하며, 선택 도시침수지도 중앙의 단일 overlay에 표시한다.
+
+## 검증
+
+- 슬라이드 4의 선택 시군구 도시침수지도 중앙 정보 배치
+- 다른 행 선택 시 중앙 overlay 교체
+- 기존 지도 extent 이동·침수위험 레이어 선택 유지
+- API 오류 시 기존 지도 이동 기능 유지
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..9a3b4f0b1
--- /dev/null
+++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md
@@ -0,0 +1,17 @@
+# SFR-011-008 침수예측지도 격자 표시 FE 구현기록
+
+## 구현 내용
+
+- 침수예측현황 선택행의 `analysisDateTime`을 지도 이동 메시지에 포함했다.
+- BE `grid-map` 응답의 `latcCd`를 GeoServer `tb_ms_shtm_frcst_latc_info` WFS CQL 필터로 사용한다.
+- 격자 폴리곤 내부점에 `latc_cd`, 침수위험도, 현재·예측 누적강우 라벨을 표시한다.
+- `SN_FLDNG_PREDICTION_GRID_LABEL` 전용 레이어로 재선택 시 오버레이를 교체한다.
+
+## 설계 변경 대기
+
+사용자 확인에 따라 최종 구현 대상은 격자별 라벨이 아니라 선택한 도시침수지도 중앙의 단일 오버레이로 변경되었다. 기존 격자별 구현은 중앙 오버레이 구현으로 교체한다.
+
+## 기준
+
+- `침수예측.pptx` 4페이지
+- 요구사항 `SFR-011-008`
diff --git a/apps/sn/src/features/fldngPredcPrst/model/analysisTimeRange.ts b/apps/sn/src/features/fldngPredcPrst/model/analysisTimeRange.ts
new file mode 100644
index 000000000..2e60793ab
--- /dev/null
+++ b/apps/sn/src/features/fldngPredcPrst/model/analysisTimeRange.ts
@@ -0,0 +1,9 @@
+/** 침수예측현황 목록과 분석도구 메뉴가 공유하는 기본 분석시각 범위다. */
+export const createPredictionAnalysisTimeRange = (now = new Date()) => {
+ const from = new Date(now);
+ from.setHours(0, 0, 0, 0);
+ const to = new Date(now);
+ const toApiDateTime = (value: Date) =>
+ `${value.getFullYear()}${String(value.getMonth() + 1).padStart(2, "0")}${String(value.getDate()).padStart(2, "0")}${String(value.getHours()).padStart(2, "0")}${String(value.getMinutes()).padStart(2, "0")}`;
+ return { analysisFrom: toApiDateTime(from), analysisTo: toApiDateTime(to) };
+};
diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx
index b7aa57bc5..93c588c75 100644
--- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx
+++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx
@@ -17,6 +17,7 @@ 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";
type ApiRow = {
sggCd: string;
@@ -26,8 +27,8 @@ type ApiRow = {
rainBeginningDateTime: string;
analysisDateTime: string;
observedAccumulatedRainfall: number | null;
- rainHour: number | null;
- rainfall: number | null;
+ predictionHour: number | null;
+ predictedAccumulatedRainfall: number | null;
exceededFrequencyYear: number | null;
floodRiskGrade: string;
};
@@ -35,11 +36,11 @@ type ApiRow = {
type TableRow = ApiRow &
Record & {
rowNo: number;
- rainBeginningDateTimeDisplay: string;
analysisDateTimeDisplay: string;
+ rainBeginningDateTimeDisplay: string;
observedAccumulatedRainfallDisplay: string;
- rainHourDisplay: string;
- rainfallDisplay: string;
+ predictionHourDisplay: string;
+ predictedAccumulatedRainfallDisplay: string;
exceededFrequencyYearDisplay: string;
};
@@ -76,8 +77,8 @@ const HEADS: UiHeadProps[] = [
},
{
id: "rainBeginningDateTimeDisplay",
- title: "강우시각",
- widthPercent: 13,
+ title: "강우시작시각",
+ widthPercent: 16,
},
{
id: "observedAccumulatedRainfallDisplay",
@@ -86,14 +87,14 @@ const HEADS: UiHeadProps[] = [
align: "right",
},
{
- id: "rainHourDisplay",
- title: "강우시간",
- widthPercent: 8,
+ id: "predictionHourDisplay",
+ title: "예측시간",
+ widthPercent: 9,
},
{
- id: "rainfallDisplay",
- title: "강수량(mm)",
- widthPercent: 10,
+ id: "predictedAccumulatedRainfallDisplay",
+ title: "예측 누적강수량(mm)",
+ widthPercent: 15,
align: "right",
},
{
@@ -102,25 +103,21 @@ const HEADS: UiHeadProps[] = [
widthPercent: 10,
align: "right",
},
- { id: "floodRiskGrade", title: "위험등급", widthPercent: 10 },
+ { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 },
];
-/** 브라우저 로컬시각을 datetime-local 입력값으로 변환한다. */
-const localDateTime = (date: Date) =>
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
-
/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */
const buildInitialFilters = (): Filters => {
- const now = new Date();
- const from = new Date(now);
- from.setHours(0, 0, 0, 0);
+ 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: localDateTime(from),
- analysisTo: localDateTime(now),
+ analysisFrom: toDateTimeLocal(range.analysisFrom),
+ analysisTo: toDateTimeLocal(range.analysisTo),
frequencyYear: "",
};
};
@@ -132,9 +129,10 @@ const displayDate = (value: string) =>
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
: value || "-";
-const rainHourText = (value: number | null) => {
+/** 분석시각 기준 예측시간을 사용자 표시문구로 변환한다. */
+const predictionHourText = (value: number | null) => {
const hour = Number(value ?? 0);
- return hour === 0 ? "현재" : `${hour > 0 ? "+" : "-"}${Math.abs(hour)}시간`;
+ return hour > 0 ? `${hour}시간 후` : "-";
};
const formatNumber = (value: number | null, digits: number) =>
@@ -189,7 +187,7 @@ const validateFilters = (filters: Filters) => {
};
const buildRowId = (row: ApiRow) =>
- `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.rainHour}`;
+ `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`;
const isDangerGrade = (value: string) => {
const grade = String(value ?? "")
@@ -262,16 +260,19 @@ export default function FldngPredcPrstPopupContent() {
content.map((item) => ({
...item,
rowNo: 0,
+ analysisDateTimeDisplay: displayDate(item.analysisDateTime),
rainBeginningDateTimeDisplay: displayDate(
item.rainBeginningDateTime,
),
- analysisDateTimeDisplay: displayDate(item.analysisDateTime),
observedAccumulatedRainfallDisplay: formatNumber(
item.observedAccumulatedRainfall,
- 4,
+ 1,
+ ),
+ predictionHourDisplay: predictionHourText(item.predictionHour),
+ predictedAccumulatedRainfallDisplay: formatNumber(
+ item.predictedAccumulatedRainfall,
+ 1,
),
- rainHourDisplay: rainHourText(item.rainHour),
- rainfallDisplay: formatNumber(item.rainfall, 1),
exceededFrequencyYearDisplay:
item.exceededFrequencyYear == null
? "-"
@@ -359,7 +360,12 @@ export default function FldngPredcPrstPopupContent() {
window.opener.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
- payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln },
+ payload: {
+ extent,
+ sggCd: row.sggCd,
+ sbmrgFrqExpln,
+ analysisDateTime: row.analysisDateTime,
+ },
},
window.location.origin,
);
diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
index 9663e9792..369d810d8 100644
--- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
+++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts
@@ -186,7 +186,7 @@ export const windowPopupDefinitions = {
key: "fldngPredcPrst",
title: "침수예측현황",
width: 1700,
- height: 900,
+ height: 820,
},
agedPipeStatusByCtpvPopup: {
key: "agedPipeStatusByCtpvPopup",