From e47c00f0b60987714879b930e4a405d62cb59aeb Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 15 Jun 2026 16:28:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=A8=EC=88=98=ED=9D=94=EC=A0=81=EB=8F=84?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D/=EC=A7=80=EB=8F=84=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20mock=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20md=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 2 - .../floodHistory/FloodHistoryDetailPanel.tsx | 142 ++++++ .../floodHistory/FloodHistoryPopupPage.tsx | 408 ++++++++++++++++ .../floodHistory/floodHistoryTypes.ts | 458 ++++++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 260 +++++++++- .../001-qa-SFR-011-009-침수발생이력-통합검증계획.md | 79 +++ .../002-qa-SFR-011-009-침수발생이력-통합검증결과.md | 52 ++ .../001-fe-SFR-011-009-침수발생이력-작업카드.md | 117 +++++ .../001-fe-SFR-011-009-침수발생이력-통합구현기록.md | 73 +++ .../windowPopup/model/popupDefinitions.ts | 4 +- apps/sn/src/features/windowPopup/registry.tsx | 11 +- .../ui/FloodHistoryPopupContent.tsx | 5 + .../business_components/src/ui/map/GisTree.js | 8 +- 13 files changed, 1602 insertions(+), 17 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts create mode 100644 apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 902be2916..b01c8b019 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -5,7 +5,6 @@ import { ChartColumnBig, ChevronLeft, ChevronRight, - History, PanelsTopLeft, PencilRuler, Search, @@ -31,7 +30,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{ { key: "infoSearch", label: "정보검색", icon: Search }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, - { key: "floodHistoryPopup", label: "침수발생이력", icon: History }, { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx new file mode 100644 index 000000000..66ed55078 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes"; + +type Props = { + detail: FloodHistoryItem; + onClose: () => void; + position: { + left: number; + top: number; + }; +}; + +type DetailTab = "year" | "disaster" | "rainfall"; + +const renderRows = (rows: Array<[string, string]>) => ( + + + + + + + {rows.map(([label, value], index) => ( + + + + + ))} + +
+ {label} + + {value} +
+); + +export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) { + // 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다. + const [activeTab, setActiveTab] = useState("year"); + + const yearRows = useMemo( + () => [ + ["침수심", `${detail.floodDepthM.toFixed(2)} m`], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수년도", `${detail.floodYear}년`], + ["등급", detail.gradeLabel], + ["침수구역", detail.districtName], + ] as Array<[string, string]>, + [detail], + ); + + const disasterRows = useMemo( + () => [ + ["침수재해명", detail.disasterName], + ["침수면적", formatFloodArea(detail.floodAreaHa)], + ["침수원인상세명", detail.causeDetailName], + ["침수시작년월일", detail.startDate], + ["침수종료년월일", detail.endDate], + ["침수시작시간", detail.startTime], + ] as Array<[string, string]>, + [detail], + ); + + const rainfallRows = useMemo( + () => [ + ["침수시작일자", detail.startDate], + ["침수종료일자", detail.endDate], + ["침수시작시간", detail.startTime], + ["침수종료시간", detail.endTime], + ["침수시간", detail.floodHour], + ["강우시작시간", detail.rainStartTime], + ["강우종료시간", detail.rainEndTime], + ["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`], + ["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`], + ["침수시간", detail.floodHour], + ["침수원인상세코드", detail.causeDetailCode], + ["피해금액", detail.damageAmount], + ] as Array<[string, string]>, + [detail], + ); + + const currentRows = activeTab === "year" + ? yearRows + : activeTab === "disaster" + ? disasterRows + : rainfallRows; + + return ( +
+
+
상세정보
+ +
+ +
+
+ {[ + ["year", "년도탭"], + ["disaster", "재해명탭"], + ["rainfall", "침수발생강우탭"], + ].map(([tabId, label]) => { + const isActive = activeTab === tabId; + + return ( + + ); + })} +
+
+ +
+
+ {renderRows(currentRows)} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx new file mode 100644 index 000000000..346ee33ec --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx @@ -0,0 +1,408 @@ +"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 new file mode 100644 index 000000000..fd6ef484e --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts @@ -0,0 +1,458 @@ +"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 7bdddc77f..95f1cbb8a 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -80,6 +80,7 @@ import { import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; +import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; import { applySinkholeNodeFilters, applySinkholeYearFilters, @@ -109,6 +110,15 @@ import { type SinkholeNearbyPipe, type SinkholeSyncSummary, } from "./components/sinkhole/sinkholeTypes"; +import { + FLOOD_HISTORY_AREA_LAYER_ID, + FLOOD_HISTORY_DEPTH_LAYER_ID, + FLOOD_HISTORY_LAYER_TYPE, + FLOOD_HISTORY_ROW_SELECT_MESSAGE, + FLOOD_HISTORY_POPUP_KEY, + isFloodHistoryNode, + type FloodHistoryItem, +} from "./components/floodHistory/floodHistoryTypes"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; import { WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LEGEND_ITEMS } from "./waterDepth/waterDepth"; @@ -157,6 +167,7 @@ const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID"; const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; +const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [100, 380, 120, 120]; type FlowDirectionUsageStyle = { color: string; @@ -727,6 +738,44 @@ const getSelectedFeatureLayerTypeName = (data: any) => { return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); }; +const createFloodHistoryAreaStyle = (label: string) => + new Style({ + fill: new Fill({ + color: "rgba(30, 136, 229, 0.16)", + }), + stroke: new Stroke({ + color: "rgba(30, 136, 229, 0.95)", + width: 3, + }), + text: new Text({ + text: label, + font: "700 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }), + padding: [4, 7, 4, 7], + overflow: true, + }), + zIndex: 9994, + }); + +const createFloodHistoryDepthStyle = (label: string, color: string) => + new Style({ + fill: new Fill({ + color, + }), + stroke: new Stroke({ + color: "rgba(71, 85, 105, 0.72)", + width: 1.4, + }), + text: new Text({ + text: label, + font: "700 11px sans-serif", + fill: new Fill({ color: "#0f172a" }), + overflow: true, + }), + zIndex: 9993, + }); + const normalizeGisEditFid = (value: unknown) => { const raw = String(value ?? "").trim(); if (!raw) return ""; @@ -2242,6 +2291,7 @@ const Map = () => { const [mapReady, setMapReady] = useState(false); const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); + const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = @@ -2251,6 +2301,8 @@ const Map = () => { ); const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false); const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS); + const [selectedFloodHistoryDetail, setSelectedFloodHistoryDetail] = useState(null); + const [floodHistoryDetailPopupPosition, setFloodHistoryDetailPopupPosition] = useState<{ left: number; top: number } | null>(null); const [selectedSinkholeDetail, setSelectedSinkholeDetail] = useState(null); const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = useState([]); const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = useState(null); @@ -2269,6 +2321,9 @@ const Map = () => { const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); const sinkholePopupWindowRef = useRef(null); + // 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다. + const floodHistoryAreaLayerRef = useRef | null>(null); + const floodHistoryDepthLayerRef = useRef | null>(null); const sinkholePopupCloseWatcherRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); @@ -3857,6 +3912,121 @@ const Map = () => { }); }, []); + const ensureFloodHistoryAreaSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryAreaLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9993, + }); + layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryAreaLayerRef.current = layer; + } + + return floodHistoryAreaLayerRef.current.getSource(); + }, [mapReady]); + + const ensureFloodHistoryDepthSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryDepthLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9992, + }); + layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryDepthLayerRef.current = layer; + } + + return floodHistoryDepthLayerRef.current.getSource(); + }, [mapReady]); + + const clearFloodHistoryLayers = useCallback(() => { + floodHistoryAreaLayerRef.current?.getSource?.()?.clear?.(); + floodHistoryDepthLayerRef.current?.getSource?.()?.clear?.(); + }, []); + + const renderSelectedFloodHistory = useCallback( + (detail: FloodHistoryItem | null) => { + const areaSource = ensureFloodHistoryAreaSource(); + const depthSource = ensureFloodHistoryDepthSource(); + if (!areaSource || !depthSource) return; + + areaSource.clear(); + depthSource.clear(); + + if (!detail) return; + + const areaFeature = new Feature({ + geometry: new Polygon([detail.areaRing]), + }); + areaFeature.setStyle(createFloodHistoryAreaStyle(`${detail.floodYear}년 ${detail.districtName}`)); + areaSource.addFeature(areaFeature); + + detail.depthZones.forEach((zone) => { + const zoneFeature = new Feature({ + geometry: new Polygon([zone.ring]), + }); + zoneFeature.setStyle(createFloodHistoryDepthStyle(zone.depthLabel, zone.color)); + depthSource.addFeature(zoneFeature); + }); + }, + [ensureFloodHistoryAreaSource, ensureFloodHistoryDepthSource], + ); + + const focusFloodHistoryOnMap = useCallback((detail: FloodHistoryItem) => { + if (!gisMaps.current) return; + + const geometry = new Polygon([detail.areaRing]); + gisMaps.current.getView?.().fit?.(geometry.getExtent(), { + duration: 350, + padding: FLOOD_HISTORY_SELECTION_PADDING, + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + }, []); + + const clearFloodHistorySelection = useCallback(() => { + setSelectedFloodHistoryDetail(null); + setFloodHistoryDetailPopupPosition(null); + clearFloodHistoryLayers(); + }, [clearFloodHistoryLayers]); + + const closeFloodHistoryDetail = useCallback(() => { + setFloodHistoryDetailPopupPosition(null); + }, []); + + const handleFloodHistoryRowSelect = useCallback( + (detail: FloodHistoryItem) => { + setSelectedFloodHistoryDetail(detail); + renderSelectedFloodHistory(detail); + focusFloodHistoryOnMap(detail); + setIsSearchModalOpen(false); + }, + [focusFloodHistoryOnMap, renderSelectedFloodHistory, setIsSearchModalOpen], + ); + + const openFloodHistoryPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + return popup; + }, []); + const buildSinkholeQueryString = useCallback( (filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT, checkedNodesData = checkedNodesDataRef.current) => buildSinkholeListQueryString(applySinkholeYearFilters(filters, checkedNodesData), ctpv, sgg, page, size), @@ -4329,6 +4499,18 @@ const Map = () => { return () => window.removeEventListener("message", handleSinkholePopupMessage); }, [loadSinkholeDetail, sinkholeScope]); + useEffect(() => { + const handleFloodHistoryPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== FLOOD_HISTORY_ROW_SELECT_MESSAGE || !event.data?.row) return; + + handleFloodHistoryRowSelect(event.data.row as FloodHistoryItem); + }; + + window.addEventListener("message", handleFloodHistoryPopupMessage); + return () => window.removeEventListener("message", handleFloodHistoryPopupMessage); + }, [handleFloodHistoryRowSelect]); + // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { const waterDepthLayers = checkedNodesData.filter(node => @@ -4378,6 +4560,11 @@ const Map = () => { handleWaterDepthVisibility(checkedIds, checkedNodesData); setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); + const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node)); + setIsFloodHistoryChecked(floodHistoryChecked); + if (!floodHistoryChecked) { + clearFloodHistorySelection(); + } const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node)); setIsSinkholeChecked(sinkholeChecked); if (sinkholeChecked) { @@ -4387,7 +4574,7 @@ const Map = () => { removeSinkholeLayer(); closeSinkholeDetail(); } - }, [closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); + }, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -4399,13 +4586,17 @@ const Map = () => { openSinkholePopupWindow(); return false; } + if (isFloodHistoryNode(node)) { + openFloodHistoryPopupWindow(); + return false; + } if (isSinkholeYearNode(node)) { return false; } return true; }, - [openAgedPipeList, openSinkholePopupWindow], + [openAgedPipeList, openFloodHistoryPopupWindow, openSinkholePopupWindow], ); // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. @@ -5191,9 +5382,11 @@ const Map = () => { ); }, [ closeSinkholeDetail, + closeFloodHistoryDetail, clearSinkholeFocusedPipeLayer, focusSinkholeNearbyPipe, handleApplyGisEditPipeBasicInfo, + selectedFloodHistoryDetail, selectedSinkholeDetail, selectedSinkholeNearbyPipes, setIsSearchModalOpen, @@ -6211,6 +6404,58 @@ const Map = () => { renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters); }, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]); + useEffect(() => { + renderSelectedFloodHistory(selectedFloodHistoryDetail); + }, [renderSelectedFloodHistory, selectedFloodHistoryDetail]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + if (!selectedFloodHistoryDetail || !isFloodHistoryChecked) return; + + let clickedFloodHistory = false; + map.forEachFeatureAtPixel( + event.pixel, + (feature: any, layer: any) => { + if ( + layer === floodHistoryAreaLayerRef.current || + layer === floodHistoryDepthLayerRef.current + ) { + clickedFloodHistory = true; + return feature; + } + return undefined; + }, + { hitTolerance: 4 }, + ); + + if (!clickedFloodHistory) return; + + const size = map.getSize?.() ?? [1200, 800]; + const popupWidth = 220; + const popupHeight = 210; + const nextLeft = Math.min(Math.max(event.pixel[0] + 12, 12), size[0] - popupWidth - 12); + const nextTop = Math.min(Math.max(event.pixel[1] - 12, 12), size[1] - popupHeight - 12); + setFloodHistoryDetailPopupPosition({ + left: nextLeft, + top: nextTop, + }); + setIsSearchModalOpen(false); + }); + + return () => { + unByKey(clickKey); + }; + }, [isFloodHistoryChecked, mapReady, selectedFloodHistoryDetail, setIsSearchModalOpen]); + + useEffect(() => { + return () => { + clearFloodHistoryLayers(); + }; + }, [clearFloodHistoryLayers]); + return ( <> @@ -6337,6 +6582,13 @@ const Map = () => { ) : null} + {selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? ( + + ) : null} setIsSearchModalOpen(true)} @@ -6397,7 +6649,9 @@ const Map = () => { showFloatingSearchButton={false} treeTogglePosition="center" zoomGuide={zoomGuide} - layerIconVisible={(node: any) => isAgedPipeLayerNode(node) || isSinkholeRootNode(node)} + layerIconVisible={(node: any) => + isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFloodHistoryNode(node) + } defaultZoom={7.5} defaultLonLat={[129, 36.3]} diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md new file mode 100644 index 000000000..e80066fa9 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md @@ -0,0 +1,79 @@ +# SFR-011-009 침수발생이력 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 설계 완료 직후 기준으로, FE-only 범위 검증 항목과 후속 통합 검증 항목을 분리한다. + +## 2. 검증 기준 요약 + +- 레이어 트리 `침수흔적도` 목록 popup이 정상적으로 열린다. +- `침수흔적도` on/off가 지도 표시와 맞게 동작한다. +- 조회조건 유효성 검증이 동작한다. +- `침수발생년도` 선택이 동작한다. +- 목록이 조건에 따라 필터링된다. +- 목록 다운로드가 현재 조회 결과 기준으로 동작한다. +- 행 클릭 시 메인 지도 preview가 뜬다. +- 지도 위 큰 침수흔적 레이어 클릭 시 작은 상세 팝업이 열린다. + +## 3. FE-only 검증 계획 + +### 3.1 popup 진입 + +- 레이어 트리 `침수흔적도` 체크 +- 레이어 트리 `침수흔적도`의 목록 버튼 클릭 +- popup open 여부 +- popup 제목, 조회조건 영역, 목록 그리드 노출 여부 + +### 3.2 조회조건 검증 + +- 시도 미선택 상태에서 시군구 선택 후 조회 시 차단되는지 확인 +- 침수발생년도 선택 시 목록 필터가 반영되는지 확인 +- 침수재해명 키워드 입력 시 목록 필터가 적용되는지 확인 + +### 3.3 목록/다운로드 + +- 조회 결과 총 건수 표시 +- `강우 분석정보` 묶음 헤더와 하위 4개 컬럼 표시 +- 나머지 상세 필드가 가로 스크롤로 모두 노출되는지 확인 +- 10건 초과 시 페이지네이션 노출 +- 다운로드 버튼 클릭 시 현재 필터링 결과 기준 파일이 생성되는지 확인 +- 결과 없음 상태에서 다운로드가 차단되는지 확인 + +### 3.4 지도 연동 + +- 행 클릭 시 지도 preview 표시 여부 +- 행 더블클릭 시 popup close 여부 +- 메인 지도 영역으로 fit 이동되는지 확인 +- `침수흔적도` 체크 시 mock polygon과 침수심 overlay가 함께 표시되는지 확인 +- `침수흔적도`를 끄면 관련 표시가 함께 숨겨지는지 확인 +- 큰 침수흔적 polygon 클릭 시 상세 팝업이 뜨는지 확인 + +### 3.5 지도 상세 팝업 + +- `침수년도탭`, `재해명탭`, `침수발생강우탭` 전환 +- 기술 컬럼 제외 후 한글 업무 항목만 보이는지 확인 +- 각 탭의 테이블 구조와 값 표시 +- 닫기 시 팝업만 닫히는지 확인 + +## 4. BE 연동 후 검증 계획 + +- 실제 목록 조회 API 필드 매핑 +- 실제 geometry 및 지도 위치 정합성 +- 지도 click 상세조회 +- 실제 레이어 트리 `침수흔적도` 토글 연계 +- 다운로드 권한 및 파일 포맷 검증 + +## 5. 차단 사유 분류 기준 + +- `기능 결함` +- `인증 차단` +- `환경 차단` +- `데이터 차단` +- `자동화 한계` + +## 6. 현재 상태 + +- 공통 설계서 작성 완료 +- FE 작업카드 작성 완료 +- 구현은 진행 중이며, 검증은 FE-only 기준으로 먼저 수행한다. diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md new file mode 100644 index 000000000..ca7d05fd6 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md @@ -0,0 +1,52 @@ +# SFR-011-009 침수발생이력 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 현재 검증 결과와 남은 수동 확인 항목을 누적 관리한다. +- 현재 문서는 FE draft 구현 직후 기준의 로컬 응답 확인과 자동화 한계 항목을 기록한다. + +## 2. 이번 라운드 결과 요약 + +- 상태: 부분 확인 +- 확인 완료 + - `localhost:3002` 기본 응답 200 확인 + - `localhost:3002/map` 응답 200 확인 + - 공통 설계서, FE 작업카드, FE 검증계획 작성 완료 +- 미완료 + - popup 실제 진입 확인 + - 목록 조회/행 클릭 preview/메인 지도 플로팅 상세팝업 수동 확인 + - 다운로드 파일 수동 확인 + +## 3. 세부 결과 + +### 3.1 환경 확인 + +- `pnpm --filter sn run dev`는 샌드박스 내에서 `spawn EPERM`으로 실패했다. +- 권한 상승 후 로컬 서버를 열어 `http://127.0.0.1:3002`, `http://127.0.0.1:3002/map` 응답 200을 확인했다. + +### 3.2 자동/간접 확인 + +| 항목 | 결과 | 메모 | +| --- | --- | --- | +| 로컬 서버 기본 응답 | 통과 | `/` 200 | +| 지도 메인 경로 응답 | 통과 | `/map` 200 | +| popup 경로 직접 요청 | 보류 | 초기 컴파일 지연 또는 자동 요청 timeout 가능성 | +| 실제 popup 클릭 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 행 클릭 preview | 보류 | 사용자가 로컬 수동 검증 예정 | +| 더블클릭 후 지도 이동 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 지도 클릭 플로팅 팝업 | 보류 | 사용자가 로컬 수동 검증 예정 | + +## 4. 남은 수동 검증 항목 + +1. 레이어 트리 `침수흔적도` 목록 버튼으로 popup이 정상적으로 열리는지 확인 +2. 조회조건 입력과 목록 필터가 기대대로 동작하는지 확인 +3. 목록 행 클릭 시 메인 지도에 테스트 레이어가 보이는지 확인 +4. 목록 행 더블클릭 시 popup이 닫히고 메인 지도 이동이 유지되는지 확인 +5. 큰 침수흔적 polygon 클릭 시 작은 상세 팝업이 뜨는지 확인 +6. `침수흔적도` on/off에 따라 영역/침수심 overlay가 메인 지도에 맞게 보이는지 확인 +7. 상세 팝업 탭과 표 구성이 자연스러운지 확인 + +## 5. 결론 + +- FE draft 구현과 문서 선행 정리는 완료됐다. +- 최종 상태는 사용자 로컬 수동 검증 결과를 반영한 뒤 `검증 통과` 또는 `추가 보완 필요`로 갱신한다. diff --git a/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md new file mode 100644 index 000000000..574acdb4d --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md @@ -0,0 +1,117 @@ +# SFR-011-009 침수발생이력 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-011-009-FE` +- 작업명: 침수발생이력 프론트엔드 구현 +- 상태: 검증 +- 작성일: 2026-06-15 +- 담당 역할: FE +- 관련 역할: DE, QA +- 담당 범위: `apps/sn` 지도 화면에서 레이어 트리 `침수흔적도` 목록 popup, 목록 조회, 지도 preview, 지도 클릭 플로팅 상세 팝업, mock 침수심 overlay 구현 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/0.침수발생이력_요구사항명세서_v2.0.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/5.침수발생이력_화면시나리오_V2.0.md.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 목록화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 지도상세정보화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력_화면.html` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: `map/page.tsx`, `windowPopup/**`는 다른 지도형 기능과 최근 수정 이력이 있어 충돌 주의가 필요하다. + +## 2. 작업 목적 + +- 레이어 트리 `침수흔적도` 목록 진입을 placeholder가 아닌 실제 popup UI로 연결한다. +- popup 조회 결과를 메인 지도와 연동해 사용자가 선택한 침수발생이력을 즉시 확인할 수 있게 한다. +- 설계 이미지의 `침수흔적도 on/off + 목록 popup + 지도 강조 + 지도 위 상세 팝업` 흐름을 현재 `sn` 지도형 구조 안에서 자연스럽게 재현한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 비고 | +| --- | --- | --- | --- | +| 메인 지도 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 진행 | popup 선택 결과 수신, 지도 overlay, 우측 상세패널 연결 | +| 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 정의 | + +## 4. 구현 범위 + +### 4.1 이번 라운드 포함 + +- 레이어 트리 `침수흔적도` 목록 popup 진입 +- 레이어 트리 `침수흔적도` on/off +- 시도/시군구/침수발생년도 선택/침수재해명 조회조건 +- mock 목록 조회/페이징 +- 목록 다운로드 +- 목록 행 클릭 preview + 더블클릭 후 메인 지도 이동 +- 메인 지도 mock polygon/침수심 overlay +- 지도 클릭 플로팅 상세 팝업 +- `강우 분석정보` 묶음 헤더 유지 + 상세 필드 가로 스크롤 그리드 + +### 4.2 이번 라운드 제외 + +- 실제 백엔드 API 연동 +- 실제 레이어 트리 토글 연계 +- 지도 click 상세조회 +- 실제 기준데이터 기반 GIS layer 렌더링 + +## 5. 화면 흐름 + +1. 사용자가 레이어 트리에서 `침수흔적도`를 체크한다. +2. 사용자가 `침수흔적도` 목록 버튼을 클릭한다. +3. popup이 열린다. +4. 사용자가 조회조건을 입력하고 `조회`를 클릭한다. +5. 목록이 popup 그리드에 표시된다. +6. 사용자가 행을 클릭하면 메인 지도에 테스트 레이어가 먼저 표시된다. +7. 사용자가 행을 더블클릭하면 popup이 닫히고 메인 지도는 해당 침수 이력 영역으로 이동한다. +8. `침수흔적도` 체크 상태에 따라 영역/침수심 표현이 함께 보인다. +9. 메인 지도 위 큰 침수흔적 영역을 클릭하면 작은 상세 팝업이 열린다. + +## 6. 구현 메모 + +- popup은 기존 `windowPopup` 체계를 그대로 사용하되, 분석도구가 아니라 레이어 트리 `침수흔적도` 목록 버튼으로 연다. +- 목록 선택 결과는 `window.opener.postMessage`로 메인 지도에 전달한다. +- popup의 `침수발생년도`는 같은 필터 줄 안의 선택 컴포넌트로 처리한다. +- 메인 지도는 `VectorLayer` 두 개를 사용해 + - 침수발생이력상세 영역 + - 침수심 표현 영역 + 을 임시 렌더링한다. +- 두 overlay는 `침수흔적도` 체크 상태를 따르도록 맞춘다. +- 상세정보는 우측 패널이 아니라 지도 위 플로팅 팝업으로 노출하고, 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 기준으로 보여준다. + +## 7. 검증 방법 + +- 레이어 트리 `침수흔적도` 목록 popup이 열린다. +- `침수흔적도` 체크 on/off에 따라 영역/침수심 overlay가 함께 바뀐다. +- 시도 없이 시군구를 선택하면 조회가 차단된다. +- 침수발생년도 선택이 동작한다. +- 조건 변경 시 목록 필터가 바뀐다. +- 행 클릭 시 지도 preview가 표시된다. +- 지도 위 큰 polygon 클릭 시 작은 상세 팝업이 열린다. +- 상세 팝업 탭 전환이 동작한다. + +## 8. 현재 메모 + +- 문서 우선 원칙에 맞춰 이번 라운드는 설계서/작업카드/검증계획을 먼저 정리한 뒤 구현을 이어간다. +- 현재 코드에는 일부 draft 구현이 이미 들어가 있으므로, 이후 단계에서 이 작업카드 기준으로 정합성을 다시 맞춘다. + +## 9. 롤백 방법 + +- 레이어 트리 목록 진입 연결 해제 +- `floodHistory` 신규 컴포넌트 삭제 +- `map/page.tsx`의 floodHistory overlay/상세패널 분기 제거 + +## 10. 남은 리스크 + +- 실제 API 필드명이 mock 데이터와 다를 수 있다. +- 실제 지도 레이어 기준데이터가 준비되면 현재 mock overlay 방식과 상태 관리가 달라질 수 있다. +- popup 상세정보를 우측 패널로 둘지 지도 내부 floating popup으로 둘지는 실제 사용성 피드백 후 재조정 가능성이 있다. diff --git a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md new file mode 100644 index 000000000..db4e62129 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md @@ -0,0 +1,73 @@ +# SFR-011-009 침수발생이력 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 재정렬 이후의 1차 FE draft 구현 상태를 기록한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` +- `COMMON_DOCS_ROOT/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합공유기록.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md` + +## 3. 구현 라운드 요약 + +### 3.1 2026-06-15 docs-first 재정렬 + +- 수행 내용 + - 작업지시 DOCX, 시나리오 DOCX, 설계 PNG, HTML 시안을 먼저 읽고 기능 범위를 재정리했다. + - 공통 설계서, FE 작업카드, FE 통합검증계획을 먼저 작성했다. + - 공통 작업현황판/파일점유현황에 `SFR-011-009-DE-FE-PLAN` 진행 상태를 등록했다. + +### 3.2 2026-06-15 FE draft 구현 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `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` +- 구현 내용 + - 분석도구의 `침수발생이력` 항목은 제거하고, 레이어 트리 `침수흔적도` 목록 진입으로 popup을 열도록 바꿨다. + - `침수발생이력` popup placeholder를 실제 popup 페이지로 교체했다. + - 시도/시군구/침수발생년도 선택/침수재해명 조회조건과 mock 목록 조회/다운로드를 연결했다. + - 그리드는 현재 파란 톤을 유지하되, 사용안함 항목을 제외한 상세 필드를 모두 노출하도록 바꾸고 가로 스크롤을 허용했다. `강우 분석정보` 묶음은 유지하면서 뒤쪽 컬럼을 재배치했다. + - 행 클릭 시 popup을 유지한 채 `postMessage`로 메인 지도에 mock preview를 보내고, 더블클릭 시 popup을 닫도록 구성했다. + - 메인 지도는 mock polygon layer와 침수심 표현 layer를 임시 `VectorLayer`로 그리도록 구성했고, 표시 여부는 `침수흔적도` 체크 상태를 따르도록 보정했다. + - 메인 지도 우측 상세패널은 제거하고, 지도 위 큰 침수흔적 영역 클릭 시 작은 플로팅 팝업이 열리도록 바꿨다. + - 상세 팝업 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 구조에 맞춰 재배치했고, 탭별 표 항목도 캡처 기준에 가깝게 맞췄다. + +## 4. 기능별 현재 상태 + +### 4.1 popup 조회 + +- FE-only draft 구현 완료 +- 레이어 트리 `침수흔적도` 목록 진입 기준 +- 시도/시군구/년도/재해명 조건과 목록 필터, 페이지네이션, 다운로드를 포함한다. + +### 4.2 지도 이동 및 강조 + +- FE-only draft 구현 완료 +- 더블클릭 시 popup close 후 메인 지도 fit 이동과 mock polygon/침수심 표현 overlay를 수행한다. +- `침수흔적도` 체크 상태에 따라 영역과 침수심 overlay를 함께 표시/숨김한다. + +### 4.3 지도 상세 팝업 + +- FE-only draft 구현 완료 +- 선택 이력 기준 3탭 플로팅 팝업을 제공한다. + +## 5. 현재 검증 메모 + +- 로컬 `localhost:3002` 응답은 확인했다. +- `next dev`는 샌드박스 내에서 `spawn EPERM`이 발생해 권한 상승 후 로컬 서버를 띄웠다. +- 사용자가 직접 로컬 화면을 수동 검증할 예정이므로, popup 실제 진입/더블클릭/상세패널 체감 확인은 후속 결과를 받아 갱신한다. + +## 6. 남은 리스크 + +- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다. +- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다. +- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다. diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 5724edd3a..dcba0e396 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -104,8 +104,8 @@ export const windowPopupDefinitions = { floodHistoryPopup: { key: "floodHistoryPopup", title: "침수발생이력", - width: 1080, - height: 760, + width: 1160, + height: 820, }, gisEditPopup: { key: "gisEditPopup", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index aa65d06d2..f63b08a91 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -14,24 +14,19 @@ 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 GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; import GisManagementPopupContent from "./ui/GisManagementPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; -const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( +const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => ( ); -const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => ( - -); - const GisEditPopupContent = (_props: WindowPopupContentProps) => ( ); @@ -95,7 +90,7 @@ export const windowPopupRegistry = { }, floodPredictionMapPopup: { ...windowPopupDefinitions.floodPredictionMapPopup, - component: FloodPredictionMapPopupContent, + component: FloodPredictionPlaceholderPopupContent, }, floodHistoryPopup: { ...windowPopupDefinitions.floodHistoryPopup, diff --git a/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx new file mode 100644 index 000000000..d363c7ecf --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx @@ -0,0 +1,5 @@ +import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage"; + +export default function FloodHistoryPopupContent() { + return ; +} diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index f931b06b7..6d9da766d 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -550,9 +550,13 @@ export default React.memo(function GisTree({ (child) => child.parent === node.id && child.data.exprsYn === "Y" ); + const nodeLabel = String(node?.data?.lyrNm ?? "").trim(); const isSinkholeRootTreeNode = String(node.id) === "SN_SINKHOLE" || - String(node?.data?.lyrNm ?? "").trim() === "지반침하정보"; + nodeLabel === "지반침하정보"; + const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도"; + const hideTreeLegendIcon = + isSinkholeRootTreeNode || isFloodHistoryRootTreeNode; const showLayerActionButton = layerIconVisible?.(node); const showLayerActionIcon = depth > 0 && imageVisible; @@ -582,7 +586,7 @@ export default React.memo(function GisTree({ onChange={() => handleCheck(node.id)} className="cursor-pointer" /> - {!isSinkholeRootTreeNode && node.icon && {node.icon}} + {!hideTreeLegendIcon && node.icon && {node.icon}}