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 b060b7564..582016955 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -72,8 +72,8 @@ const ANALYSIS_POPUP_ITEMS: Array<{ icon: ChartColumnBig, }, { - key: "floodPredictionMapPopup", - label: windowPopupDefinitions.floodPredictionMapPopup.title, + key: "fldngPredcPrst", + label: windowPopupDefinitions.fldngPredcPrst.title, icon: Waves, }, { 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 a1d76a322..f1834d748 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -17,6 +17,7 @@ import { WINDOW_POPUP_MESSAGE_TYPE, type WindowPopupContentProps, } from "@/features/windowPopup/model/types"; + import { fetchWaterRiskAreas, fetchWaterRiskMapView, @@ -45,6 +46,7 @@ type ResultFilters = { const WATER_RISK_PAGE_SIZE = 10; const EMPTY_RESULT_PAGE: WaterRiskResultPage = { items: [], + analysisBaseAt: null, total: 0, page: 0, size: WATER_RISK_PAGE_SIZE, @@ -57,73 +59,44 @@ type WaterRiskTableRow = Record & { sidoName: string; sggName: string; areaName: string; + rainStartedAt: string; currentAccumRainQty: string; forecastRainQty: string; - maxRainIntensity: string; - runoffFlowQty: string; - conveyQty: string; - analysisDisplay: string; + maxFloodingAt: string; + maxFloodingVolume: string; riskStatus: WaterRiskResultRow["riskStatus"]; }; -const CURRENT_RESULT_HEADLIST: UiHeadProps[] = [ +const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [ { id: "rowNo", title: "순번", widthPercent: 6 }, - { id: "sidoName", title: "시도", widthPercent: 9 }, - { id: "sggName", title: "시군구", widthPercent: 9 }, + { id: "sidoName", title: "시도", widthPercent: 8 }, + { id: "sggName", title: "시군구", widthPercent: 8 }, { id: "areaName", title: "중점관리지역", widthPercent: 14 }, + { id: "rainStartedAt", title: "강우시작시간", widthPercent: 13 }, { id: "currentAccumRainQty", - title: "현재누적강우량(mm)", - widthPercent: 13, + title: "누적강우량(mm)", + widthPercent: 11, align: "right", }, { id: "forecastRainQty", title: "예측강우량(mm)", - widthPercent: 12, - align: "right", - }, - { - id: "runoffFlowQty", - title: "유출유량(㎥/s)", widthPercent: 11, align: "right", }, { - id: "conveyQty", - title: "통수량(㎥/s)", + id: "maxFloodingAt", + title: "최대 침수 예상시점", + widthPercent: 13, + }, + { + id: "maxFloodingVolume", + title: "최대침수량(m³)", widthPercent: 10, align: "right", }, - { id: "analysisDisplay", title: "분석일", widthPercent: 10 }, - { id: "riskStatus", title: "통수위험", widthPercent: 7 }, -]; - -const HISTORY_RESULT_HEADLIST: UiHeadProps[] = [ - { id: "rowNo", title: "순번", widthPercent: 6 }, - { id: "sidoName", title: "시도", widthPercent: 9 }, - { id: "sggName", title: "시군구", widthPercent: 9 }, - { id: "areaName", title: "중점관리지역", widthPercent: 15 }, - { - id: "maxRainIntensity", - title: "최대강우강도(mm/hr)", - widthPercent: 15, - align: "right", - }, - { - id: "runoffFlowQty", - title: "유출유량(㎥/s)", - widthPercent: 12, - align: "right", - }, - { - id: "conveyQty", - title: "통수량(㎥/s)", - widthPercent: 11, - align: "right", - }, - { id: "analysisDisplay", title: "분석일", widthPercent: 12 }, - { id: "riskStatus", title: "통수위험", widthPercent: 11 }, + { id: "riskStatus", title: "위험 여부", widthPercent: 7 }, ]; const formatOptionalNumber = ( @@ -139,12 +112,26 @@ const formatLocalDate = (date: Date) => { return `${year}-${month}-${day}`; }; -/** 화면을 연 날짜를 현재 위험 기본 조회일로 사용한다. */ +/** 현재 로컬시각을 API가 조회할 정시 yyyy-MM-ddTHH:00:00으로 내림한다. */ +const formatCurrentAnalysisHour = (date: Date) => + `${formatLocalDate(date)}T${String(date.getHours()).padStart(2, "0")}:00:00`; + +/** API 분석기준시간을 화면의 yyyy.MM.dd HH:mm 형식으로 표시한다. */ +const formatAnalysisBaseAt = (value?: string | null) => { + if (!value || value === "-") return "-"; + const normalized = value.replace("T", " "); + const [date = "", time = ""] = normalized.split(" "); + return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim(); +}; + +/** 개발 DB의 과거 시험자료와 운영 최신자료를 함께 찾도록 최근 1년을 기본 조회기간으로 사용한다. */ const buildDefaultResultFilters = (): ResultFilters => { - const today = formatLocalDate(new Date()); + const endDate = new Date(); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 365); return { - startDate: today, - endDate: today, + startDate: formatLocalDate(startDate), + endDate: formatLocalDate(endDate), ctpvCd: "ALL", sggCd: "ALL", areaId: "ALL", @@ -183,6 +170,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const [selectedResultKey, setSelectedResultKey] = useState(""); const [resultCurrentPage, setResultCurrentPage] = useState(1); const [reloadVersion, setReloadVersion] = useState(0); + const [currentAnalysisBaseAt, setCurrentAnalysisBaseAt] = useState(() => + formatCurrentAnalysisHour(new Date()), + ); const [isLoading, setIsLoading] = useState(false); const [isMapLoading, setIsMapLoading] = useState(false); const [noticeMessage, setNoticeMessage] = useState(""); @@ -214,12 +204,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { fetchWaterRiskResults({ startDate: appliedResultFilters.startDate, endDate: appliedResultFilters.endDate, + analysisBaseAt: + resultMode === "current" ? currentAnalysisBaseAt : undefined, ctpvCd: - appliedResultFilters.ctpvCd === "ALL" + resultMode === "current" || appliedResultFilters.ctpvCd === "ALL" ? undefined : appliedResultFilters.ctpvCd, sggCd: - appliedResultFilters.sggCd === "ALL" + resultMode === "current" || appliedResultFilters.sggCd === "ALL" ? undefined : appliedResultFilters.sggCd, areaId: @@ -227,9 +219,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { ? undefined : appliedResultFilters.areaId, riskStatus: - appliedResultFilters.riskStatus === "ALL" + resultMode === "current" || appliedResultFilters.riskStatus === "ALL" ? undefined : appliedResultFilters.riskStatus, + rainEventStatus: resultMode === "current" ? "IN_PROGRESS" : "ENDED", page: resultCurrentPage - 1, size: WATER_RISK_PAGE_SIZE, }) @@ -257,7 +250,13 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { return () => { active = false; }; - }, [appliedResultFilters, reloadVersion, resultCurrentPage]); + }, [ + appliedResultFilters, + currentAnalysisBaseAt, + reloadVersion, + resultCurrentPage, + resultMode, + ]); useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -284,9 +283,34 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { useEffect(() => { if (typeof window === "undefined" || !window.resizeTo) return; - window.resizeTo(1180, 720); + window.resizeTo(1360, 600); }, []); + /** 현재 탭의 표시·조회 기준시간을 매 정시에 새 현재시간으로 변경한다. */ + useEffect(() => { + if (resultMode !== "current" || typeof window === "undefined") return; + + let intervalId: number | undefined; + const applyCurrentHour = () => + setCurrentAnalysisBaseAt(formatCurrentAnalysisHour(new Date())); + applyCurrentHour(); + const now = new Date(); + const nextRefreshAt = new Date(now); + nextRefreshAt.setHours(now.getHours() + 1, 0, 0, 0); + const timeoutId = window.setTimeout( + () => { + applyCurrentHour(); + intervalId = window.setInterval(applyCurrentHour, 60 * 60 * 1000); + }, + Math.max(0, nextRefreshAt.getTime() - now.getTime()), + ); + + return () => { + window.clearTimeout(timeoutId); + if (intervalId != null) window.clearInterval(intervalId); + }; + }, [resultMode]); + const ctpvOptions = useMemo(() => { const unique = new Map(); areaOptions.forEach((option) => { @@ -334,25 +358,18 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const tableRows = useMemo( () => resultPage.items.map((row) => { - const representativePipe = - row.pipeSummaries.find((pipe) => pipe.riskStatus === "DANGER") ?? - row.pipeSummaries[0]; - return { sourceRow: row, rowNo: 0, sidoName: row.sidoName || "-", sggName: row.sggName || "-", areaName: row.areaName, + rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt), currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1), forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1), - maxRainIntensity: formatOptionalNumber(row.maxRainIntensity, 1), - runoffFlowQty: formatOptionalNumber( - representativePipe?.runoffFlowQty, - 2, - ), - conveyQty: formatOptionalNumber(representativePipe?.conveyQty, 3), - analysisDisplay: row.maxFloodingAt || row.analysisDate || "-", + maxFloodingAt: formatAnalysisBaseAt(row.maxFloodingAt), + // 목록 API에는 최대 침수유량만 있고 침수량 합계는 아직 제공하지 않는다. + maxFloodingVolume: "-", riskStatus: row.riskStatus, }; }), @@ -376,10 +393,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { setNoticeMessage("통수위험 분석 결과를 조회했습니다."); }; - /** 현재 위험과 과거 이력 표시열만 전환하고 같은 API 검색조건은 유지한다. */ + /** 강우이벤트 상태에 따라 진행중 현재 위험과 종료된 과거 이력을 다시 조회한다. */ const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); + setResultPage(EMPTY_RESULT_PAGE); + setSelectedResultKey(""); setNoticeMessage(""); }; @@ -417,102 +436,116 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { }`} > - - handleChangeResultMode(value as WaterRiskResultMode) - } - /> +
+ + handleChangeResultMode(value as WaterRiskResultMode) + } + /> + {resultMode === "current" ? ( +
+ 분석기준시간 + + {formatAnalysisBaseAt(currentAnalysisBaseAt)} + +
+ ) : null} +
- + {resultMode === "history" ? ( + <> + - + - + + + ) : null}