sn: 확률강우량 모달 추가

This commit is contained in:
2026-10-02 18:07:00 +09:00
parent ae341d66ba
commit e5f7778861
3 changed files with 264 additions and 0 deletions
@@ -0,0 +1,41 @@
# SFR-011-008 침수예측현황 확률강우량 FE 유지보수 작업계획
## 작업 목적
침수예측현황의 선택 시군구가 어떤 확률강우량 기준으로 위험도가 산정되는지 확인할 수 있도록 목록 열과 기준표 팝업을 추가한다.
## 승인 상태
- `maintenance` 작업계획
- 2026-10-02 사용자 승인, 코드 구현 진행
## 구현 계획
- `FldngPredcPrstPopupContent`의 `침수위험도` 오른쪽에 `확률강우량` 액션을 추가한다.
- 기준표 팝업 컴포넌트 파일은 표준용어를 사용한 `PrbbtRnflDialog.tsx`로 둔다.
- 목록의 확률강우량은 `predc_rain_hr` 기준 `N시간` 형식의 클릭 가능한 지속시간으로 표시한다. 기존 `predictionHour`(`predc_hr`)와 구분한다.
- 선택 행의 `sggCd`로 BE 확률강우량 조회 API를 호출한다.
- API 경로는 사용자 지정 `GET /api/v1/fldng/predc/prst/prbbt-rnfl`을 사용한다.
- 팝업 표는 재현기간을 행, `1시간`, `2시간`, `3시간` 형식의 지속시간을 열로 표시한다.
- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다.
- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다.
- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다.
- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다.
## 검증 계획
- 제주·서울 등 서로 다른 시군구 기준표 조회
- 1~72시간 행과 빈도년도 열 표시
- API 오류·빈 결과 표시
- 기존 지도 이동 회귀
- `pnpm --filter sn run build`
## 구현 결과 (2026-10-02)
- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다.
- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다.
- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다.
- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다.
- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다.
@@ -0,0 +1,19 @@
# SFR-011-008 침수예측현황 확률강우량 FE 구현기록
## 구현 내용
- 확률강우량 팝업 컴포넌트는 `PrbbtRnflDialog.tsx`에 둔다.
- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다.
- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다.
- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다.
- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다.
- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다.
- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다.
- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다.
## 검증
- `pnpm --filter sn run typecheck` 성공
- `git diff --check` 성공
- 개발 서버(3002)가 실행 중이어서 build는 실행하지 않았다.
## 수동 확인 필요
- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인
@@ -0,0 +1,204 @@
"use client";
import { useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */
export type PrbbtRnflCell = {
frequencyYear: number;
durationHour: number;
rainfall: number;
};
/** 확률관측소의 시군구 교차면적 비율 API 응답이다. */
export type PrbbtRnflStation = {
prbbtObsvtrCd: string;
prbbtObsvtrNm: string;
areaRt: number;
};
/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */
export type PrbbtRnflResponse = {
sggCd: string;
rainfall: PrbbtRnflCell[];
stations: PrbbtRnflStation[];
areaRtTotal: number;
};
type PrbbtRnflDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sggName: string;
selectedDurationHour: number | null;
data: PrbbtRnflResponse | null;
loading: boolean;
error: string | null;
};
const formatRainfall = (value: number) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(4) : "-";
const formatAreaRatio = (value: number) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-";
/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */
export default function PrbbtRnflDialog({
open,
onOpenChange,
sggName,
selectedDurationHour,
data,
loading,
error,
}: PrbbtRnflDialogProps) {
const { frequencies, durations, rainfallByKey } = useMemo(() => {
const rainfall = data?.rainfall ?? [];
return {
frequencies: [...new Set(rainfall.map((item) => item.frequencyYear))].sort(
(a, b) => a - b,
),
durations: [...new Set(rainfall.map((item) => item.durationHour))].sort(
(a, b) => a - b,
),
rainfallByKey: new Map(
rainfall.map((item) => [
`${item.frequencyYear}:${item.durationHour}`,
item.rainfall,
]),
),
};
}, [data]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[90vh] w-[94vw] !max-w-[150rem] flex-col gap-4 overflow-hidden"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{sggName} 확률강우량</DialogTitle>
</DialogHeader>
{loading ? (
<p className="py-8 text-center text-xl" role="status">
확률강우량을 조회하고 있습니다.
</p>
) : error ? (
<p className="py-8 text-center text-xl text-destructive" role="alert">
{error}
</p>
) : !frequencies.length || !durations.length ? (
<p className="py-8 text-center text-xl" role="status">
해당 시군구의 확률강우량 기준자료가 없습니다.
</p>
) : (
<>
<div className="min-h-0 overflow-auto rounded-md border border-border">
<table className="w-max min-w-full border-collapse text-right text-lg">
<caption className="sr-only">
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm)
</caption>
<thead className="sticky top-0 z-10 bg-muted text-foreground">
<tr>
<th
scope="col"
className="sticky left-0 z-20 min-w-[8rem] border border-border bg-muted px-3 py-2 text-center"
>
재현기간(년)
</th>
{durations.map((durationHour) => (
<th
key={durationHour}
scope="col"
className="min-w-[9rem] border border-border px-3 py-2 text-center"
>
{durationHour}시간
</th>
))}
</tr>
</thead>
<tbody>
{frequencies.map((frequencyYear) => (
<tr key={frequencyYear}>
<th
scope="row"
className="sticky left-0 z-[1] border border-border bg-background px-3 py-2 text-right font-medium"
>
{frequencyYear}
</th>
{durations.map((durationHour) => {
const rainfall = rainfallByKey.get(
`${frequencyYear}:${durationHour}`,
);
return (
<td
key={durationHour}
className={`border border-border px-3 py-2 tabular-nums ${
rainfall != null && durationHour === selectedDurationHour
? "bg-amber-100 font-semibold text-amber-950"
: ""
}`}
>
{rainfall == null ? "-" : formatRainfall(rainfall)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="shrink-0 space-y-2">
<h3 className="text-xl font-semibold">확률관측소 면적비율</h3>
<div className="max-h-[16rem] overflow-auto rounded-md border border-border">
<table className="w-full border-collapse text-lg">
<thead className="bg-muted text-foreground">
<tr>
<th scope="col" className="border border-border px-3 py-2">
확률관측소코드
</th>
<th scope="col" className="border border-border px-3 py-2">
관측소명
</th>
<th scope="col" className="border border-border px-3 py-2 text-right">
면적비율(%)
</th>
</tr>
</thead>
<tbody>
{(data?.stations ?? []).map((station) => (
<tr key={station.prbbtObsvtrCd}>
<td className="border border-border px-3 py-2 text-center">
{station.prbbtObsvtrCd}
</td>
<td className="border border-border px-3 py-2 text-center">
{station.prbbtObsvtrNm || "-"}
</td>
<td className="border border-border px-3 py-2 text-right tabular-nums">
{formatAreaRatio(station.areaRt)}
</td>
</tr>
))}
<tr className="font-semibold">
<th colSpan={2} scope="row" className="border border-border px-3 py-2 text-center">
합계
</th>
<td className="border border-border px-3 py-2 text-right tabular-nums">
{formatAreaRatio(data?.areaRtTotal ?? Number.NaN)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</>
)}
<p className="shrink-0 text-right text-sm text-muted-foreground">
시간은 강우가 지속된 시간입니다
</p>
</DialogContent>
</Dialog>
);
}