Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev

This commit is contained in:
2026-08-20 15:10:36 +09:00
11 changed files with 977 additions and 1062 deletions
@@ -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,
},
{
@@ -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<string, unknown> & {
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<string, string>();
areaOptions.forEach((option) => {
@@ -334,25 +358,18 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const tableRows = useMemo<WaterRiskTableRow[]>(
() =>
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) {
}`}
>
<LoadingOverlay isLoading={isLoading || isMapLoading} />
<UITab
variant="underline"
defaultValue="current"
tabList={[
{ value: "current", label: "현재 위험" },
{ value: "history", label: "과거 이력" },
]}
onValueChange={(value) =>
handleChangeResultMode(value as WaterRiskResultMode)
}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<UITab
variant="underline"
defaultValue="current"
tabList={[
{ value: "current", label: "현재 위험" },
{ value: "history", label: "과거 이력" },
]}
onValueChange={(value) =>
handleChangeResultMode(value as WaterRiskResultMode)
}
/>
{resultMode === "current" ? (
<div className="flex items-center gap-2 text-xl">
<span className="font-medium text-label">분석기준시간</span>
<strong className="font-semibold text-primary">
{formatAnalysisBaseAt(currentAnalysisBaseAt)}
</strong>
</div>
) : null}
</div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
{resultMode === "current" ? "조회기간" : "강우발생일"}
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: event.target.value,
}))
}
aria-label="조회 시작일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="date"
value={draftResultFilters.endDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
endDate: event.target.value,
}))
}
aria-label="조회 종료일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</span>
</label>
{resultMode === "history" ? (
<>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
강우발생일
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: event.target.value,
}))
}
aria-label="조회 시작일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="date"
value={draftResultFilters.endDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
endDate: event.target.value,
}))
}
aria-label="조회 종료일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</span>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
value={draftResultFilters.ctpvCd}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "ALL",
areaId: "ALL",
}))
}
aria-label="시도"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
{ctpvOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
value={draftResultFilters.ctpvCd}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "ALL",
areaId: "ALL",
}))
}
aria-label="시도"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
{ctpvOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
value={draftResultFilters.sggCd}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sggCd: event.target.value,
areaId: "ALL",
}))
}
aria-label="시군구"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
{sggOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
value={draftResultFilters.sggCd}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sggCd: event.target.value,
areaId: "ALL",
}))
}
aria-label="시군구"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
{sggOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</label>
</>
) : null}
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
@@ -530,7 +563,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
{filteredAreaOptions.map((option) => (
{(resultMode === "current"
? areaOptions
: filteredAreaOptions
).map((option) => (
<option
key={`${option.ctpvCd}:${option.sggCd}:${option.areaId}`}
value={option.areaId}
@@ -541,26 +577,29 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
{resultMode === "history" ? (
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
) : null}
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button
@@ -588,11 +627,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
<MapPopupDataTable
rows={tableRows}
headlist={
resultMode === "current"
? CURRENT_RESULT_HEADLIST
: HISTORY_RESULT_HEADLIST
}
headlist={WATER_RISK_RESULT_HEADLIST}
title={resultListTitle}
total={resultPage.total}
page={resultCurrentPage}
@@ -611,11 +646,11 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
) : undefined
}
scrollMode="horizontal"
minTableWidth={resultMode === "current" ? "114rem" : "106rem"}
minTableWidth="124rem"
emptyMessage="조회 조건에 해당하는 통수위험 결과가 없습니다."
actions={
<span className="text-xl font-normal text-muted-foreground">
합리식 유출유량과 Manning 통수량 비교 결과
SWMM 시뮬레이션 기반 통수위험 분석 결과
</span>
}
/>
@@ -11,10 +11,12 @@ import type {
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
analysisBaseAt?: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
riskStatus?: WaterRiskStatus;
rainEventStatus?: "IN_PROGRESS" | "ENDED";
page: number;
size: number;
};
@@ -48,9 +50,11 @@ export const fetchWaterRiskResults = async (
size: String(query.size),
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
if (query.rainEventStatus) params.set("rainEventStatus", query.rainEventStatus);
const response = await getData(`v1/waterRisk/results?${params.toString()}`);
const items = Array.isArray(response?.items)
@@ -58,6 +62,7 @@ export const fetchWaterRiskResults = async (
: [];
return {
items,
analysisBaseAt: response?.analysisBaseAt ?? null,
total: Number(response?.total ?? 0),
page: Number(response?.page ?? query.page),
size: Number(response?.size ?? query.size),
@@ -76,7 +81,12 @@ export const fetchWaterRiskMapView = async (
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
return getData(
const response = await getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
);
return {
...response,
pipes: Array.isArray(response?.pipes) ? response.pipes : [],
manholes: Array.isArray(response?.manholes) ? response.manholes : [],
};
};
@@ -1,24 +1,27 @@
"use client";
export type WaterRiskStatus = "DANGER" | "NORMAL";
export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2" | "RATIONAL_MANNING";
export type WaterRiskCalculationMethod = "SWMM";
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
export type WaterRiskPipeSummary = {
pipeId: string;
runoffFlowQty: number;
conveyQty: number;
runoffFlowQty: number | null;
conveyQty: number | null;
riskStatus?: WaterRiskStatus;
};
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
export type WaterRiskResultRow = {
analysisDate?: string;
analysisBaseAt?: string | null;
rainStartedAt?: string | null;
areaId: string;
areaName: string;
ctpvCd?: string;
sidoName: string;
sggCd?: string;
sggName: string;
// 기존 물리 테이블에 누적강우량이 없으므로 값이 없을 때는 화면에서 '-'로 표시한다.
currentAccumRainQty: number | null;
forecastRainQty: number | null;
maxRainIntensity: number | null;
@@ -28,26 +31,7 @@ export type WaterRiskResultRow = {
pipeSummaries: WaterRiskPipeSummary[];
};
export type WaterRiskGridCell = {
gridId: string;
rainQty: number;
colorLevel: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4";
polygonCoordinates: number[][][];
};
export type WaterRiskAreaMapData = {
areaId: string;
areaName: string;
polygonCoordinates: number[][][];
gridCells: WaterRiskGridCell[];
};
export type WaterRiskMapPipe = {
pipeId: string;
displayLabel: string;
lineCoordinates: number[][];
};
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null;
};
@@ -56,650 +40,9 @@ export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null,
};
const buildRectanglePolygon = (
centerX: number,
centerY: number,
halfWidth: number,
halfHeight: number,
) => {
return [[
[centerX - halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY - halfHeight],
]];
};
const buildGridCells = (
centerX: number,
centerY: number,
values: number[],
): WaterRiskGridCell[] => {
const cellHalfSize = 180;
const offsets = [
[-190, 190],
[190, 190],
[-190, -190],
[190, -190],
] as const;
return offsets.map(([offsetX, offsetY], index) => {
const rainQty = values[index] ?? values[values.length - 1] ?? 0;
const colorLevel = rainQty >= 40
? "LEVEL_4"
: rainQty >= 30
? "LEVEL_3"
: rainQty >= 20
? "LEVEL_2"
: "LEVEL_1";
return {
gridId: `GRID-${index + 1}`,
rainQty,
colorLevel,
polygonCoordinates: buildRectanglePolygon(
centerX + offsetX,
centerY + offsetY,
cellHalfSize,
cellHalfSize,
),
};
});
};
export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 18.6,
forecastRainQty: 42.0,
maxRainIntensity: 50.2,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-10284", runoffFlowQty: 18.5, conveyQty: 15.2 },
{ pipeId: "PIPE-10291", runoffFlowQty: 11.3, conveyQty: 12.8 },
],
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 12.4,
forecastRainQty: 31.4,
maxRainIntensity: 42.1,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-45103", runoffFlowQty: 9.8, conveyQty: 14.7 },
],
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 15.2,
forecastRainQty: 37.8,
maxRainIntensity: 48.7,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-60014", runoffFlowQty: 16.1, conveyQty: 12.9 },
{ pipeId: "PIPE-60018", runoffFlowQty: 10.2, conveyQty: 11.1 },
{ pipeId: "PIPE-60021", runoffFlowQty: 8.7, conveyQty: 9.5 },
],
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 8.9,
forecastRainQty: 24.3,
maxRainIntensity: 35.4,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-72010", runoffFlowQty: 7.2, conveyQty: 11.4 },
{ pipeId: "PIPE-72018", runoffFlowQty: 6.1, conveyQty: 9.8 },
],
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 10.7,
forecastRainQty: 28.6,
maxRainIntensity: 39.8,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-83014", runoffFlowQty: 8.3, conveyQty: 12.1 },
],
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 19.8,
forecastRainQty: 45.1,
maxRainIntensity: 54.2,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-83018", runoffFlowQty: 17.4, conveyQty: 12.3 },
{ pipeId: "PIPE-83019", runoffFlowQty: 13.6, conveyQty: 10.1 },
],
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 13.5,
forecastRainQty: 33.2,
maxRainIntensity: 44.6,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-91004", runoffFlowQty: 9.7, conveyQty: 12.5 },
],
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 16.4,
forecastRainQty: 39.4,
maxRainIntensity: 51.3,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-91010", runoffFlowQty: 15.5, conveyQty: 11.6 },
{ pipeId: "PIPE-91012", runoffFlowQty: 12.4, conveyQty: 10.8 },
{ pipeId: "PIPE-91014", runoffFlowQty: 9.2, conveyQty: 9.0 },
],
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 9.6,
forecastRainQty: 26.1,
maxRainIntensity: 37.2,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-99001", runoffFlowQty: 6.7, conveyQty: 10.4 },
],
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 17.1,
forecastRainQty: 41.8,
maxRainIntensity: 52.7,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-99007", runoffFlowQty: 14.8, conveyQty: 10.9 },
{ pipeId: "PIPE-99009", runoffFlowQty: 10.1, conveyQty: 9.7 },
],
},
{
areaId: "AREA-011",
areaName: "K 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 11.2,
forecastRainQty: 29.7,
maxRainIntensity: 40.1,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-11001", runoffFlowQty: 8.8, conveyQty: 12.5 },
{ pipeId: "PIPE-11002", runoffFlowQty: 7.9, conveyQty: 11.4 },
],
},
{
areaId: "AREA-012",
areaName: "L 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 18.9,
forecastRainQty: 43.6,
maxRainIntensity: 53.8,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-12001", runoffFlowQty: 16.7, conveyQty: 12.2 },
],
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 14.3,
forecastRainQty: 35.2,
maxRainIntensity: 46.5,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-13001", runoffFlowQty: 14.4, conveyQty: 11.6 },
{ pipeId: "PIPE-13002", runoffFlowQty: 10.5, conveyQty: 10.9 },
{ pipeId: "PIPE-13003", runoffFlowQty: 8.6, conveyQty: 9.3 },
],
},
{
areaId: "AREA-014",
areaName: "N 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 7.6,
forecastRainQty: 21.9,
maxRainIntensity: 32.8,
maxFloodingAt: "2026-05-18 11:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-14001", runoffFlowQty: 5.8, conveyQty: 8.7 },
],
},
{
areaId: "AREA-015",
areaName: "O 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 20.4,
forecastRainQty: 47.3,
maxRainIntensity: 56.9,
maxFloodingAt: "2026-05-18 16:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-15001", runoffFlowQty: 18.2, conveyQty: 13.4 },
{ pipeId: "PIPE-15002", runoffFlowQty: 12.7, conveyQty: 10.8 },
],
},
{
areaId: "AREA-016",
areaName: "P 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 10.1,
forecastRainQty: 27.5,
maxRainIntensity: 38.6,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-16001", runoffFlowQty: 7.1, conveyQty: 10.3 },
{ pipeId: "PIPE-16002", runoffFlowQty: 6.2, conveyQty: 9.8 },
],
},
{
areaId: "AREA-017",
areaName: "Q 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 12.8,
forecastRainQty: 32.8,
maxRainIntensity: 43.7,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-17001", runoffFlowQty: 9.9, conveyQty: 11.7 },
],
},
{
areaId: "AREA-018",
areaName: "R 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 18.2,
forecastRainQty: 44.9,
maxRainIntensity: 55.1,
maxFloodingAt: "2026-05-18 16:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-18001", runoffFlowQty: 17.1, conveyQty: 12.4 },
{ pipeId: "PIPE-18002", runoffFlowQty: 13.4, conveyQty: 10.6 },
{ pipeId: "PIPE-18003", runoffFlowQty: 9.8, conveyQty: 9.1 },
],
},
{
areaId: "AREA-019",
areaName: "S 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 8.4,
forecastRainQty: 23.4,
maxRainIntensity: 34.9,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
{ pipeId: "PIPE-19001", runoffFlowQty: 6.4, conveyQty: 9.6 },
],
},
{
areaId: "AREA-020",
areaName: "T 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 16.7,
forecastRainQty: 40.7,
maxRainIntensity: 51.9,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
{ pipeId: "PIPE-20001", runoffFlowQty: 15.2, conveyQty: 11.4 },
{ pipeId: "PIPE-20002", runoffFlowQty: 11.9, conveyQty: 10.1 },
],
},
];
export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
polygonCoordinates: buildRectanglePolygon(955300, 1948770, 360, 240),
gridCells: buildGridCells(955300, 1948770, [38.2, 42.0, 31.5, 28.4]),
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954480, 1948350, 320, 210),
gridCells: buildGridCells(954480, 1948350, [22.4, 24.1, 20.5, 18.9]),
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
polygonCoordinates: buildRectanglePolygon(956260, 1948220, 390, 250),
gridCells: buildGridCells(956260, 1948220, [35.1, 37.8, 32.4, 29.6]),
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957020, 1948660, 300, 210),
gridCells: buildGridCells(957020, 1948660, [19.3, 24.3, 21.7, 17.8]),
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954900, 1949020, 290, 200),
gridCells: buildGridCells(954900, 1949020, [24.8, 28.6, 22.5, 20.7]),
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
polygonCoordinates: buildRectanglePolygon(955980, 1949040, 330, 220),
gridCells: buildGridCells(955980, 1949040, [41.7, 45.1, 36.2, 31.5]),
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
polygonCoordinates: buildRectanglePolygon(956760, 1947990, 280, 190),
gridCells: buildGridCells(956760, 1947990, [27.4, 33.2, 24.2, 21.1]),
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957420, 1948090, 340, 230),
gridCells: buildGridCells(957420, 1948090, [34.7, 39.4, 32.8, 28.6]),
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
polygonCoordinates: buildRectanglePolygon(954020, 1947900, 260, 180),
gridCells: buildGridCells(954020, 1947900, [18.4, 26.1, 17.6, 15.9]),
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
polygonCoordinates: buildRectanglePolygon(957980, 1948900, 300, 210),
gridCells: buildGridCells(957980, 1948900, [37.9, 41.8, 33.8, 29.4]),
},
];
export const WATER_RISK_MAP_PIPES_BY_AREA: Record<string, WaterRiskMapPipe[]> = {
"AREA-001": [
{
pipeId: "PIPE-10284",
displayLabel: "관로 ID 1",
lineCoordinates: [
[955020, 1948450],
[955210, 1948570],
[955320, 1948690],
],
},
{
pipeId: "PIPE-10291",
displayLabel: "관로 ID 2",
lineCoordinates: [
[955350, 1948820],
[955470, 1948680],
[955560, 1948550],
],
},
],
"AREA-002": [
{
pipeId: "PIPE-45103",
displayLabel: "관로 ID 1",
lineCoordinates: [
[954230, 1948500],
[954360, 1948420],
[954520, 1948340],
],
},
],
"AREA-003": [
{
pipeId: "PIPE-60014",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956000, 1948420],
[956110, 1948320],
[956220, 1948210],
],
},
{
pipeId: "PIPE-60018",
displayLabel: "관로 ID 2",
lineCoordinates: [
[956180, 1948480],
[956270, 1948360],
[956410, 1948240],
],
},
{
pipeId: "PIPE-60021",
displayLabel: "관로 ID 3",
lineCoordinates: [
[956420, 1948420],
[956520, 1948340],
[956650, 1948240],
],
},
],
"AREA-004": [
{
pipeId: "PIPE-72010",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956770, 1948790],
[956900, 1948720],
[957050, 1948650],
],
},
{
pipeId: "PIPE-72018",
displayLabel: "관로 ID 2",
lineCoordinates: [
[956980, 1948840],
[957090, 1948740],
[957210, 1948640],
],
},
],
"AREA-005": [
{
pipeId: "PIPE-83014",
displayLabel: "관로 ID 1",
lineCoordinates: [
[954710, 1949160],
[954840, 1949080],
[954980, 1949000],
],
},
],
"AREA-006": [
{
pipeId: "PIPE-83018",
displayLabel: "관로 ID 1",
lineCoordinates: [
[955760, 1949180],
[955910, 1949100],
[956030, 1949020],
],
},
{
pipeId: "PIPE-83019",
displayLabel: "관로 ID 2",
lineCoordinates: [
[955980, 1949240],
[956120, 1949140],
[956240, 1949040],
],
},
],
"AREA-007": [
{
pipeId: "PIPE-91004",
displayLabel: "관로 ID 1",
lineCoordinates: [
[956520, 1948120],
[956660, 1948050],
[956820, 1947980],
],
},
],
"AREA-008": [
{
pipeId: "PIPE-91010",
displayLabel: "관로 ID 1",
lineCoordinates: [
[957150, 1948220],
[957300, 1948160],
[957470, 1948100],
],
},
{
pipeId: "PIPE-91012",
displayLabel: "관로 ID 2",
lineCoordinates: [
[957350, 1948290],
[957480, 1948200],
[957610, 1948110],
],
},
{
pipeId: "PIPE-91014",
displayLabel: "관로 ID 3",
lineCoordinates: [
[957540, 1948340],
[957670, 1948220],
[957790, 1948100],
],
},
],
"AREA-009": [
{
pipeId: "PIPE-99001",
displayLabel: "관로 ID 1",
lineCoordinates: [
[953810, 1948010],
[953940, 1947960],
[954080, 1947910],
],
},
],
"AREA-010": [
{
pipeId: "PIPE-99007",
displayLabel: "관로 ID 1",
lineCoordinates: [
[957750, 1949040],
[957900, 1948970],
[958040, 1948900],
],
},
{
pipeId: "PIPE-99009",
displayLabel: "관로 ID 2",
lineCoordinates: [
[957980, 1949120],
[958120, 1949010],
[958260, 1948900],
],
},
],
};
export const WATER_RISK_SIDO_OPTIONS = ["전체", "서울특별시", "부산광역시"];
export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record<string, string[]> = {
전체: ["전체", "강남구", "해운대구"],
서울특별시: ["전체", "강남구"],
부산광역시: ["전체", "해운대구"],
};
export const WATER_RISK_COLOR_LABELS: Record<WaterRiskGridCell["colorLevel"], string> = {
LEVEL_1: "0.0~20.0 미만",
LEVEL_2: "20.0~30.0 미만",
LEVEL_3: "30.0~40.0 미만",
LEVEL_4: "40.0 이상",
};
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
status === "DANGER" ? "위험" : "정상";
export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) =>
method === "RATIONAL_MANNING"
? "합리식·Manning"
: method === "METHOD_1"
? "방법 1"
: "방법 2";
export type WaterRiskAreaOption = {
areaId: string;
areaName: string;
@@ -711,6 +54,7 @@ export type WaterRiskAreaOption = {
export type WaterRiskResultPage = {
items: WaterRiskResultRow[];
analysisBaseAt: string | null;
total: number;
page: number;
size: number;
@@ -722,6 +66,7 @@ export type WaterRiskGeoJsonGeometry = {
coordinates: unknown;
};
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
export type WaterRiskMapView = {
analysisDate: string;
areaId: string;
@@ -731,14 +76,27 @@ export type WaterRiskMapView = {
sggCd: string;
sggName: string;
riskStatus: WaterRiskStatus;
currentAccumRainQty: number | null;
forecastRainQty: number | null;
maxFloodingFlow: number | null;
projection: "EPSG:5179" | string;
areaGeometry: WaterRiskGeoJsonGeometry | null;
pipes: Array<{
pipeId: string;
gisFacilityId: string | null;
displayLabel: string;
geometry: WaterRiskGeoJsonGeometry | null;
runoffFlowQty: number;
conveyQty: number;
runoffFlowQty: number | null;
conveyQty: number | null;
capacityRatio: number | null;
riskStatus: WaterRiskStatus;
}>;
manholes: Array<{
manholeId: string;
gisFacilityId: string | null;
displayLabel: string;
geometry: WaterRiskGeoJsonGeometry | null;
floodingFlow: number | null;
riskStatus: WaterRiskStatus;
}>;
};
+146 -189
View File
@@ -168,9 +168,6 @@ import {
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
WATER_RISK_MAP_AREAS,
WATER_RISK_MAP_PIPES_BY_AREA,
WATER_RISK_RESULT_ROWS,
type WaterRiskMapView,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
@@ -244,8 +241,8 @@ const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15;
const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW";
const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES";
const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID";
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [
100, 380, 120, 120,
];
@@ -918,22 +915,6 @@ const expandGisEditPipeSnapExtent = (extent: number[]) => {
const geometryObjectToKey = (geometry: Record<string, unknown> | null) =>
geometry ? JSON.stringify(geometry) : "";
const getWaterRiskAreaMapData = (areaId: string | null) =>
WATER_RISK_MAP_AREAS.find((area) => area.areaId === areaId) ?? null;
const getWaterRiskResultRow = (areaId: string | null) =>
WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId) ?? null;
const buildWaterRiskInfoAnchor = (areaId: string | null) => {
const areaData = getWaterRiskAreaMapData(areaId);
if (!areaData) return null;
const polygon = new Polygon(areaData.polygonCoordinates);
const extent = polygon.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
return [maxX, (minY + maxY) / 2] as [number, number];
};
const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.24)"
@@ -944,17 +925,13 @@ const buildWaterRiskStatusStrokeColor = (riskStatus: "DANGER" | "NORMAL") =>
? "rgba(198, 40, 40, 0.92)"
: "rgba(21, 101, 192, 0.92)";
const buildWaterRiskGridFillColor = (colorLevel: string) => {
switch (colorLevel) {
case "LEVEL_4":
return "rgba(198, 40, 40, 0.28)";
case "LEVEL_3":
return "rgba(245, 124, 0, 0.24)";
case "LEVEL_2":
return "rgba(251, 192, 45, 0.22)";
default:
return "rgba(33, 150, 243, 0.18)";
}
/** capacity를 첨부 범례의 다섯 고정 구간색으로 변환한다. */
const buildWaterRiskCapacityColor = (capacityRatio: number) => {
if (capacityRatio <= 0.25) return "#0000ff";
if (capacityRatio <= 0.5) return "#00d9ff";
if (capacityRatio <= 0.75) return "#00d95f";
if (capacityRatio < 1) return "#ffe100";
return "#ff1c15";
};
const createWaterRiskAreaStyle = (feature: any) => {
@@ -982,27 +959,9 @@ const createWaterRiskAreaStyle = (feature: any) => {
});
};
const createWaterRiskGridStyle = (feature: any) => {
const rainQty = Number(feature.get("rainQty") ?? 0);
const colorLevel = String(feature.get("colorLevel") ?? "LEVEL_1");
return new Style({
fill: new Fill({ color: buildWaterRiskGridFillColor(colorLevel) }),
stroke: new Stroke({
color: "rgba(71, 85, 105, 0.36)",
width: 1,
}),
text: new Text({
text: `${rainQty.toFixed(1)}mm`,
font: "600 11px sans-serif",
fill: new Fill({ color: "#0f172a" }),
overflow: true,
}),
zIndex: 9995,
});
};
const createWaterRiskPipeStyle = (feature: any) => {
const displayLabel = String(feature.get("displayLabel") ?? "");
const capacityRatio = Number(feature.get("capacityRatio") ?? 0);
const capacityColor = buildWaterRiskCapacityColor(capacityRatio);
return [
new Style({
stroke: new Stroke({
@@ -1015,24 +974,30 @@ const createWaterRiskPipeStyle = (feature: any) => {
}),
new Style({
stroke: new Stroke({
color: "rgba(205, 52, 52, 0.96)",
color: capacityColor,
width: 4,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
text: new Text({
text: displayLabel,
font: "700 12px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({ color: "rgba(60, 60, 60, 0.9)" }),
padding: [3, 6, 3, 6],
overflow: true,
}),
}),
];
};
/** 맨홀 Flooding이 0이면 파랑, 양수이면 빨강으로 표시한다. */
const createWaterRiskManholeStyle = (feature: any) => {
const floodingFlow = Number(feature.get("floodingFlow") ?? 0);
const isFlooding = floodingFlow > 0;
return new Style({
image: new CircleStyle({
radius: isFlooding ? 7 : 4,
fill: new Fill({ color: isFlooding ? "#ff1c15" : "#1677ff" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
zIndex: 9999,
});
};
const getSelectedFeatureLayerTypeName = (data: any) => {
const previewLayerId = String(data?.get?.("layerId") ?? "").trim();
if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) {
@@ -3224,10 +3189,10 @@ const Map = () => {
const gisEditVertexSourceRef = useRef<VectorSource | null>(null);
const waterRiskAreaLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskAreaSourceRef = useRef<VectorSource | null>(null);
const waterRiskGridLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskGridSourceRef = useRef<VectorSource | null>(null);
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
const gisEditDrawRef = useRef<any | null>(null);
const gisEditDrawSnapRef = useRef<any | null>(null);
const gisEditTranslateRef = useRef<any | null>(null);
@@ -3447,12 +3412,11 @@ const Map = () => {
}, [waterRiskSessionSnapshot]);
const selectedWaterRiskResultRow = useMemo(
() =>
selectedWaterRiskApiResult ??
getWaterRiskResultRow(waterRiskSession.selectedAreaId),
[selectedWaterRiskApiResult, waterRiskSession.selectedAreaId],
() => selectedWaterRiskApiResult,
[selectedWaterRiskApiResult],
);
/** 선택 영역 오른쪽에 흰색 강우·침수 요약카드를 배치한다. */
const syncWaterRiskInfoCardPosition = useCallback(() => {
const map = gisMaps.current;
if (!map || !waterRiskInfoAnchor) {
@@ -3464,7 +3428,7 @@ const Map = () => {
const [pixelX = 0, pixelY = 0] =
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
const cardWidth = 244;
const cardHeight = 236;
const cardHeight = 180;
const left = pixelX + 22;
const top = pixelY - cardHeight / 2;
@@ -3482,10 +3446,7 @@ const Map = () => {
return;
}
setWaterRiskInfoCardStyle({
left,
top,
});
setWaterRiskInfoCardStyle({ left, top });
}, [waterRiskInfoAnchor]);
const setGisEditPreviewCursor = useCallback((cursor: string) => {
@@ -3887,29 +3848,6 @@ const Map = () => {
return source;
}, []);
const ensureWaterRiskGridLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskGridLayerRef.current && waterRiskGridSourceRef.current) {
return waterRiskGridSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9995,
});
layer.set("id", WATER_RISK_GRID_LAYER_ID);
layer.set("lyrNm", "통수위험 예측강우 격자");
layer.set("lyrTyNm", WATER_RISK_GRID_LAYER_ID);
map.addLayer(layer);
waterRiskGridLayerRef.current = layer;
waterRiskGridSourceRef.current = source;
return source;
}, []);
const ensureWaterRiskPipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
@@ -3933,10 +3871,29 @@ const Map = () => {
return source;
}, []);
const ensureWaterRiskManholeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskManholeLayerRef.current && waterRiskManholeSourceRef.current) {
return waterRiskManholeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({ source, zIndex: 9999 });
layer.set("id", WATER_RISK_MANHOLE_LAYER_ID);
layer.set("lyrNm", "통수위험 맨홀 Flooding");
layer.set("lyrTyNm", WATER_RISK_MANHOLE_LAYER_ID);
map.addLayer(layer);
waterRiskManholeLayerRef.current = layer;
waterRiskManholeSourceRef.current = source;
return source;
}, []);
const clearWaterRiskLayers = useCallback(() => {
waterRiskAreaSourceRef.current?.clear();
waterRiskGridSourceRef.current?.clear();
waterRiskPipeSourceRef.current?.clear();
waterRiskManholeSourceRef.current?.clear();
}, []);
const renderWaterRiskSelection = useCallback(
@@ -3945,107 +3902,75 @@ const Map = () => {
if (!map) return;
const areaSource = ensureWaterRiskAreaLayer();
const gridSource = ensureWaterRiskGridLayer();
if (!areaSource || !gridSource) return;
if (!areaSource) return;
areaSource.clear();
gridSource.clear();
const pipeSource = ensureWaterRiskPipeLayer();
pipeSource?.clear();
const manholeSource = ensureWaterRiskManholeLayer();
manholeSource?.clear();
if (!areaId) return;
if (!areaId || !mapView || mapView.areaId !== areaId) return;
if (mapView && mapView.areaId === areaId) {
const geoJson = new GeoJSON();
const projection = mapView.projection || "EPSG:5179";
let focusGeometry: any | null = null;
const geoJson = new GeoJSON();
const projection = mapView.projection || "EPSG:5179";
let focusGeometry: any | null = null;
if (mapView.areaGeometry) {
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const areaFeature = new Feature({
geometry,
areaId: mapView.areaId,
areaName: mapView.areaName,
riskStatus: mapView.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
focusGeometry = geometry;
}
mapView.pipes.forEach((pipe) => {
if (!pipeSource || !pipe.geometry) return;
const geometry = geoJson.readGeometry(pipe.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const pipeFeature = new Feature({
geometry,
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel || pipe.pipeId,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
if (!focusGeometry) focusGeometry = geometry;
if (mapView.areaGeometry) {
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
if (!focusGeometry) return;
const extent = focusGeometry.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
map.getView().fit(extent, {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
constrainResolution: true,
nearest: true,
const areaFeature = new Feature({
geometry,
areaId: mapView.areaId,
areaName: mapView.areaName,
riskStatus: mapView.riskStatus,
});
return;
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
focusGeometry = geometry;
}
const areaData = getWaterRiskAreaMapData(areaId);
const resultRow = getWaterRiskResultRow(areaId);
if (!areaData || !resultRow) return;
const areaFeature = new Feature({
geometry: new Polygon(areaData.polygonCoordinates),
areaId: areaData.areaId,
areaName: areaData.areaName,
riskStatus: resultRow.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
areaData.gridCells.forEach((cell) => {
const cellFeature = new Feature({
geometry: new Polygon(cell.polygonCoordinates),
gridId: cell.gridId,
rainQty: cell.rainQty,
colorLevel: cell.colorLevel,
mapView.pipes.forEach((pipe) => {
if (!pipeSource || !pipe.geometry) return;
const geometry = geoJson.readGeometry(pipe.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
cellFeature.setStyle(createWaterRiskGridStyle(cellFeature));
gridSource.addFeature(cellFeature);
});
const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? [];
pipes.forEach((pipe) => {
if (!pipeSource) return;
const pipeFeature = new Feature({
geometry: new LineString(pipe.lineCoordinates),
geometry,
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel,
displayLabel: pipe.displayLabel || pipe.pipeId,
capacityRatio: pipe.capacityRatio ?? 0,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
const areaGeometry = areaFeature.getGeometry();
if (!areaGeometry) return;
mapView.manholes.forEach((manhole) => {
if (!manholeSource || !manhole.geometry) return;
const geometry = geoJson.readGeometry(manhole.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const manholeFeature = new Feature({
geometry,
manholeId: manhole.manholeId,
displayLabel: manhole.displayLabel || manhole.manholeId,
floodingFlow: manhole.floodingFlow ?? 0,
});
manholeFeature.setStyle(createWaterRiskManholeStyle(manholeFeature));
manholeSource.addFeature(manholeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
map.getView().fit(areaGeometry.getExtent(), {
if (!focusGeometry) return;
const extent = focusGeometry.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
map.getView().fit(extent, {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
@@ -4055,7 +3980,7 @@ const Map = () => {
},
[
ensureWaterRiskAreaLayer,
ensureWaterRiskGridLayer,
ensureWaterRiskManholeLayer,
ensureWaterRiskPipeLayer,
],
);
@@ -4082,11 +4007,8 @@ const Map = () => {
]);
useEffect(() => {
if (selectedWaterRiskMapView) return;
setWaterRiskInfoAnchor(
buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId),
);
}, [selectedWaterRiskMapView, waterRiskSession.selectedAreaId]);
if (!selectedWaterRiskMapView) setWaterRiskInfoAnchor(null);
}, [selectedWaterRiskMapView]);
useEffect(() => {
const popup = waterRiskPopupWindowRef.current;
@@ -4098,10 +4020,7 @@ const Map = () => {
if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return;
const map = gisMaps.current;
const syncDuringMapRender = () => {
syncWaterRiskInfoCardPosition();
};
const syncDuringMapRender = () => syncWaterRiskInfoCardPosition();
syncWaterRiskInfoCardPosition();
map.on?.("postrender", syncDuringMapRender);
map.on?.("moveend", syncDuringMapRender);
@@ -8859,7 +8778,11 @@ const Map = () => {
{selectedWaterRiskResultRow.pipeSummaries.map(
(pipe, index) => {
const isDanger = pipe.runoffFlowQty > pipe.conveyQty;
const isDanger =
pipe.riskStatus === "DANGER" ||
(pipe.runoffFlowQty != null &&
pipe.conveyQty != null &&
pipe.runoffFlowQty > pipe.conveyQty);
const isLast =
index ===
selectedWaterRiskResultRow.pipeSummaries.length - 1;
@@ -8899,7 +8822,9 @@ const Map = () => {
(㎥/s)
</td>
<td className="border-b border-border px-[9px] py-[7px] text-right text-[11px] font-medium text-foreground">
{pipe.conveyQty.toFixed(1)}
{pipe.conveyQty == null
? "-"
: pipe.conveyQty.toFixed(1)}
</td>
</tr>
<tr
@@ -8915,7 +8840,9 @@ const Map = () => {
<td
className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-foreground"}`}
>
{pipe.runoffFlowQty.toFixed(1)}
{pipe.runoffFlowQty == null
? "-"
: pipe.runoffFlowQty.toFixed(1)}
</td>
</tr>
</Fragment>
@@ -8928,6 +8855,36 @@ const Map = () => {
</div>
</div>
) : null}
{selectedWaterRiskMapView ? (
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
<div className="mb-1 font-bold">Capacity</div>
<div className="space-y-1 font-semibold">
{[
["#0000ff", "≤ 0.25"],
["#00d9ff", "≤ 0.50"],
["#00d95f", "≤ 0.75"],
["#ffe100", "< 1.00"],
["#ff1c15", "≥ 1.00"],
].map(([color, label]) => (
<div key={label} className="flex items-center gap-2">
<span
className="inline-block h-[10px] w-[10px] border border-foreground"
style={{ backgroundColor: color }}
/>
<span>{label}</span>
</div>
))}
</div>
<div className="mt-2 border-t border-border pt-2">
<div className="font-bold">Flooding(m³/s)</div>
<div className="mt-1 flex gap-2">
<span className="text-[#1677ff]">● 0</span>
<span className="text-[#ff1c15]">● &gt; 0</span>
</div>
</div>
</div>
) : null}
{selectedFldngHstryDetail && fldngHstryDetailPopupPosition ? (
<FldngHstryDetailPanel
detail={selectedFldngHstryDetail}
@@ -114,3 +114,48 @@
- dev 서버 또는 로컬 미리보기 진입 가능
- mock 데이터 또는 초기 API 응답 구조 준비
- 인증 가능한 브라우저 환경에서 지도 조작 수동 검증 가능
## 6. 2026-08-18 강우이벤트 상태별 탭 연동 검증
- Java `WaterRiskServiceTest` 4건과 `compileJava`가 성공했다.
- 개발 DB 직접 API에서 `IN_PROGRESS=0건`, `ENDED=6건`을 확인했다.
- 종료 이력 6건은 위험 4건·정상 2건이며 기존 6개 성공모델 시험결과와 일치한다.
- 프론트 3002 미들웨어 경유 API도 현재 위험 0건·과거 이력 6건으로 동일하게 응답했다.
- 변경한 FE 4개 파일의 ESLint는 오류·경고 없이 통과했다.
- TypeScript 단독 검사는 기존 `tsconfig.json`의 `ignoreDeprecations` 값과 실행 TypeScript 버전 불일치로 중단됐으며 변경 파일 오류는 ESLint와 실행 중 Next 개발서버 컴파일로 분리 확인했다.
- 브라우저 자동검증 환경에는 로그인 세션이 없어 로그인 화면까지만 확인했다. 사용자의 로그인된 Chrome에서 탭 전환과 과거 6건 표시는 최종 수동 확인 대상으로 남긴다.
- 사용자 소유 프론트 3002·백엔드 8883은 종료하거나 재시작하지 않았다.
## 7. 2026-08-18 임시 GIS 관로·맨홀 매핑 검증
- KH_HM 대표실행의 SWMM 관로 184건과 실제 GIS 관로 geometry 184건이 모두 연결됐다.
- SWMM 맨홀 180건과 실제 GIS 맨홀 geometry 180건이 모두 연결됐다.
- 관로 capacity 범위는 `0.00420~0.83100`이며 0.25/0.50/0.75/1.00 고정 색상구간 적용을 확인했다.
- 맨홀 180건의 Flooding 양수 건수는 0건이므로 현재 시험화면에서는 모두 정상 파랑으로 표시된다.
- 지도요약은 누적강우 52.3mm, 예측강우 39.89mm, 최대 침수유량 NULL(`-`)이다.
- 프론트 3002 프록시에서도 관로·맨홀 geometry가 각각 184/184, 180/180으로 반환됐다.
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
## 10. 2026-08-18 팝업 크기·시간갱신 정적검증
- 팝업 정의와 컴포넌트 크기가 `1360×600`으로 일치한다.
- 현재 탭 전용 자동갱신은 다음 `HH:00`에 시작하고 이후 1시간 간격이며 effect cleanup에서 timeout·interval을 모두 해제한다.
- 현재 탭 설명문 제거와 FE 대상 ESLint 통과를 확인했다.
- 과거분석이력에서 현재로 전환할 때 결과페이지는 비우되 분석기준시간은 현재 정시를 계속 표시하며 DB 0건에도 사라지지 않는다.
- 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다.
## 9. 2026-08-18 결과표 헤더·강우시작시간 검증
- `rainStartedAt`이 실제 강우이벤트 시작시각으로 반환되는 것을 확인했다.
- 사천 `2026-07-01 03:00`, 완도 `2026-07-01 01:00`과 누적·예측강우 값이 같은 행에 표시되는 계약을 확인했다.
- 2단 그룹헤더는 총 10개 데이터 열과 일치하며 로딩·빈 결과 colSpan도 10으로 통일했다.
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
- 관리번호 15 완도는 MS 중점관리지역 geometry가 없어 임시 공간매핑이 불가능하다.
## 8. 2026-08-18 탭별 검색조건·분석기준시간 검증
- 목록 API에서 대표실행 `ANLS_CRTR_DT`가 `analysisBaseAt`으로 반환되는 것을 확인했다.
- 개발 DB 현재 진행중 이벤트가 0건이어도 현재 탭 분석기준시간은 현재 정시를 표시하고 그리드만 0건이다.
- 과거 종료결과에서 실제 `2026-07-01 01:00`, `03:00`, `05:00` 등의 분석기준시간이 반환됐다.
- 과거 종료결과 전체의 상단 최신 분석기준시간은 `2026-07-06 08:00`으로 반환됐다.
- Java Service 테스트와 FE 대상 ESLint가 통과했다.
@@ -0,0 +1,68 @@
# SFR-011-008 침수예측현황 FE 작업계획
## 작업 목적
지도 분석도구의 기존 침수예측현황 placeholder를 검색·목록·Excel·지도연계가 가능한 실제 WindowPopup으로 전환한다.
## 근거
- 요구사항: `SFR-011-008`
- 화면: `SN_MN010205 침수예측현황`
- route: `/popup/fldngPredcPrst`
- 참고: `PipeAnalysisPopupPage`, `FloodDngrMapPopupPage`, `/oper/obsltPplBiz`
## 수정 예정 범위
- `src/features/fldngPredcPrst/{api,model,ui}/**`
- `src/features/windowPopup/model/popupDefinitions.ts`
- `src/features/windowPopup/registry.tsx`
- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- 공통 `app/(popupLayout)/popup/[popupKey]` route는 재사용하고 popup key를 `fldngPredcPrst`로 변경한다.
- 공통 패키지는 수정하지 않는다.
## 화면·상태 계약
- 강우시작시각은 최초 진입 시 미입력으로 두고, 분석일시는 오늘 00:00~현재 KST를 기본값으로 설정한다.
- 시도 변경 시 시군구를 전체로 초기화한다.
- 입력 조건과 실제 적용 조건을 분리하여 페이지 이동이 마지막 조회조건을 유지한다.
- 20건 서버 페이징, 허용 컬럼 서버 정렬, 전체건수 기반 no를 제공한다.
- 시도 헤더는 `ctpvCd` 서버 정렬키를 사용한다.
- 강수량은 소수점 한 자리로 표시하고 `초과빈도년도` 값은 `년` 접미사 없이 숫자로 표시한다.
- `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다.
- Excel은 현재 적용 검색조건을 동일하게 전달한다.
- 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다.
## 지도 연계
- 행 선택 시 기존 도시침수지도 group extent API를 호출한다.
- 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다.
- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다.
## 검증
- TypeScript 정적검사 및 `sn` 빌드(실행 중 개발서버가 없거나 사용자 확인 후)
- `/popup/fldngPredcPrst` 직접 진입
- 분석도구 클릭 별도 창 진입
- 기본 날짜·종속 시군구·조회·페이지·정렬·가로스크롤
- Excel 파일명/열/검색조건
- 행 클릭 후 부모 지도 Network·extent·레이어 동작
## 롤백과 위험
- 신규 route/feature를 제거하고 registry를 placeholder로 복원한다.
- 팝업 경로와 기존 popup key가 다르므로 route 연결 누락을 회귀 확인한다.
- 행정구역 코드와 도시침수지도 빈도 자료가 없는 행은 지도 이동이 제한될 수 있다.
## 구현 결과 (2026-08-13)
- 분석도구 메뉴와 WindowPopup key를 `fldngPredcPrst`로 변경하고 placeholder를 실제 화면으로 교체했다.
- 강우시작시각 미입력·분석일시 당일 00:00~현재 기본값, 시도·시군구 종속조건, 두 날짜 범위, 빈도년도 검색을 구현했다.
- 20건 서버 페이징·정렬, `ListDiv` 가로 스크롤, 적용조건 기준 Excel 다운로드를 구현했다.
- 행 클릭 시 기존 extent API와 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 사용한다.
- 도시침수지도가 없는 빈도는 30·50·80년 중 임의 근사하지 않고 안내한다.
- 2026-08-18 시도 헤더를 정렬 가능 상태로 전환하고 `ctpvCd` 서버 정렬을 연결했다.
- `pnpm --filter sn run build`를 통과했다.
- 독립 `tsc --noEmit`은 현재 수정과 무관한 조사사업·침수이력·지도·노후관로·팝업 registry 등의 기존 타입 오류로 실패했다. 이번 변경 파일에서는 오류가 보고되지 않았다.
- 2026-08-18 목록 강수량을 소수점 한 자리로, 헤더를 `초과빈도년도`로 변경하고 값의 `년` 접미사를 제거했다.
- 기존 `.next` 산출물을 재생성한 뒤 `pnpm --filter sn run build`를 최종 통과했다.
- TypeScript 정적검사는 저장소 기존 `ignoreDeprecations` 설정과 실행 중 산출물 잠금 때문에 완료하지 못했고, Next 빌드는 최적화 단계 장기 대기 후 중단했다.
@@ -174,3 +174,44 @@
- 실제 API 응답 구조 확정
- BE 저장/다운로드 계약 확정
- 인증 가능한 브라우저 환경에서 수동 검증 가능 상태 확보
## 9. 2026-08-18 SWMM 현재 위험·과거 이력 실 DB 연동
- 기존 팝업 레이아웃과 검색조건은 유지하고 탭별 조회조건을 강우이벤트 상태로 분리했다.
- `현재 위험`은 `RAIN_SEVNT_STTS_CD=IN_PROGRESS`, `과거 이력`은 `RAIN_SEVNT_STTS_CD=ENDED`를 `GET /api/v1/waterRisk/results`에 전달한다.
- 기본 조회기간을 최근 365일에서 오늘까지로 설정해 개발 DB의 과거 시험자료와 운영 최신자료를 함께 조회할 수 있게 했다.
- 합리식·Manning 안내문과 통수량 열을 제거하고 `대표관로 예측유량`, `분석일`, `최대침수예상시점` 등 SWMM 결과 용어로 교체했다.
- 부모 지도 정보카드도 6시간 예측강우량과 대표관로 예측유량을 표시하며 위험판정은 API의 관로 위험상태를 사용한다.
- `waterRiskTypes.ts`에 남아 있던 mock 결과·영역·격자·관로 자료와 합리식 계산방법 타입을 제거했다.
- 부모 지도는 선택행의 실제 `mapView` 응답만 사용하며 mock polygon·grid·pipe fallback을 사용하지 않는다.
## 10. 2026-08-18 실제 GIS 자산 임시 매핑 지도
- 선택한 중점관리지역의 실제 GIS 관로·맨홀 geometry를 확장된 `mapView`로 수신한다.
- 관로는 capacity 0.25/0.50/0.75/1.00 경계의 고정 구간색으로, 맨홀은 Flooding 0/양수 기준으로 지도에 표시한다.
- 지도에 capacity 세로 범례와 맨홀 Flooding 범례를 함께 표시한다.
- 지역 정보카드는 대표관로 상세 대신 현재 누적강우량, 예측강우량, 최대 침수유량 3개 항목으로 단순화했다.
- 매핑은 화면검증용 임시 순번 연결이며 정식 매핑테이블 도입 후 교체 대상이다.
- 중점관리지역 중앙 빨간 라벨은 제거하고 흰색 강우·침수 요약카드는 유지했다.
## 11. 2026-08-18 탭명·검색조건 분리
- 탭명을 `현재`, `과거분석이력`으로 변경했다.
- 현재 검색영역은 중점관리지역 선택, 브라우저 현재시각을 정시로 내린 분석기준시간, 조회 버튼만 표시한다.
- 과거분석이력 검색영역은 기존 검색조건을 유지하고 날짜 라벨을 `강우 발생기간`으로 변경했다.
- 현재 탭에서는 숨겨진 시도·시군구·위험상태 조건을 API에 전달하지 않아 이전 과거검색 조건이 현재조회에 영향을 주지 않게 했다.
## 12. 2026-08-18 화면설계서 기준 2단 결과표 헤더
- 관측강우 2개 열과 통수위험 3개 열을 묶는 2단 그룹 헤더로 변경했다.
- 목록 행은 강우시작시간, 누적강우량, 예측강우량, 최대 침수 예상시점, 최대침수량, 위험 여부 순으로 표시한다.
- 최대침수량 체적값은 현재 API 원천이 없어 `-`로 표시한다.
## 13. 2026-08-18 팝업 크기·현재 분석기준시간 자동갱신
- 팝업 정의와 화면 `resizeTo`를 모두 `1360×600`으로 통일했다.
- 현재 탭 결과 제목 우측의 별도 SWMM 설명문을 제거했다.
- 현재 탭이 열린 동안 매시간 `HH:00`에 현재 정시를 새 분석기준시간으로 적용하고 동일 `ANLS_CRTR_DT` 결과를 자동 재조회한다.
- 탭을 과거분석이력으로 전환하거나 팝업을 닫으면 자동갱신 타이머를 해제한다.
- 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다.
- `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다.
@@ -0,0 +1,406 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui/table";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { getBlobData, getRawJson } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
type ApiRow = {
sggCd: string;
ctpvCd: string;
ctpvNm: string;
sggNm: string;
rainBeginningDateTime: string;
analysisDateTime: string;
observedAccumulatedRainfall: number;
rainHour: number;
rainfall: number;
exceededFrequencyYear: number;
floodRiskGrade: string;
};
type TableRow = ApiRow & {
rowNo: number;
rainBeginningDateTimeDisplay: string;
analysisDateTimeDisplay: string;
observedAccumulatedRainfallDisplay: string;
rainHourDisplay: string;
rainfallDisplay: string;
exceededFrequencyYearDisplay: string;
};
type Filters = {
ctpvCd: string;
sggCd: string;
rainBgngFrom: string;
rainBgngTo: string;
analysisFrom: string;
analysisTo: string;
frequencyYear: string;
};
const PAGE_SIZE = 20;
const MAP_FREQUENCIES = new Set([30, 50, 80]);
const HEADS: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
{
id: "ctpvNm",
title: "시도",
widthPercent: 9,
sortable: true,
sortKey: "ctpvCd",
},
{
id: "sggNm",
title: "시군구",
widthPercent: 10,
sortable: true,
sortKey: "sggCd",
},
{
id: "rainBeginningDateTimeDisplay",
title: "강우 시작시각",
widthPercent: 13,
sortable: true,
sortKey: "rainBeginningDateTime",
},
{
id: "analysisDateTimeDisplay",
title: "분석시각",
widthPercent: 13,
sortable: true,
sortKey: "analysisDateTime",
},
{
id: "observedAccumulatedRainfallDisplay",
title: "관측 누적강수량(mm)",
widthPercent: 13,
sortable: true,
sortKey: "observedAccumulatedRainfall",
},
{
id: "rainHourDisplay",
title: "강우시간",
widthPercent: 9,
sortable: true,
sortKey: "rainHour",
},
{
id: "rainfallDisplay",
title: "강수량(mm)",
widthPercent: 10,
sortable: false,
},
{
id: "exceededFrequencyYearDisplay",
title: "초과빈도년도",
widthPercent: 9,
sortable: true,
sortKey: "exceededFrequencyYear",
},
{
id: "floodRiskGrade",
title: "위험등급",
widthPercent: 9,
sortable: true,
sortKey: "floodRiskGrade",
},
];
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 initialFilters = (): Filters => {
const now = new Date();
const from = new Date(now);
from.setHours(0, 0, 0, 0);
return {
ctpvCd: "0",
sggCd: "0",
// 최초 조회에서는 강우 시작시각을 제한하지 않고 전체 강우사상을 대상으로 한다.
rainBgngFrom: "",
rainBgngTo: "",
analysisFrom: localDateTime(from),
analysisTo: localDateTime(now),
frequencyYear: "",
};
};
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
const displayDate = (value: string) =>
/^\d{12}$/.test(value)
? `${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) =>
value === 0 ? "현재" : `${value > 0 ? "+" : "-"}${Math.abs(value)}시간`;
const query = (filters: Filters, page?: number, sort?: UiTableSort) => {
const p = new URLSearchParams();
if (page !== undefined) {
p.set("page", String(page - 1));
p.set("size", String(PAGE_SIZE));
}
if (filters.ctpvCd !== "0") p.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd !== "0") p.set("sggCd", filters.sggCd.slice(0, 5));
["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"].forEach(
(key) => {
const value = apiDate(filters[key as keyof Filters]);
if (value) p.set(key, value);
},
);
if (filters.frequencyYear) p.set("frequencyYear", filters.frequencyYear);
if (sort?.key && sort.direction)
p.set("sort", `${sort.key},${sort.direction}`);
return p.toString();
};
export default function FldngPredcPrstPopupContent() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<Filters>(() => initialFilters());
const [applied, setApplied] = useState<Filters>(() => initialFilters());
const [rows, setRows] = useState<TableRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(-1);
useEffect(() => {
if (!ctpv.length) void fetchCtpv();
if (!sgg.length) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
const load = useCallback(
async (targetPage: number, condition: Filters, nextSort?: UiTableSort) => {
setLoading(true);
try {
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst?${query(condition, targetPage, nextSort)}`,
);
const payload = response?.data ?? response;
const content: ApiRow[] = Array.isArray(payload?.content)
? payload.content
: [];
setRows(
content.map((item, index) => ({
...item,
rowNo: (targetPage - 1) * PAGE_SIZE + index + 1,
rainBeginningDateTimeDisplay: displayDate(
item.rainBeginningDateTime,
),
analysisDateTimeDisplay: displayDate(item.analysisDateTime),
observedAccumulatedRainfallDisplay: Number(
item.observedAccumulatedRainfall ?? 0,
).toFixed(4),
rainHourDisplay: rainHourText(Number(item.rainHour ?? 0)),
rainfallDisplay: Number(item.rainfall ?? 0).toFixed(1),
exceededFrequencyYearDisplay: String(
item.exceededFrequencyYear ?? "",
),
})),
);
setTotal(Number(payload?.totalElements ?? 0));
setPage(targetPage);
setApplied(condition);
setSort(nextSort);
setSelected(-1);
} catch (error) {
console.error(error);
alert(
error instanceof Error
? error.message
: "침수예측현황 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
const defaults = initialFilters();
setFilters(defaults);
void load(1, defaults);
}, [load]);
const mapRow = async (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelected(index);
if (!MAP_FREQUENCIES.has(row.exceededFrequencyYear)) {
alert(
`${row.exceededFrequencyYear}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80년 빈도만 제공)`,
);
return;
}
setLoading(true);
try {
const sbmrgFrqExpln = `${row.exceededFrequencyYear}년`;
const p = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln });
const extentData = await getRawJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${p}`,
);
const extent = [
extentData.minX,
extentData.minY,
extentData.maxX,
extentData.maxY,
].map(Number);
if (extent.some((v) => !Number.isFinite(v)))
throw new Error("도시침수지도 위치 정보가 없습니다.");
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln },
},
window.location.origin,
);
} catch (error) {
alert(
error instanceof Error ? error.message : "지도 이동에 실패했습니다.",
);
} finally {
setLoading(false);
}
};
const download = async () => {
if (!total) return alert("다운로드할 데이터가 없습니다.");
setLoading(true);
try {
const blob = await getBlobData(
`/sewerage-service/api/v1/fldng/predc/prst/excel?${query(applied, undefined, sort)}`,
);
fileDownload(blob, "침수예측현황.xlsx");
} catch (error) {
alert(
error instanceof Error
? error.message
: "Excel 다운로드에 실패했습니다.",
);
} finally {
setLoading(false);
}
};
const field = (key: keyof Filters, label: string) => (
<label className="sn-popup-field">
<div className="sn-popup-field-label">{label}</div>
<input
className="sn-popup-input"
type="datetime-local"
value={filters[key]}
onChange={(e) => setFilters((v) => ({ ...v, [key]: e.target.value }))}
/>
</label>
);
return (
<main className="sn-popup-page w-full max-w-full overflow-hidden">
<LoadingOverlay isLoading={loading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수예측현황</h1>
<div />
</header>
<section className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-4 gap-3">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input"
value={filters.ctpvCd}
onChange={(e) =>
setFilters((v) => ({
...v,
ctpvCd: e.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((x) => (
<option key={x.val} value={String(x.val)}>
{x.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input"
value={filters.sggCd}
onChange={(e) =>
setFilters((v) => ({ ...v, sggCd: e.target.value }))
}
>
<option value="0">전체</option>
{sggOptions.map((x) => (
<option key={x.val} value={String(x.val)}>
{x.text}
</option>
))}
</select>
</label>
{field("rainBgngFrom", "강우 시작시각 From")}
{field("rainBgngTo", "강우 시작시각 To")}
{field("analysisFrom", "분석시각 From")}
{field("analysisTo", "분석시각 To")}
<label className="sn-popup-field">
<div className="sn-popup-field-label">초과 빈도년도</div>
<input
className="sn-popup-input"
type="number"
min="0"
value={filters.frequencyYear}
onChange={(e) =>
setFilters((v) => ({ ...v, frequencyYear: e.target.value }))
}
/>
</label>
<div className="sn-popup-actions self-end">
<Button
size="sm"
color="primary"
onClick={() => void load(1, filters, sort)}
>
조회
</Button>
<Button size="sm" color="primary" onClick={() => void download()}>
다운로드
</Button>
</div>
</div>
<ListDiv className="mt-3 min-h-0 flex-1 overflow-x-auto">
<div className="min-w-[1450px]">
<UiTable<TableRow>
headName={
<>침수예측현황 조회 결과 총 {total.toLocaleString()}건</>
}
tableData={rows}
headlist={HEADS}
pageSize={PAGE_SIZE}
total={total}
curPage={page}
onPageChange={(p) => void load(p, applied, sort)}
cellClick={(i) => void mapRow(i)}
index={selected}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(s) => void load(1, applied, s)}
/>
</div>
</ListDiv>
<div className="sn-popup-help-text">
행 클릭 시 해당 시군구·빈도의 도시침수지도로 이동합니다.
</div>
</section>
</main>
);
}
@@ -182,11 +182,11 @@ export const windowPopupDefinitions = {
height: 760,
requiredParams: ["zoneId"],
},
floodPredictionMapPopup: {
key: "floodPredictionMapPopup",
fldngPredcPrst: {
key: "fldngPredcPrst",
title: "침수예측현황",
width: 1080,
height: 760,
width: 1500,
height: 860,
},
agedPipeStatusByCtpvPopup: {
key: "agedPipeStatusByCtpvPopup",
@@ -223,8 +223,8 @@ export const windowPopupDefinitions = {
flowRiskPopup: {
key: "flowRiskPopup",
title: "통수 위험",
width: 1180,
height: 820,
width: 1360,
height: 600,
},
} as const satisfies Record<string, WindowPopupDefinition>;
+5 -10
View File
@@ -123,13 +123,8 @@ const RepairHistoryPopupContent = loadPopupContent(
const WaterRiskPopupContent = loadPopupContent(
() => import("./ui/WaterRiskPopupContent"),
);
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodPredictionMapPopup.title}
/>
const FldngPredcPrstPopupContent = loadPopupContent(
() => import("../fldngPredcPrst/ui/FldngPredcPrstPopupContent"),
);
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
@@ -243,9 +238,9 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.repairHistoryPopup,
component: RepairHistoryPopupContent,
},
floodPredictionMapPopup: {
...windowPopupDefinitions.floodPredictionMapPopup,
component: FloodPredictionPlaceholderPopupContent,
fldngPredcPrst: {
...windowPopupDefinitions.fldngPredcPrst,
component: FldngPredcPrstPopupContent,
},
agedPipeStatusByCtpvPopup: {
...windowPopupDefinitions.agedPipeStatusByCtpvPopup,