diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx similarity index 97% rename from apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx rename to apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx index 66ed55078..a56aa7127 100644 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx @@ -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]>) => ( ); -export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) { +export default function FldngHstryDetailPanel({ detail, onClose, position }: Props) { // 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다. const [activeTab, setActiveTab] = useState("year"); diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx new file mode 100644 index 000000000..147fafdb2 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -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 = (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(FLOOD_HISTORY_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(FLOOD_HISTORY_DEFAULT_FILTERS); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); + + 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 ( +
+ +
+

침수발생이력

+
+
+ +
+
+ + + + + + + + +
+ + +
+
+ +
+ + 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)} + /> +
+ +
+ 행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다. +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts new file mode 100644 index 000000000..9812c2937 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts @@ -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 = { + 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; +}; diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx deleted file mode 100644 index f9ae60995..000000000 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx +++ /dev/null @@ -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(FLOOD_HISTORY_DEFAULT_FILTERS); - // 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다. - const [appliedFilters, setAppliedFilters] = useState(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 ( -
-
-

침수흔적도 목록

-
-
- -
-
-
- - - - - - - - -
- - -
-
-
- -
-
-

- 침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건 -

-
- -
- - - {GRID_COLUMNS.map((head) => ( - - ))} - - - - NO - 시도 - 시군구 - 강우 분석정보 - 침수재해명 - 침수년도 - 침수시간 - 침수지구명 - 침수원인상세코드 - 침수원인상세 - 평균침수위 - 침수면적 - 조사년도 - 피해금액 - - - 침수시작일자 - 침수종료일자 - 침수시작시간 - 침수종료시간 - - - - {pagedRows.length > 0 ? ( - pagedRows.map((row) => { - const isSelected = selectedHistoryId === row.historyId; - - return ( - handleRowClick(row)} - onDoubleClick={() => handleRowDoubleClick(row)} - > - {row.rowNo} - {row.ctpvNm} - {row.sggNm} - {row.startDateDisplay} - {row.endDateDisplay} - {row.startTimeDisplay} - {row.endTimeDisplay} - {row.disasterName} - {row.floodYearDisplay} - {row.floodHourDisplay} - {row.districtNameDisplay} - {row.causeCodeDisplay} - {row.causeDetailDisplay} - {row.avgFloodDepthDisplay} - {row.floodAreaDisplay} - {row.exmnYearDisplay} - {row.damageAmountDisplay} - - ); - }) - ) : ( - - - 조회된 데이터가 없습니다. - - - )} - -
-
- - {filteredRows.length > 0 ? ( - - ) : null} - -
- 행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다. -
-
-
-
- ); -} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts deleted file mode 100644 index fd6ef484e..000000000 --- a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts +++ /dev/null @@ -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 = { - 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 & { - 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; -}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 853fe2b1d..32297e76b 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -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 = () => { ) : null} {selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? ( - ; \ No newline at end of file +} as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx new file mode 100644 index 000000000..fcc8c0a90 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx @@ -0,0 +1,5 @@ +import FldngHstryPopupPage from "@/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage"; + +export default function FldngHstryPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx deleted file mode 100644 index d363c7ecf..000000000 --- a/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage"; - -export default function FloodHistoryPopupContent() { - return ; -}