sn: 확률강우량 모달 추가
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user