sn: 침수발생이력 팝업 api 적용

This commit is contained in:
2026-07-24 14:22:41 +09:00
parent 90f0e4691b
commit 8a35d232ef
13 changed files with 850 additions and 892 deletions
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./fldngHstryTypes";
type Props = {
detail: FloodHistoryItem;
@@ -35,7 +35,7 @@ const renderRows = (rows: Array<[string, string]>) => (
</table>
);
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
export default function FldngHstryDetailPanel({ detail, onClose, position }: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("year");
@@ -0,0 +1,378 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getRawJson } from "@/components/lib/api";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
attachFloodHistoryGeometry,
buildFloodHistoryListQueryString,
buildFloodHistoryYearOptions,
FLOOD_HISTORY_DEFAULT_FILTERS,
FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE,
FLOOD_HISTORY_DOWNLOAD_HEADERS,
FLOOD_HISTORY_LIST_HEADLIST,
FLOOD_HISTORY_PAGE_SIZE,
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
normalizeFloodHistoryFilters,
toFloodHistoryTableRows,
type FloodHistoryApiItem,
type FloodHistoryFilters,
type FloodHistoryTableRow,
} from "./fldngHstryTypes";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const resolveFloodHistoryApiSort = (sort?: UiTableSort) => {
if (!sort?.key || !sort.direction) return undefined;
return { key: sort.key, direction: sort.direction } as UiTableSort;
};
const buildDownloadRows = (rows: FloodHistoryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
floodYearDisplay: row.floodYearDisplay,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
rainBgngTmDisplay: row.rainBgngTmDisplay,
rainEndTmDisplay: row.rainEndTmDisplay,
rainTotDisplay: row.rainTotDisplay,
rainStrDisplay: row.rainStrDisplay,
floodHourDisplay: row.floodHourDisplay,
districtName: row.districtName,
causeDetailCode: row.causeDetailCode,
causeDetailName: row.causeDetailName,
avgFloodDepthDisplay: row.avgFloodDepthDisplay,
floodAreaDisplay: row.floodAreaDisplay,
exmnYearDisplay: row.exmnYearDisplay,
damageAmountDisplay: row.damageAmountDisplay,
}));
const resolveRegionNames = (
row: FloodHistoryTableRow,
ctpvOptions: Array<{ text: string; val: string | number }>,
sggOptions: Array<{ text: string; val: string | number }>,
): FloodHistoryTableRow => ({
...row,
ctpvNm: resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || row.ctpvNm,
sggNm: resolveOptionText(row.sggCd ?? "", sggOptions, 5) || row.sggNm,
});
export default function FldngHstryPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
const [rows, setRows] = useState<FloodHistoryTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (rows.length === 0) return;
if (ctpv.length === 0 && sgg.length === 0) return;
setRows((current) =>
current.map((row) => resolveRegionNames(row, ctpv as any, sgg as any)),
);
}, [ctpv, rows.length, sgg]);
const floodYearOptions = useMemo(() => buildFloodHistoryYearOptions(), []);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
const fetchRows = useCallback(
async (page: number, nextFilters: FloodHistoryFilters, size: number, nextSort?: UiTableSort) => {
const normalized = normalizeFloodHistoryFilters(nextFilters);
const queryString = buildFloodHistoryListQueryString(
normalized,
page,
size,
resolveFloodHistoryApiSort(nextSort),
);
const response = await getRawJson(`/sewerage-service/api/v1/fldng/hstry?${queryString}`);
const payload = unwrapPayload<{ items?: FloodHistoryApiItem[]; total?: number }>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
const displayItems = items.map((item) => {
const hydrated = attachFloodHistoryGeometry(item);
return {
...hydrated,
ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2),
sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5),
};
});
return {
rows: toFloodHistoryTableRows(displayItems, page, size),
total: Number(payload?.total ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page: number, nextFilters: FloodHistoryFilters, nextSort?: UiTableSort) => {
setIsLoading(true);
try {
const normalized = normalizeFloodHistoryFilters(nextFilters);
const response = await fetchRows(page, normalized, FLOOD_HISTORY_PAGE_SIZE, nextSort);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
setSort(nextSort);
setAppliedSort(nextSort);
setSelectedIndex(-1);
} catch (error) {
console.error("침수발생이력 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(error instanceof Error ? error.message : "침수발생이력 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[fetchRows],
);
useEffect(() => {
void loadRows(1, FLOOD_HISTORY_DEFAULT_FILTERS, undefined);
// 최초 로딩은 한 번만 수행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
const normalized = normalizeFloodHistoryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
setFilters(normalized);
await loadRows(page, normalized, sort);
};
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters, appliedSort);
};
const handleSortChange = async (nextSort?: UiTableSort) => {
await loadRows(1, appliedFilters, nextSort);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
setIsLoading(true);
try {
const response = await fetchRows(
1,
appliedFilters,
Math.max(total, FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE),
appliedSort,
);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(response.rows) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
});
} catch (error) {
console.error("침수발생이력 다운로드 중 오류:", error);
alert(error instanceof Error ? error.message : "침수발생이력 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수발생이력</h1>
<div />
</header>
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수발생년도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{floodYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수재해명</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="sn-popup-actions md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<FloodHistoryTableRow>
headName={<>침수발생이력 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={FLOOD_HISTORY_LIST_HEADLIST}
pageSize={FLOOD_HISTORY_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handlePageChange(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => void handleSortChange(nextSort)}
/>
</div>
<div className="sn-popup-help-text">
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
</div>
</section>
</main>
);
}
@@ -0,0 +1,446 @@
"use client";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLOOD_HISTORY_PAGE_SIZE = 10;
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE = 2000;
export type FloodHistoryFilters = {
ctpvCd: string;
sggCd: string;
floodYear: string;
disasterName: string;
};
export type FloodHistoryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FloodHistoryApiItem = {
fid: string;
fldngMapFid?: string;
fldngWtlvMapSn?: number | null;
taskClCd?: string;
fldngDstrNm?: string;
exmnYr?: string;
avgFldngWtlv?: number | null;
fldngArea?: number | null;
dtlOcrnCsCn?: string;
stdgCd?: string;
ctpvCd?: string;
sggCd?: string;
dataSn?: number | null;
bgngYmd?: string;
endYmd?: string;
fldngBgngHm?: string;
fldngEndHm?: string;
fldngCsDtlCd?: string;
fldngDstrctNm?: string;
damAmt?: number | null;
fldngHr?: number | null;
fldngYr?: string;
cstrnFbctnYn?: string;
atchFileSn?: number | null;
confArea?: number | null;
confLen?: number | null;
rainBgngTm?: string | null;
rainEndTm?: string | null;
rainTot?: number | null;
rainStr?: number | null;
};
export type FloodHistoryItem = FloodHistoryApiItem & {
historyId: string;
ctpvNm: string;
sggNm: string;
floodYear: string;
exmnYear: string;
disasterName: string;
districtName: string;
floodAreaHa: number;
avgFloodDepthM: number;
floodDepthM: number;
gradeLabel: string;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
floodHour: string;
causeDetailCode: string;
causeDetailName: string;
damageAmount: string;
rainStartDateTime: string;
rainEndDateTime: string;
rainStartTime: string;
rainEndTime: string;
totalRainfallMm: number;
rainfallIntensityMmHr: number;
areaRing: [number, number][];
depthZones: FloodHistoryDepthZone[];
};
export type FloodHistoryTableRow = FloodHistoryItem & {
rowNo: number;
floodYearDisplay: string;
floodHourDisplay: string;
avgFloodDepthDisplay: string;
floodAreaDisplay: string;
exmnYearDisplay: string;
damageAmountDisplay: string;
rainBgngTmDisplay: string;
rainEndTmDisplay: string;
rainTotDisplay: string;
rainStrDisplay: string;
};
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
ctpvCd: "0",
sggCd: "0",
floodYear: "",
disasterName: "",
};
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
{ color: "#67d8ff", label: "1.0~2.0m" },
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLOOD_HISTORY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
{ id: "floodYearDisplay", title: "침수년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
{ id: "ctpvNm", title: "시도", widthPercent: 7, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 8, sortable: true, sortKey: "sggCd" },
{ id: "disasterName", title: "침수재해명", widthPercent: 14, sortable: true, sortKey: "fldngDstrNm" },
{ id: "rainBgngTmDisplay", title: "강우시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
{ id: "rainEndTmDisplay", title: "강우종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8, sortable: true, sortKey: "rainTot" },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8, sortable: true, sortKey: "rainStr" },
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7, sortable: true, sortKey: "fldngHr" },
{ id: "districtName", title: "침수지구명", widthPercent: 10, sortable: true, sortKey: "fldngDstrctNm" },
{ id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
{ id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
{ id: "avgFloodDepthDisplay", title: "평균침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
{ id: "floodAreaDisplay", title: "침수면적", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6, sortable: true, sortKey: "exmnYr" },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9, sortable: true, sortKey: "damAmt" },
];
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "NO",
floodYearDisplay: "침수년도",
ctpvNm: "시도",
sggNm: "시군구",
disasterName: "침수재해명",
rainBgngTmDisplay: "강우시작일시",
rainEndTmDisplay: "강우종료일시",
rainTotDisplay: "총강우량(mm)",
rainStrDisplay: "강우강도(mm/hr)",
floodHourDisplay: "침수시간",
districtName: "침수지구명",
causeDetailCode: "침수원인상세코드",
causeDetailName: "침수원인상세",
avgFloodDepthDisplay: "평균침수위",
floodAreaDisplay: "침수면적",
exmnYearDisplay: "조사년도",
damageAmountDisplay: "피해금액",
};
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
const jitterX = radiusX * scale * Math.cos(angle);
const jitterY = radiusY * scale * Math.sin(angle);
return [centerX + jitterX, centerY + jitterY] as [number, number];
});
return [...points, points[0]];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce((sum, char, index) => sum + char.charCodeAt(0) * (index + 1), 0);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FloodHistoryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!/^\d{8}$/.test(source)) return "";
return `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`;
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3) return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
const date = new Date(source);
if (Number.isNaN(date.getTime())) return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toFixed(digits)}${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
};
const buildCauseDetailName = (item: FloodHistoryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFloodHistoryFilters = (
filters: FloodHistoryFilters,
): FloodHistoryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
});
export const buildFloodHistoryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFloodHistoryListQueryString = (
filters: FloodHistoryFilters,
page: number,
size: number,
sort?: UiTableSort,
) => {
const normalized = normalizeFloodHistoryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
if (normalized.ctpvCd !== "0") params.set("ctpvCd", normalized.ctpvCd.slice(0, 2));
if (normalized.sggCd !== "0") params.set("sggCd", normalized.sggCd.slice(0, 5));
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
if (normalized.disasterName) params.set("fldngDstrNm", normalized.disasterName);
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
return params.toString();
};
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
export const attachFloodHistoryGeometry = (item: FloodHistoryApiItem): FloodHistoryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1, "시간"),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0), "원"),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createDepthZones(historyId, geometrySeed.centerX, geometrySeed.centerY),
};
};
export const toFloodHistoryTableRows = (
items: FloodHistoryItem[],
page: number,
pageSize = FLOOD_HISTORY_PAGE_SIZE,
): FloodHistoryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear ? `${item.floodYear}년` : "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear ? `${item.exmnYear}년` : "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatNumber(item.totalRainfallMm, 1),
rainStrDisplay: formatNumber(item.rainfallIntensityMmHr, 1),
}));
const resolveFloodHistoryNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
const resolveFloodHistoryLayerType = (node: any) =>
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
export const isFloodHistoryNode = (node: any) => {
const name = resolveFloodHistoryNodeName(node);
const layerType = resolveFloodHistoryLayerType(node);
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
};
@@ -1,408 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import useStore from "@/store/useStore";
import {
buildFloodHistoryYearOptions,
buildFloodHistoryTableRows,
FLOOD_HISTORY_DEFAULT_FILTERS,
FLOOD_HISTORY_DOWNLOAD_HEADERS,
FLOOD_HISTORY_ITEMS,
FLOOD_HISTORY_PAGE_SIZE,
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
formatFloodArea,
formatFloodDepth,
normalizeFloodHistoryFilters,
type FloodHistoryFilters,
type FloodHistoryItem,
} from "./floodHistoryTypes";
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const GRID_COLUMNS = [
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
] as const;
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
rows.map((row, index) => ({
rowNo: index + 1,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
exmnYearDisplay: `${row.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
causeDetailDisplay: row.causeDetailName,
startDateDisplay: row.startDate,
endDateDisplay: row.endDate,
startTimeDisplay: row.startTime,
endTimeDisplay: row.endTime,
causeCodeDisplay: row.causeDetailCode,
districtNameDisplay: row.districtName,
damageAmountDisplay: row.damageAmount,
floodHourDisplay: row.floodHour,
floodYearDisplay: `${row.floodYear}년`,
}));
export default function FloodHistoryPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
const [currentPage, setCurrentPage] = useState(1);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const floodYearOptions = useMemo(
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
[],
);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
const filteredRows = useMemo(() => {
const normalized = normalizeFloodHistoryFilters(appliedFilters);
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
return FLOOD_HISTORY_ITEMS.filter((item) => {
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
if (selectedSggName && item.sggNm !== selectedSggName) return false;
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
if (
disasterToken &&
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
) {
return false;
}
return true;
}).sort((left, right) => {
if (left.floodYear !== right.floodYear) {
return Number(right.floodYear) - Number(left.floodYear);
}
return left.disasterName.localeCompare(right.disasterName, "ko");
});
}, [appliedFilters, ctpv, sgg]);
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
const end = start + FLOOD_HISTORY_PAGE_SIZE;
return buildFloodHistoryTableRows(
filteredRows.slice(start, end),
currentPage,
FLOOD_HISTORY_PAGE_SIZE,
);
}, [currentPage, filteredRows]);
const handleSearch = () => {
const normalized = normalizeFloodHistoryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
if (normalized.floodYear && normalized.floodYear.length !== 4) {
alert("침수발생년도는 4자리 연도로 입력하세요.");
return;
}
setAppliedFilters(normalized);
setCurrentPage(1);
setSelectedHistoryId("");
};
const handleDownload = async () => {
if (filteredRows.length === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(filteredRows) as unknown as JSON[],
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수흔적도"],
headerRange: "A1:Q1",
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
});
};
const handleRowDoubleClick = (row: FloodHistoryItem) => {
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
window.close();
};
const handleRowClick = (row: FloodHistoryItem) => {
setSelectedHistoryId(row.historyId);
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수흔적도 목록</h1>
<div />
</header>
<section className="sn-popup-section">
<div className="sn-popup-filter-panel-compact">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수발생년도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{floodYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수재해명</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<Table variant="secondary" className="w-max min-w-full">
<colgroup>
{GRID_COLUMNS.map((head) => (
<col key={head.id} style={{ width: head.width }} />
))}
</colgroup>
<TableHeader className="font-bold">
<TableRow>
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
</TableRow>
<TableRow>
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-center">
{pagedRows.length > 0 ? (
pagedRows.map((row) => {
const isSelected = selectedHistoryId === row.historyId;
return (
<TableRow
key={row.historyId}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
onClick={() => handleRowClick(row)}
onDoubleClick={() => handleRowDoubleClick(row)}
>
<TableCell>{row.rowNo}</TableCell>
<TableCell>{row.ctpvNm}</TableCell>
<TableCell>{row.sggNm}</TableCell>
<TableCell>{row.startDateDisplay}</TableCell>
<TableCell>{row.endDateDisplay}</TableCell>
<TableCell>{row.startTimeDisplay}</TableCell>
<TableCell>{row.endTimeDisplay}</TableCell>
<TableCell className="text-left">{row.disasterName}</TableCell>
<TableCell>{row.floodYearDisplay}</TableCell>
<TableCell>{row.floodHourDisplay}</TableCell>
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
<TableCell>{row.causeCodeDisplay}</TableCell>
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
<TableCell>{row.exmnYearDisplay}</TableCell>
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
조회된 데이터가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{filteredRows.length > 0 ? (
<UiPageNations
total={filteredRows.length}
cur={currentPage}
pageSize={FLOOD_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
) : null}
<div className="sn-popup-help-text">
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
</div>
</div>
</section>
</main>
);
}
@@ -1,458 +0,0 @@
"use client";
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLOOD_HISTORY_PAGE_SIZE = 10;
export type FloodHistoryFilters = {
ctpvCd: string;
sggCd: string;
floodYear: string;
disasterName: string;
};
export type FloodHistoryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FloodHistoryItem = {
historyId: string;
ctpvNm: string;
sggNm: string;
floodYear: string;
exmnYear: string;
disasterName: string;
districtName: string;
floodAreaHa: number;
avgFloodDepthM: number;
floodDepthM: number;
gradeLabel: string;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
floodHour: string;
causeDetailCode: string;
causeDetailName: string;
damageAmount: string;
rainStartTime: string;
rainEndTime: string;
totalRainfallMm: number;
rainfallIntensityMmHr: number;
areaRing: [number, number][];
depthZones: FloodHistoryDepthZone[];
};
export type FloodHistoryTableRow = FloodHistoryItem & {
rowNo: number;
exmnYearDisplay: string;
avgFloodDepthDisplay: string;
floodAreaDisplay: string;
causeDetailDisplay: string;
startDateDisplay: string;
endDateDisplay: string;
startTimeDisplay: string;
endTimeDisplay: string;
causeCodeDisplay: string;
districtNameDisplay: string;
damageAmountDisplay: string;
floodHourDisplay: string;
floodYearDisplay: string;
};
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
ctpvCd: "0",
sggCd: "0",
floodYear: "",
disasterName: "",
};
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "NO",
ctpvNm: "시도",
sggNm: "시군구",
disasterName: "침수재해명",
exmnYearDisplay: "조사년도",
avgFloodDepthDisplay: "평균침수위",
floodAreaDisplay: "침수면적",
causeDetailDisplay: "침수원인상세",
startDateDisplay: "침수시작일자",
endDateDisplay: "침수종료일자",
startTimeDisplay: "침수시작시간",
endTimeDisplay: "침수종료시간",
causeCodeDisplay: "침수원인상세코드",
districtNameDisplay: "침수지구명",
damageAmountDisplay: "피해금액",
floodHourDisplay: "침수시간",
floodYearDisplay: "침수년도",
};
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
{ color: "#67d8ff", label: "1.0~2.0m" },
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
const jitterX = radiusX * scale * Math.cos(angle);
const jitterY = radiusY * scale * Math.sin(angle);
return [centerX + jitterX, centerY + jitterY] as [number, number];
});
return [...points, points[0]];
};
const createDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FloodHistoryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 5200,
centerY: centerY - 2600,
radiusX: 3600,
radiusY: 2900,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 2600,
centerY: centerY - 1200,
radiusX: 4300,
radiusY: 3600,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX: centerX - 600,
centerY: centerY + 2600,
radiusX: 5600,
radiusY: 4700,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 4200,
centerY: centerY + 5200,
radiusX: 3200,
radiusY: 2500,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 600,
centerY: centerY - 5200,
radiusX: 1500,
radiusY: 1350,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const createHistory = (
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
centerX: number;
centerY: number;
width: number;
height: number;
},
): FloodHistoryItem => {
const { centerX, centerY, width, height, ...detail } = item;
return {
...detail,
areaRing: createIrregularRing({
centerX,
centerY,
radiusX: width / 2,
radiusY: height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createDepthZones(detail.historyId, centerX, centerY),
};
};
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
createHistory({
historyId: "FH-2024-001",
ctpvNm: "부산광역시",
sggNm: "중구",
floodYear: "2024",
exmnYear: "2025",
disasterName: "중앙동 저지대 침수",
districtName: "중앙동 저지대",
floodAreaHa: 1.8,
avgFloodDepthM: 0.62,
floodDepthM: 0.62,
gradeLabel: "1등급",
startDate: "2024-07-18",
endDate: "2024-07-18",
startTime: "14:10",
endTime: "18:20",
floodHour: "4.2시간",
causeDetailCode: "FC-01",
causeDetailName: "집중강우",
damageAmount: "120,000,000원",
rainStartTime: "13:50",
rainEndTime: "18:20",
totalRainfallMm: 91,
rainfallIntensityMmHr: 31,
centerX: 1122000,
centerY: 1705000,
width: 18000,
height: 14000,
}),
createHistory({
historyId: "FH-2023-002",
ctpvNm: "경상남도",
sggNm: "창원시",
floodYear: "2023",
exmnYear: "2024",
disasterName: "팔용동 공단 배수지연 침수",
districtName: "팔용동 공단",
floodAreaHa: 4.8,
avgFloodDepthM: 1.92,
floodDepthM: 1.92,
gradeLabel: "2등급",
startDate: "2023-08-11",
endDate: "2023-08-11",
startTime: "09:40",
endTime: "15:10",
floodHour: "5.5시간",
causeDetailCode: "FC-04",
causeDetailName: "배수시설 처리능력 초과",
damageAmount: "340,000,000원",
rainStartTime: "08:55",
rainEndTime: "15:05",
totalRainfallMm: 138,
rainfallIntensityMmHr: 42,
centerX: 1081000,
centerY: 1778000,
width: 25000,
height: 17000,
}),
createHistory({
historyId: "FH-2022-003",
ctpvNm: "울산광역시",
sggNm: "남구",
floodYear: "2022",
exmnYear: "2023",
disasterName: "태화강 제내지 도로 침수",
districtName: "여천동 하류부",
floodAreaHa: 3.1,
avgFloodDepthM: 1.15,
floodDepthM: 1.15,
gradeLabel: "2등급",
startDate: "2022-09-06",
endDate: "2022-09-06",
startTime: "07:20",
endTime: "12:40",
floodHour: "5.3시간",
causeDetailCode: "FC-02",
causeDetailName: "하천 수위 상승",
damageAmount: "210,000,000원",
rainStartTime: "06:30",
rainEndTime: "11:50",
totalRainfallMm: 117,
rainfallIntensityMmHr: 37,
centerX: 1168000,
centerY: 1811000,
width: 22000,
height: 16000,
}),
createHistory({
historyId: "FH-2021-004",
ctpvNm: "부산광역시",
sggNm: "서구",
floodYear: "2021",
exmnYear: "2022",
disasterName: "서대신동 급경사지 유출 침수",
districtName: "서대신동 일원",
floodAreaHa: 2.4,
avgFloodDepthM: 0.85,
floodDepthM: 0.85,
gradeLabel: "1등급",
startDate: "2021-07-07",
endDate: "2021-07-07",
startTime: "16:00",
endTime: "20:10",
floodHour: "4.1시간",
causeDetailCode: "FC-03",
causeDetailName: "우수관로 월류",
damageAmount: "175,000,000원",
rainStartTime: "15:20",
rainEndTime: "19:50",
totalRainfallMm: 104,
rainfallIntensityMmHr: 29,
centerX: 1111000,
centerY: 1709000,
width: 20000,
height: 14500,
}),
createHistory({
historyId: "FH-2020-005",
ctpvNm: "부산광역시",
sggNm: "영도구",
floodYear: "2020",
exmnYear: "2021",
disasterName: "청학동 해안도로 월파 침수",
districtName: "청학동 해안부",
floodAreaHa: 1.2,
avgFloodDepthM: 0.48,
floodDepthM: 0.48,
gradeLabel: "1등급",
startDate: "2020-10-22",
endDate: "2020-10-22",
startTime: "03:10",
endTime: "06:30",
floodHour: "3.3시간",
causeDetailCode: "FC-06",
causeDetailName: "해안 월파",
damageAmount: "82,000,000원",
rainStartTime: "02:40",
rainEndTime: "05:50",
totalRainfallMm: 55,
rainfallIntensityMmHr: 18,
centerX: 1129000,
centerY: 1695000,
width: 15000,
height: 12000,
}),
createHistory({
historyId: "FH-2019-006",
ctpvNm: "경상남도",
sggNm: "김해시",
floodYear: "2019",
exmnYear: "2020",
disasterName: "장유 배수분구 내수 침수",
districtName: "장유 배수분구",
floodAreaHa: 2.9,
avgFloodDepthM: 0.96,
floodDepthM: 0.96,
gradeLabel: "1등급",
startDate: "2019-09-21",
endDate: "2019-09-21",
startTime: "11:30",
endTime: "16:15",
floodHour: "4.8시간",
causeDetailCode: "FC-05",
causeDetailName: "배수펌프 지연 가동",
damageAmount: "143,000,000원",
rainStartTime: "10:45",
rainEndTime: "15:50",
totalRainfallMm: 88,
rainfallIntensityMmHr: 24,
centerX: 1098000,
centerY: 1734000,
width: 21000,
height: 15000,
}),
];
export const normalizeFloodHistoryFilters = (
filters: FloodHistoryFilters,
): FloodHistoryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
});
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
export const buildFloodHistoryTableRows = (
items: FloodHistoryItem[],
page: number,
pageSize = FLOOD_HISTORY_PAGE_SIZE,
): FloodHistoryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
exmnYearDisplay: `${item.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
causeDetailDisplay: item.causeDetailName,
startDateDisplay: item.startDate,
endDateDisplay: item.endDate,
startTimeDisplay: item.startTime,
endTimeDisplay: item.endTime,
causeCodeDisplay: item.causeDetailCode,
districtNameDisplay: item.districtName,
damageAmountDisplay: item.damageAmount,
floodHourDisplay: item.floodHour,
floodYearDisplay: `${item.floodYear}년`,
}));
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
Array.from(new Set(items.map((item) => item.floodYear)))
.sort((left, right) => Number(right) - Number(left));
const resolveFloodHistoryNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
const resolveFloodHistoryLayerType = (node: any) =>
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
export const isFloodHistoryNode = (node: any) => {
const name = resolveFloodHistoryNodeName(node);
const layerType = resolveFloodHistoryLayerType(node);
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
};
+3 -3
View File
@@ -83,7 +83,7 @@ import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap
import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
import {
buildSinkholeDefaultYearRange,
buildSinkholeFiltersFromYearRange,
@@ -118,7 +118,7 @@ import {
FLOOD_HISTORY_POPUP_KEY,
isFloodHistoryNode,
type FloodHistoryItem,
} from "./components/floodHistory/floodHistoryTypes";
} from "./components/fldngHstry/fldngHstryTypes";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import SearchModal from "./searchModal/searchModal";
import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth";
@@ -7288,7 +7288,7 @@ const Map = () => {
</div>
) : null}
{selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? (
<FloodHistoryDetailPanel
<FldngHstryDetailPanel
detail={selectedFloodHistoryDetail}
position={floodHistoryDetailPopupPosition}
onClose={closeFloodHistoryDetail}
@@ -38,10 +38,10 @@
| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 진행 | tree 목록 진입 popup 키/크기 정의 |
| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 진행 | placeholder에서 실제 popup content로 교체 |
| 분석도구 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 진행 | 분석도구에서 `침수발생이력` 제거 |
| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` | 진행 | popup 본문 연결 |
| 신규 popup 페이지 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` | 진행 | 조회조건/목록/다운로드 |
| 신규 상세패널 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` | 진행 | 기본정보/발생일시/원인·피해 탭 |
| 신규 타입/샘플데이터 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` | 진행 | mock 데이터, postMessage 타입, legend 정의 |
| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/FldngHstryPopupContent.tsx` | 진행 | popup 본문 연결 |
| 신규 popup 페이지 | `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx` | 진행 | 조회조건/목록/다운로드 |
| 신규 상세패널 | `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx` | 진행 | 기본정보/발생일시/원인·피해 탭 |
| 신규 타입/샘플데이터 | `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts` | 진행 | mock 데이터, postMessage 타입, legend 정의 |
## 4. 구현 범위
@@ -27,10 +27,10 @@
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts`
- `FE_APP_ROOT/src/features/windowPopup/ui/FldngHstryPopupContent.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts`
- 구현 내용
- 분석도구의 `침수발생이력` 항목은 제거하고, 레이어 트리 `침수흔적도` 목록 진입으로 popup을 열도록 바꿨다.
- `침수발생이력` popup placeholder를 실제 popup 페이지로 교체했다.
@@ -23,7 +23,7 @@
- FE 변경분 기준 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`을 실행했다.
- 이번 변경으로 새로 생긴 오류는 없었고, 아래 선행 타입 오류 때문에 전체 통과는 하지 못했다.
- `app/(map-layout)/map/components/common/index.ts`
- `app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts`
- `app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts`
- `app/(map-layout)/map/page.tsx`
- `app/html/(map-layout)/map/components/common/index.ts`
- `app/html/(with-base-layout)/layout.tsx`
@@ -121,8 +121,8 @@ export const windowPopupDefinitions = {
width: 1080,
height: 760,
},
floodHistoryPopup: {
key: "floodHistoryPopup",
fldngHstryPopup: {
key: "fldngHstryPopup",
title: "침수발생이력",
width: 1160,
height: 820,
@@ -16,7 +16,7 @@ import SinkholePopupContent from "./ui/SinkholePopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import FloodHistoryPopupContent from "./ui/FloodHistoryPopupContent";
import FldngHstryPopupContent from "./ui/FldngHstryPopupContent";
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
@@ -102,9 +102,9 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.floodPredictionMapPopup,
component: FloodPredictionPlaceholderPopupContent,
},
floodHistoryPopup: {
...windowPopupDefinitions.floodHistoryPopup,
component: FloodHistoryPopupContent,
fldngHstryPopup: {
...windowPopupDefinitions.fldngHstryPopup,
component: FldngHstryPopupContent,
},
gisEditPopup: {
...windowPopupDefinitions.gisEditPopup,
@@ -122,4 +122,4 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.flowRiskPopup,
component: WaterRiskPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,5 @@
import FldngHstryPopupPage from "@/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage";
export default function FldngHstryPopupContent() {
return <FldngHstryPopupPage />;
}
@@ -1,5 +0,0 @@
import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage";
export default function FloodHistoryPopupContent() {
return <FloodHistoryPopupPage />;
}