/** * 화면명: 통수위험 분석 * 통수위험 분석 결과와 과거 분석이력을 조회하고 지도에 표시한다. */ "use client"; import { useEffect, useMemo, useState, useRef } from "react"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import { WINDOW_POPUP_MESSAGE_TYPE, type WindowPopupContentProps, } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { StandardPopupLayout, type UiHeadProps, UITab, } from "@common/business_components"; import type { UiTableSort } from "@common/business_components/ui/table"; import { Button } from "@common/components"; import { formatWaterRiskHourValue, getWaterRiskMinStartDate, toWaterRiskApiDateTime, toWaterRiskHourValue, validateWaterRiskAnalysisPeriod, validateWaterRiskRainStartedPeriod, } from "./water-risk-date-range"; import { fetchWaterRiskAreas, fetchWaterRiskMapView, fetchWaterRiskResults, } from "./water-risk-client"; import { buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, isWaterRiskDanger, type WaterRiskAreaOption, type WaterRiskResultPage, type WaterRiskResultRow, type WaterRiskSessionSnapshot, type WaterRiskStatus, } from "./water-risk-types"; import LoadingOverlay from "@/components/ui/loading-bar"; type WaterRiskResultMode = "current" | "history"; /** * 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며 * 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다. */ type ResultFilters = { ctpvCd: string; sggCd: string; areaId: string; riskStatus: "ALL" | WaterRiskStatus; /** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */ analysisFrom: string; /** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */ analysisTo: string; /** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */ rainStartedFrom: string; /** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */ rainStartedTo: string; }; /** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */ type HourRangeKeys = | { fromKey: "analysisFrom"; toKey: "analysisTo" } | { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" }; const WATER_RISK_PAGE_SIZE = 10; const EMPTY_RESULT_PAGE: WaterRiskResultPage = { items: [], analysisBaseAt: null, total: 0, page: 0, size: WATER_RISK_PAGE_SIZE, totalPages: 0, }; type WaterRiskTableRow = Record & { sourceRow: WaterRiskResultRow; rowNo: number; sidoName: string; sggName: string; areaName: string; /** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */ analysisBaseAt: string; rainStartedAt: string; currentAccumRainQty: string; forecastRainQty: string; maxFloodingAt: string; maxFloodingFlow: string; riskStatus: WaterRiskResultRow["riskStatus"]; }; const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [ { id: "rowNo", title: "순번", widthPercent: 5, sortable: true, sortKey: "rowNumber", }, { id: "sidoName", title: "시도", widthPercent: 7, sortable: true, sortKey: "ctpvName", }, { id: "sggName", title: "시군구", widthPercent: 7, sortable: true }, { id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true }, // 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다. { id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true }, { id: "rainStartedAt", title: "강우시작시각", widthPercent: 11, sortable: true, }, { id: "currentAccumRainQty", title: "누적강우량(mm)", widthPercent: 9, align: "right", sortable: true, }, { id: "forecastRainQty", title: "예측강우량(mm)", widthPercent: 9, align: "right", sortable: true, }, { id: "maxFloodingAt", title: "최대 침수 예상시점", widthPercent: 11, sortable: true, }, { id: "maxFloodingFlow", title: "최대 침수유량(m³/s)", widthPercent: 9, align: "right", sortable: true, }, // "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다. { id: "riskStatus", title: "위험 여부", widthPercent: 9 }, ]; const formatOptionalNumber = ( value: number | null | undefined, digits: number, ) => (value == null ? "-" : value.toFixed(digits)); /** 브라우저 로컬 날짜를 API의 yyyy-MM-dd 형식으로 변환한다. */ const formatLocalDate = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); 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(); }; /** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */ const buildDefaultResultFilters = (): ResultFilters => { const now = new Date(); return { ctpvCd: "ALL", sggCd: "ALL", areaId: "ALL", riskStatus: "ALL", // 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다. analysisFrom: "", analysisTo: "", // 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다. rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`, rainStartedTo: formatWaterRiskHourValue(now), }; }; /** 부모 지도 창으로 통수위험 선택 메시지를 전달한다. */ const postMessageToOpener = ( type: string, payload?: Record, ) => { if (typeof window === "undefined" || !window.opener) return; window.opener.postMessage({ type, payload }, window.location.origin); }; /** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */ const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) => isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; /** * 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다. * 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다. */ const buildResultRowKey = (row: WaterRiskResultRow) => row.executionId || [ row.analysisDate ?? "", row.rainStartedAt ?? "", row.analysisBaseAt ?? "", row.ctpvCd ?? "", row.sggCd ?? "", row.areaId, ].join(":"); /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { // 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다. const initialFilters = useMemo(() => buildDefaultResultFilters(), []); // 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다. const currentHourValue = formatWaterRiskHourValue(new Date()); const [resultMode, setResultMode] = useState("current"); const [draftResultFilters, setDraftResultFilters] = useState(initialFilters); const [appliedResultFilters, setAppliedResultFilters] = useState(initialFilters); const [areaOptions, setAreaOptions] = useState([]); const [resultPage, setResultPage] = useState(EMPTY_RESULT_PAGE); const [selectedResultKey, setSelectedResultKey] = useState(""); const [resultCurrentPage, setResultCurrentPage] = useState(1); const [resultSort, setResultSort] = useState(); const [reloadVersion, setReloadVersion] = useState(0); const [currentAnalysisBaseAt, setCurrentAnalysisBaseAt] = useState(() => formatCurrentAnalysisHour(new Date()), ); const [isLoading, setIsLoading] = useState(false); const [isMapLoading, setIsMapLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); useEffect(() => { let active = true; fetchWaterRiskAreas() .then((options) => { if (active) setAreaOptions(options); }) .catch((error) => { if (!active) return; const message = error instanceof Error ? error.message : "중점관리지역 목록을 조회하지 못했습니다."; setErrorMessage(message); }); return () => { active = false; }; }, []); useEffect(() => { let active = true; setIsLoading(true); setErrorMessage(""); const isHistory = resultMode === "history"; // 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다. // 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다. const requestToday = formatLocalDate(new Date()); const startDate = isHistory ? appliedResultFilters.rainStartedFrom.slice(0, 10) : getWaterRiskMinStartDate(requestToday)!; const endDate = isHistory ? appliedResultFilters.rainStartedTo.slice(0, 10) : requestToday; // 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다. const analysisBaseFrom = isHistory ? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom) : ""; const analysisBaseTo = isHistory ? toWaterRiskApiDateTime(appliedResultFilters.analysisTo) : ""; const rainStartedFrom = isHistory ? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom) : ""; const rainStartedTo = isHistory ? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo) : ""; fetchWaterRiskResults({ startDate, endDate, analysisBaseAt: resultMode === "current" ? currentAnalysisBaseAt : undefined, analysisBaseFrom: analysisBaseFrom || undefined, analysisBaseTo: analysisBaseTo || undefined, rainStartedFrom: rainStartedFrom || undefined, rainStartedTo: rainStartedTo || undefined, ctpvCd: resultMode === "current" || appliedResultFilters.ctpvCd === "ALL" ? undefined : appliedResultFilters.ctpvCd, sggCd: resultMode === "current" || appliedResultFilters.sggCd === "ALL" ? undefined : appliedResultFilters.sggCd, areaId: appliedResultFilters.areaId === "ALL" ? undefined : appliedResultFilters.areaId, riskStatus: resultMode === "current" || appliedResultFilters.riskStatus === "ALL" ? undefined : appliedResultFilters.riskStatus, rainEventStatus: resultMode === "current" ? "IN_PROGRESS" : "ENDED", page: resultCurrentPage - 1, size: WATER_RISK_PAGE_SIZE, sort: resultSort, }) .then((page) => { if (!active) return; setResultPage(page); if (page.items.length > 0) { setSelectedResultKey( (current) => current || buildResultRowKey(page.items[0]!), ); } }) .catch((error) => { if (!active) return; setResultPage(EMPTY_RESULT_PAGE); setErrorMessage( error instanceof Error ? error.message : "통수위험 분석 결과를 조회하지 못했습니다.", ); }) .finally(() => { if (active) setIsLoading(false); }); return () => { active = false; }; }, [ appliedResultFilters, currentAnalysisBaseAt, reloadVersion, resultCurrentPage, resultMode, resultSort, ]); useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; const nextSnapshot = (event.data.payload ?? EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; if (nextSnapshot.selectedAreaId) { // 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다. const matchingRow = nextSnapshot.selectedExecutionId ? resultPage.items.find( (row) => row.executionId === nextSnapshot.selectedExecutionId, ) : resultPage.items.find( (row) => row.areaId === nextSnapshot.selectedAreaId, ); if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow)); } }; window.addEventListener("message", handleMessage); postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); return () => { window.removeEventListener("message", handleMessage); }; }, [resultPage.items]); /** 브라우저 X·새로고침 등으로 팝업 문서가 종료되면 부모 지도의 선택결과를 즉시 정리한다. */ useEffect(() => { const notifyPopupClosed = () => { postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED); }; window.addEventListener("pagehide", notifyPopupClosed); window.addEventListener("beforeunload", notifyPopupClosed); return () => { window.removeEventListener("pagehide", notifyPopupClosed); window.removeEventListener("beforeunload", notifyPopupClosed); }; }, []); /** 현재 탭의 표시·조회 기준시간을 매 정시에 새 현재시간으로 변경한다. */ 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) => { if (option.ctpvCd) unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd); }); return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name, "ko"), ); }, [areaOptions]); const sggOptions = useMemo(() => { const unique = new Map(); areaOptions .filter( (option) => draftResultFilters.ctpvCd !== "ALL" && option.ctpvCd === draftResultFilters.ctpvCd, ) .forEach((option) => { if (option.sggCd) unique.set(option.sggCd, option.sggName || option.sggCd); }); return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) => a.name.localeCompare(b.name, "ko"), ); }, [areaOptions, draftResultFilters.ctpvCd]); const filteredAreaOptions = useMemo( () => areaOptions.filter( (option) => (draftResultFilters.ctpvCd !== "ALL" && option.ctpvCd === draftResultFilters.ctpvCd) && (draftResultFilters.sggCd === "ALL" || option.sggCd === draftResultFilters.sggCd), ), [areaOptions, draftResultFilters.ctpvCd, draftResultFilters.sggCd], ); const resultListTitle = resultMode === "current" ? "통수위험 분석 결과 목록" : "통수위험 과거 이력 목록"; const tableRows = useMemo( () => resultPage.items.map((row) => { return { sourceRow: row, rowNo: 0, sidoName: row.sidoName || "-", sggName: row.sggName || "-", areaName: row.areaName, analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt), rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt), currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1), forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1), maxFloodingAt: formatAnalysisBaseAt(row.maxFloodingAt), maxFloodingFlow: formatOptionalNumber(row.maxFloodingFlow, 2), riskStatus: row.riskStatus, }; }), [resultPage.items], ); /** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */ const handleApplyResultFilters = () => { if (resultMode === "history") { const nowHourValue = formatWaterRiskHourValue(new Date()); // 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다. const analysisPeriodError = validateWaterRiskAnalysisPeriod( draftResultFilters.analysisFrom, draftResultFilters.analysisTo, nowHourValue, ); if (analysisPeriodError) { setErrorMessage(analysisPeriodError); return; } // 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다. const rainStartedPeriodError = validateWaterRiskRainStartedPeriod( draftResultFilters.rainStartedFrom, draftResultFilters.rainStartedTo, nowHourValue, ); if (rainStartedPeriodError) { setErrorMessage(rainStartedPeriodError); return; } } setErrorMessage(""); setResultCurrentPage(1); setAppliedResultFilters({ ...draftResultFilters }); setReloadVersion((version) => version + 1); }; /** 공통 결과표 정렬상태를 적용하고 첫 페이지를 다시 조회한다. */ const handleSortChange = (nextSort?: UiTableSort) => { setResultCurrentPage(1); setResultSort(nextSort); }; /** 강우이벤트 상태에 따라 진행중 현재 위험과 종료된 과거 이력을 다시 조회한다. */ const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); setResultSort(undefined); setResultPage(EMPTY_RESULT_PAGE); setSelectedResultKey(""); }; /** 늦게 끝난 이전 행 요청이 최신 선택을 덮지 않도록 식별한다. */ const mapRequestRef = useRef(""); /** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */ const handleSelectResultRow = async (row: WaterRiskResultRow) => { const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; mapRequestRef.current = requestId; postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { phase: "loading", requestId, }); setSelectedResultKey(buildResultRowKey(row)); setIsMapLoading(true); setErrorMessage(""); try { const mapView = await fetchWaterRiskMapView(row); if (mapRequestRef.current !== requestId) return; postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { requestId, areaId: row.areaId, resultRow: row, mapView, }); } catch (error) { if (mapRequestRef.current !== requestId) return; setErrorMessage( error instanceof Error ? error.message : "통수위험 지도 결과를 조회하지 못했습니다.", ); } finally { if (mapRequestRef.current === requestId) setIsMapLoading(false); } }; /** * 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `datetime-local`)로 과거이력 시각 범위를 그린다. * `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정하고, 분이 들어온 경우는 저장 전에 00으로 내린다. * 현재 정시 이후는 고를 수 없게 `max`만 둔다(`min`은 step 기준점을 바꾸므로 두지 않는다). */ const renderHourRange = (label: string, { fromKey, toKey }: HourRangeKeys) => (
{label}
{ const nextValue = toWaterRiskHourValue(event.target.value); setDraftResultFilters((current) => ({ ...current, [fromKey]: nextValue })); }} aria-label={`${label} 시작`} className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring" /> ~ { const nextValue = toWaterRiskHourValue(event.target.value); setDraftResultFilters((current) => ({ ...current, [toKey]: nextValue })); }} aria-label={`${label} 종료`} className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring" />
); return (
.card]:!flex [&_.standard-data-table>.card]:!min-h-0 [&_.standard-data-table>.card]:!flex-1 [&_.standard-data-table>.card]:!flex-col [&_.standard-data-table]:!flex [&_.standard-data-table]:!min-h-0 [&_.standard-data-table]:!flex-1 [&_.standard-data-table]:!flex-col [&_.standard-data-table__scroll>div]:!h-full [&_.standard-data-table__scroll]:!min-h-96 [&_.standard-data-table__scroll]:!flex-1" : "" }`} >
handleChangeResultMode(value as WaterRiskResultMode) } /> {resultMode === "current" ? (
분석기준시간 {formatAnalysisBaseAt(currentAnalysisBaseAt)}
) : null}
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */} {resultMode === "history" ? ( <> ) : null} {resultMode === "history" ? ( <> {/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */} {renderHourRange("분석시각", { fromKey: "analysisFrom", toKey: "analysisTo", })} {renderHourRange("강우시각", { fromKey: "rainStartedFrom", toKey: "rainStartedTo", })} ) : null}
{errorMessage ? (
{errorMessage}
) : null} buildResultRowKey(row.sourceRow)} selectedRowId={selectedResultKey} onRowClick={(row) => void handleSelectResultRow(row.sourceRow)} renderCell={(row, headId) => headId === "riskStatus" ? ( {buildWaterRiskStatusLabel(row.riskStatus)} ) : undefined } scrollMode="horizontal" // 분석시각 컬럼 추가로 기존 124rem보다 넓힌다. minTableWidth="136rem" emptyMessage="조회된 결과가 없습니다." sort={resultSort} onSortChange={handleSortChange} /> {/* 인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고, 분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다. */} 분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "} 위험, 매핑되지 않은 관로만 0.8 이상이면{" "} 위험(매핑관로없음), 없으면 정상 , "통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.", "분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행", "분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석", "최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.", ]} />
); }