diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx new file mode 100644 index 000000000..e60f971e5 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; +import { + FLOOD_DNGR_MAP_DEFAULT_FILTERS, + FLOOD_DNGR_MAP_DEPTH_OPTIONS, + FLOOD_DNGR_MAP_FREQUENCY_OPTIONS, + FLOOD_DNGR_MAP_HEADLIST, + FLOOD_DNGR_MAP_PAGE_SIZE, + buildFloodDngrMapListQueryString, + toFloodDngrMapTableRows, + type FloodDngrMapApiItem, + type FloodDngrMapExtent, + type FloodDngrMapFilters, + type FloodDngrMapTableRow, +} from "./floodDngrMapTypes"; + +const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000; +const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000; + +/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */ +const getFloodDngrMapJson = async (url: string) => { + let timeoutId: number | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.")); + }, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS); + }); + try { + return await Promise.race([getData(url), timeoutPromise]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */ +export default function FloodDngrMapPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(FLOOD_DNGR_MAP_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(FLOOD_DNGR_MAP_DEFAULT_FILTERS); + const [items, setItems] = useState([]); + const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const availableCtpvCodes = useMemo( + () => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 2)).filter(Boolean)), + [administrativeScopeCodes], + ); + + const availableSggCodes = useMemo( + () => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 5)).filter(Boolean)), + [administrativeScopeCodes], + ); + + const ctpvOptions = useMemo( + () => + administrativeScopeCodes === null + ? ctpv + : ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))), + [administrativeScopeCodes, availableCtpvCodes, ctpv], + ); + + const sggOptions = useMemo(() => { + const scopedOptions = + administrativeScopeCodes === null + ? sgg + : sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5))); + return filters.ctpvCd === "0" + ? scopedOptions + : scopedOptions.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + }, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]); + + const rows = useMemo( + () => + toFloodDngrMapTableRows( + items, + currentPage - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + ctpv, + sgg, + total, + sort, + ), + [ctpv, currentPage, items, sgg, sort, total], + ); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + /** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */ + useEffect(() => { + if (!isLoading) return; + const watchdogId = window.setTimeout(() => { + setIsLoading(false); + setErrorMessage((current) => + current || "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.", + ); + }, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS); + return () => window.clearTimeout(watchdogId); + }, [isLoading]); + + /** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */ + const loadRows = useCallback( + async (page: number, nextFilters: FloodDngrMapFilters, nextSort?: UiTableSort) => { + setIsLoading(true); + setErrorMessage(""); + try { + const queryString = buildFloodDngrMapListQueryString( + nextFilters, + page - 1, + FLOOD_DNGR_MAP_PAGE_SIZE, + nextSort, + ); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`, + ); + const payload = response as { + items?: FloodDngrMapApiItem[]; + total?: number; + } | null; + if (!payload || !Array.isArray(payload.items)) { + throw new Error("도시침수지도 목록 API 응답 형식이 올바르지 않습니다."); + } + setItems(payload.items); + setTotal(Number(payload?.total ?? 0)); + setCurrentPage(page); + setAppliedFilters(nextFilters); + setSort(nextSort); + setAppliedSort(nextSort); + setSelectedIndex(-1); + } catch (error) { + console.error("도시침수지도 목록 조회 중 오류:", error); + setItems([]); + setTotal(0); + setErrorMessage( + error instanceof Error ? error.message : "도시침수지도 목록 조회 중 오류가 발생했습니다.", + ); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */ + const loadAdministrativeScope = useCallback(async () => { + try { + const response = await getFloodDngrMapJson( + "/sewerage-service/api/v1/fldng/flood-dngr-map/scope", + ); + if (!Array.isArray(response)) { + throw new Error("도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다."); + } + setAdministrativeScopeCodes(response); + } catch (error) { + console.error("도시침수지도 행정구역 범위 조회 중 오류:", error); + setAdministrativeScopeCodes(null); + } + }, []); + + useEffect(() => { + void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS); + void loadAdministrativeScope(); + }, [loadAdministrativeScope, loadRows]); + + const handleSearch = () => { + void loadRows(1, filters, sort); + }; + + /** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */ + const handleRowClick = async (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row?.sggCd || !row?.sbmrgFrqExpln) return; + setSelectedIndex(index); + setIsLoading(true); + setErrorMessage(""); + try { + const params = new URLSearchParams({ + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }); + const response = await getFloodDngrMapJson( + `/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`, + ); + const payload = response as FloodDngrMapExtent; + const extent = [payload?.minX, payload?.minY, payload?.maxX, payload?.maxY].map(Number); + if (extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) { + throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다."); + } + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP, + payload: { + extent, + sggCd: row.sggCd, + sbmrgFrqExpln: row.sbmrgFrqExpln, + }, + }, + window.location.origin, + ); + } catch (error) { + console.error("도시침수지도 위치 이동 중 오류:", error); + const message = + error instanceof Error && error.message.includes("404") + ? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다." + : error instanceof Error + ? error.message + : "도시침수지도 위치 이동 중 오류가 발생했습니다."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+

도시침수지도 목록

+
+ +
+
+ + + + + + + + +
+ +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + headName={ + <> + 도시침수지도 조회 결과 총 {total.toLocaleString()}건 + + } + tableData={rows} + headlist={FLOOD_DNGR_MAP_HEADLIST} + pageSize={FLOOD_DNGR_MAP_PAGE_SIZE} + total={total} + cellClick={(index) => void handleRowClick(index)} + onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + isHideCount={true} + sort={sort} + onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)} + /> +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts new file mode 100644 index 000000000..24a4187d7 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts @@ -0,0 +1,172 @@ +"use client"; + +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; + +export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup"; +export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map"; +export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)"; +export const FLOOD_DNGR_MAP_PAGE_SIZE = 20; + +export type FloodDngrMapFilters = { + ctpvCd: string; + sggCd: string; + sbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapApiItem = { + ctpvCd: string; + sggCd: string; + minSbmrgDpthlCd: string; + maxSbmrgDpthlCd: string; + sbmrgFrqExpln: string; +}; + +export type FloodDngrMapExtent = { + minX: number; + minY: number; + maxX: number; + maxY: number; +}; + +export type FloodDngrMapTableRow = FloodDngrMapApiItem & { + rowNo: number; + ctpvNm: string; + sggNm: string; + sbmrgDpthlDisplay: string; + sbmrgFrqDisplay: string; +}; + +type AdministrativeOption = { + val: unknown; + text: unknown; +}; + +export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = { + ctpvCd: "0", + sggCd: "0", + sbmrgDpthlCd: "0", + sbmrgFrqExpln: "0", +}; + +export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"]; + +export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record = { + N330: "0.0~0.5m 미만", + N331: "0.5~1.0m 미만", + N332: "1.0~2.0m 미만", + N333: "2.0~5.0m 미만", + N334: "5.0m 이상", +}; + +export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(FLOOD_DNGR_MAP_DEPTH_LABELS).map( + ([value, label]) => ({ value, label }), +); + +export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "NO", widthPercent: 8, sortable: true, sortKey: "rowNo" }, + { id: "ctpvNm", title: "시도", widthPercent: 18, sortable: true, sortKey: "ctpvCd" }, + { id: "sggNm", title: "시군구", widthPercent: 20, sortable: true, sortKey: "sggCd" }, + { + id: "sbmrgDpthlDisplay", + title: "침수심도(최소~최대)", + widthPercent: 31, + sortable: true, + sortKey: "sbmrgDpthlCd", + }, + { + id: "sbmrgFrqDisplay", + title: "침수빈도", + widthPercent: 23, + sortable: true, + sortKey: "sbmrgFrqExpln", + }, +]; + +/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */ +export const buildFloodDngrMapListQueryString = ( + filters: FloodDngrMapFilters, + page: number, + size: number, + sort?: UiTableSort, +) => { + const params = new URLSearchParams({ page: String(page), size: String(size) }); + if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); + if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") { + params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd); + } + if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") { + params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln); + } + if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`); + return params.toString(); +}; + +const resolveAdministrativeName = ( + value: unknown, + options: AdministrativeOption[], + codeLength: number, +) => { + const code = String(value ?? "").trim().slice(0, codeLength); + const matched = options.find( + (option) => String(option.val ?? "").trim().slice(0, codeLength) === code, + ); + return String(matched?.text ?? "").trim() || "-"; +}; + +const toFrequencyDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return "-"; + const numericValue = Number(normalized); + return Number.isFinite(numericValue) ? `${numericValue}년` : normalized; +}; + +const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record = { + N330: { min: "0.0", max: "0.5" }, + N331: { min: "0.5", max: "1.0" }, + N332: { min: "1.0", max: "2.0" }, + N333: { min: "2.0", max: "5.0" }, + N334: { min: "5.0", max: "5.0", openEnded: true }, +}; + +/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */ +const toDepthRangeDisplay = (minCode: string, maxCode: string) => { + if (!minCode && !maxCode) return "-"; + if (minCode === maxCode) return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-"; + const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode]; + const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode]; + if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`; + return maxDepth.openEnded + ? `${minDepth.min}m ~ ${maxDepth.min}m 이상` + : `${minDepth.min}m ~ ${maxDepth.max}m 미만`; +}; + +/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */ +export const toFloodDngrMapTableRows = ( + items: FloodDngrMapApiItem[], + page: number, + pageSize = FLOOD_DNGR_MAP_PAGE_SIZE, + ctpvOptions: AdministrativeOption[] = [], + sggOptions: AdministrativeOption[] = [], + total = items.length, + sort?: UiTableSort, +): FloodDngrMapTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: + sort?.key === "rowNo" && sort.direction === "desc" + ? total - page * pageSize - index + : page * pageSize + index + 1, + ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2), + sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5), + sbmrgDpthlDisplay: toDepthRangeDisplay(item.minSbmrgDpthlCd, item.maxSbmrgDpthlCd), + sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln), + })); + +const resolveNodeName = (node: any) => + String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); + +/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */ +export const isFloodDngrMapRootNode = (node: any) => + resolveNodeName(node).replace(/\s/g, "") === FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, ""); diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx new file mode 100644 index 000000000..592a86ba3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import { getRawJson } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; +import { + SBMRG_DNGR_MAP_DEFAULT_FILTERS, + SBMRG_DNGR_MAP_FREQUENCY_OPTIONS, + SBMRG_DNGR_MAP_HEADLIST, + SBMRG_DNGR_MAP_LAYER_TYPE, + SBMRG_DNGR_MAP_PAGE_SIZE, + SBMRG_DNGR_MAP_SCOPE_SIZE, + buildSbmrgDngrMapListQueryString, + extractAdministrativeCodes, + toSbmrgDngrMapTableRows, + type SbmrgDngrMapApiItem, + type SbmrgDngrMapFilters, + type SbmrgDngrMapTableRow, +} from "./sbmrgDngrMapTypes"; + +const unwrapPayload = (response: any): T => (response?.data ?? response) as T; + +/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */ +export default function SbmrgDngrMapPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + // 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다. + const [filters, setFilters] = useState(SBMRG_DNGR_MAP_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(SBMRG_DNGR_MAP_DEFAULT_FILTERS); + const [items, setItems] = useState([]); + const [administrativeScopeItems, setAdministrativeScopeItems] = useState(null); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const availableCtpvCodes = useMemo( + () => + new Set( + (administrativeScopeItems ?? []).flatMap((item) => + extractAdministrativeCodes(item.ctpvCd, 2), + ), + ), + [administrativeScopeItems], + ); + + const availableSggCodes = useMemo( + () => + new Set( + (administrativeScopeItems ?? []).flatMap((item) => + extractAdministrativeCodes(item.sggCd, 5), + ), + ), + [administrativeScopeItems], + ); + + const ctpvOptions = useMemo( + () => + administrativeScopeItems === null + ? ctpv + : ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))), + [administrativeScopeItems, availableCtpvCodes, ctpv], + ); + + const sggOptions = useMemo( + () => { + const scopedOptions = + administrativeScopeItems === null + ? sgg + : sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5))); + return filters.ctpvCd === "0" + ? scopedOptions + : scopedOptions.filter((item) => + String(item.val).startsWith(filters.ctpvCd.slice(0, 2)), + ); + }, + [administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg], + ); + + const rows = useMemo( + () => + toSbmrgDngrMapTableRows( + items, + currentPage - 1, + SBMRG_DNGR_MAP_PAGE_SIZE, + ctpv, + sgg, + total, + sort, + ), + [ctpv, currentPage, items, sgg, sort, total], + ); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + /** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */ + const loadRows = useCallback( + async (page: number, nextFilters: SbmrgDngrMapFilters, nextSort?: UiTableSort) => { + setIsLoading(true); + try { + const queryString = buildSbmrgDngrMapListQueryString( + nextFilters, + page - 1, + SBMRG_DNGR_MAP_PAGE_SIZE, + nextSort, + ); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + ); + const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; total?: number }>(response); + const items = Array.isArray(payload?.items) ? payload.items : []; + setItems(items); + setTotal(Number(payload?.total ?? 0)); + setCurrentPage(page); + setAppliedFilters(nextFilters); + setSort(nextSort); + setAppliedSort(nextSort); + setSelectedIndex(-1); + } catch (error) { + console.error("침수위험지도 목록 조회 중 오류:", error); + setItems([]); + setTotal(0); + alert(error instanceof Error ? error.message : "침수위험지도 목록 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */ + const loadAdministrativeScope = useCallback(async () => { + try { + const queryString = buildSbmrgDngrMapListQueryString( + SBMRG_DNGR_MAP_DEFAULT_FILTERS, + 0, + SBMRG_DNGR_MAP_SCOPE_SIZE, + ); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`, + ); + const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(response); + setAdministrativeScopeItems(Array.isArray(payload?.items) ? payload.items : []); + } catch (error) { + console.error("침수위험지도 행정구역 범위 조회 중 오류:", error); + setAdministrativeScopeItems(null); + } + }, []); + + useEffect(() => { + void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS); + void loadAdministrativeScope(); + }, [loadAdministrativeScope, loadRows]); + + /** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */ + const handleSearch = () => { + void loadRows(1, filters, sort); + }; + + /** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */ + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!Array.isArray(row?.fids) || row.fids.length === 0) return; + setSelectedIndex(index); + const featureIds = row.fids.map((fid) => + String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`) + ? String(fid) + : `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`, + ); + window.opener?.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + payload: { layerNm: SBMRG_DNGR_MAP_LAYER_TYPE, fids: JSON.stringify(featureIds) }, + }, + window.location.origin, + ); + }; + + return ( +
+ +
+

침수위험지도 목록

+
+ +
+
+ + + + + + +
+ +
+
+ +
+ + headName={<>침수위험지도 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={SBMRG_DNGR_MAP_HEADLIST} + pageSize={SBMRG_DNGR_MAP_PAGE_SIZE} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + isHideCount={true} + sort={sort} + onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)} + /> +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts new file mode 100644 index 000000000..44e4d71c3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts @@ -0,0 +1,153 @@ +"use client"; + +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; + +export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup"; +export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map"; +export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)"; +export const SBMRG_DNGR_MAP_PAGE_SIZE = 20; +export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000; + +export type SbmrgDngrMapFilters = { + ctpvCd: string; + sggCd: string; + floodWtlvFrqVl: string; +}; + +export type SbmrgDngrMapApiItem = { + fids: string[]; + ctpvCd: string; + sggCd: string; + emphsMngRgnNm: string; + emphsMngRgnMngNo: string; + floodWtlvFrqVl: number | string | null; + minElvtVl: number | string | null; + maxElvtVl: number | string | null; +}; + +export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & { + rowNo: number; + ctpvNm: string; + sggNm: string; + floodWtlvFrqVlDisplay: string; + elvtVlDisplay: string; +}; + +type AdministrativeOption = { + val: unknown; + text: unknown; +}; + +export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = { + ctpvCd: "0", + sggCd: "0", + floodWtlvFrqVl: "0", +}; + +export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100]; + +export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [ + { id: "rowNo", title: "NO", widthPercent: 7, sortable: true, sortKey: "rowNo" }, + { id: "ctpvNm", title: "시도", widthPercent: 11, sortable: true, sortKey: "ctpvCd" }, + { id: "sggNm", title: "시군구", widthPercent: 12, sortable: true, sortKey: "sggCd" }, + { id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23, sortable: true }, + { id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15, sortable: true }, + { + id: "floodWtlvFrqVlDisplay", + title: "홍수수위빈도값", + widthPercent: 17, + sortable: true, + sortKey: "floodWtlvFrqVl", + }, + { + id: "elvtVlDisplay", + title: "해발값(최소~최대)", + widthPercent: 15, + sortable: true, + sortKey: "elvtVl", + }, +]; + +/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */ +export const buildSbmrgDngrMapListQueryString = ( + filters: SbmrgDngrMapFilters, + page: number, + size: number, + sort?: UiTableSort, +) => { + const params = new URLSearchParams({ page: String(page), size: String(size) }); + if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); + if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); + if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") { + params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl); + } + if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`); + return params.toString(); +}; + +const toDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + return normalized || "-"; +}; + +const toRangeDisplay = (minValue: unknown, maxValue: unknown) => { + const minDisplay = toDisplay(minValue); + const maxDisplay = toDisplay(maxValue); + if (minDisplay === "-" && maxDisplay === "-") return "-"; + if (minDisplay === maxDisplay) return minDisplay; + return `${minDisplay} ~ ${maxDisplay}`; +}; + +/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */ +export const extractAdministrativeCodes = (value: unknown, codeLength: number) => + String(value ?? "") + .split(",") + .map((code) => code.trim().slice(0, codeLength)) + .filter(Boolean); + +/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */ +const resolveAdministrativeNames = ( + value: unknown, + options: AdministrativeOption[], + codeLength: number, +) => { + const names = extractAdministrativeCodes(value, codeLength) + .map((code) => { + const matched = options.find( + (option) => String(option.val ?? "").trim().slice(0, codeLength) === code, + ); + return String(matched?.text ?? "").trim(); + }) + .filter(Boolean); + + return names.length > 0 ? [...new Set(names)].join(", ") : "-"; +}; + +/** 서버 페이지 번호를 화면 NO에 반영하고 숫자 표시값을 보정한다. */ +export const toSbmrgDngrMapTableRows = ( + items: SbmrgDngrMapApiItem[], + page: number, + pageSize = SBMRG_DNGR_MAP_PAGE_SIZE, + ctpvOptions: AdministrativeOption[] = [], + sggOptions: AdministrativeOption[] = [], + total = items.length, + sort?: UiTableSort, +): SbmrgDngrMapTableRow[] => + items.map((item, index) => ({ + ...item, + rowNo: + sort?.key === "rowNo" && sort.direction === "desc" + ? total - page * pageSize - index + : page * pageSize + index + 1, + ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2), + sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5), + floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl), + elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl), + })); + +const resolveNodeName = (node: any) => + String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim(); + +/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */ +export const isSbmrgDngrMapRootNode = (node: any) => + resolveNodeName(node).replace(/\s/g, "") === SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, ""); diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index eeaec1437..7f4e01478 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -143,6 +143,15 @@ import { isFldngHstryNode, type FldngHstryItem, } from "./components/fldngHstry/fldngHstryTypes"; +import { + SBMRG_DNGR_MAP_POPUP_KEY, + isSbmrgDngrMapRootNode, +} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes"; +import { + FLOOD_DNGR_MAP_LAYER_TYPE, + FLOOD_DNGR_MAP_POPUP_KEY, + isFloodDngrMapRootNode, +} from "./components/floodDngrMap/floodDngrMapTypes"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; import SearchModal from "./searchModal/searchModal"; import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth"; @@ -417,6 +426,7 @@ const getLayerTypeName = (layerInfo: any) => layerInfo?.lyr_type_nm ?? "", ).trim(); + const getLayerFilterSql = (layerInfo: any) => String( layerInfo?.filtrSql ?? @@ -3148,6 +3158,71 @@ const Map = () => { return; } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) { + const extent = Array.isArray(data.payload?.extent) + ? data.payload.extent.map(Number) + : []; + const map = gisMaps.current; + if (extent.length !== 4 || extent.some((value: number) => !Number.isFinite(value)) || !map?.getView) { + return; + } + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP) { + const extent = Array.isArray(data.payload?.extent) + ? data.payload.extent.map(Number) + : []; + const sggCd = String(data.payload?.sggCd ?? "").trim(); + const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim(); + const map = gisMaps.current; + const select = GisApp.Module.select as any; + if ( + extent.length !== 4 + || extent.some((value: number) => !Number.isFinite(value)) + || !/^\d{5}$/.test(sggCd) + || !/^(030|050|080|100)$/.test(frequency) + || !map?.getView + || !select?.selectFocusedBoundaryFeature + ) { + return; + } + + await select.resetSelectedFeature?.(); + const selectedFeatureIds = new Set(); + const selectLoadedGroupFeatures = (source: any) => { + const features = source?.getFeatures?.() ?? []; + features.forEach((feature: any) => { + const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); + const featureFrequency = String(feature.get?.("sbmrg_frq_expln") ?? "").trim(); + if (featureSggCd !== sggCd || featureFrequency !== frequency) return; + const featureId = String(feature.getId?.() ?? feature.get?.("fid") ?? ""); + if (featureId && selectedFeatureIds.has(featureId)) return; + if (featureId) selectedFeatureIds.add(featureId); + select.selectFocusedBoundaryFeature(feature, true); + }); + }; + + map.getLayers().getArray().forEach((layer: any) => { + if (String(layer.get?.("lyrTyNm") ?? "").trim() !== FLOOD_DNGR_MAP_LAYER_TYPE) return; + const source = layer.getSource?.(); + if (!source?.getFeatures) return; + selectLoadedGroupFeatures(source); + source.once?.("featuresloadend", () => selectLoadedGroupFeatures(source)); + }); + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + return; + } + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) { const rawLayerTypeNames = data.payload?.refreshLayerTypeNames; let layerTypeNames = ["tb_ms_swage_ppl"]; @@ -5579,6 +5654,24 @@ const Map = () => { [sinkholeScope, sinkholeYearRange], ); + /** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openSbmrgDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + + /** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openFloodDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { if (isAgedPipeLayerNode(node)) { @@ -5589,6 +5682,14 @@ const Map = () => { openSinkholePopupWindow(); return false; } + if (isSbmrgDngrMapRootNode(node)) { + openSbmrgDngrMapPopupWindow(); + return false; + } + if (isFloodDngrMapRootNode(node)) { + openFloodDngrMapPopupWindow(); + return false; + } if (isFldngHstryNode(node)) { openFldngHstryPopupWindow(); return false; @@ -5596,7 +5697,13 @@ const Map = () => { return true; }, - [openAgedPipeList, openFldngHstryPopupWindow, openSinkholePopupWindow], + [ + openAgedPipeList, + openFldngHstryPopupWindow, + openFloodDngrMapPopupWindow, + openSbmrgDngrMapPopupWindow, + openSinkholePopupWindow, + ], ); // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. @@ -7859,7 +7966,11 @@ const Map = () => { treeTogglePosition="center" zoomGuide={zoomGuide} layerIconVisible={(node: any) => - isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFldngHstryNode(node) + isAgedPipeLayerNode(node) + || isSinkholeRootNode(node) + || isSbmrgDngrMapRootNode(node) + || isFloodDngrMapRootNode(node) + || isFldngHstryNode(node) } defaultZoom={7.5} diff --git a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md new file mode 100644 index 000000000..c32b7dea4 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/001-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증계획.md @@ -0,0 +1,67 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 계획 + +## 1. 문서 목적 + +- 도시침수지도 목록보기와 WMS·SLD 일반 표출의 정합성·성능·회귀 여부를 검증한다. + +## 2. 검증 범위 + +- `sm_bs_sn.tb_sn_lyr_stng` 부모 3건과 자식 15건 +- 30·50·80년 레이어 트리 ON/OFF +- TileWMS 요청 파라미터와 SLD 규칙 +- 기존 침수심도 범례 색상·투명도·선 두께 +- 기존 WFS 레이어 회귀 +- 목록 팝업과 행 선택 지도 이동 회귀 + +## 3. 검증 기준 + +| 영역 | 통과 기준 | +|---|---| +| 부모 설정 | 58·59·60이 `wms/Y`, 빈도 필터가 각각 `080/030/050` | +| 자식 설정 | 부모별 5건, 전체 `wms/N`, `N330~N334` 각 1건 | +| 화면 | 30·50·80년 각각 ON/OFF 즉시 반영 | +| WMS | `GetMap`, PNG, 투명 배경, EPSG:5179 | +| 빈도 필터 | 선택 빈도에 맞는 `CQL_FILTER` | +| SLD | 접두사 없는 NamedLayer와 침수심도 Rule 5개 | +| 스타일 | DB의 면색·선색·opacity·선 두께와 일치 | +| 회귀 | 다른 WFS 레이어와 목록 팝업 기능 유지 | +| 성능 | 전국 초기 진입 시 대용량 GeoJSON 파싱·벡터 렌더링 없음 | + +## 4. 검증 절차 + +### 4.1 정적·빌드 검증 + +1. SLD 생성 헬퍼에 자식 5건을 입력한다. +2. Rule 5개, 색상·opacity, NamedLayer, 누락·중복 차단을 확인한다. +3. 변경 파일 ESLint를 실행한다. +4. `@common/business_components`를 빌드한다. + +### 4.2 DB 검증 + +1. 부모 58·59·60의 필터·서비스·실제 레이어 여부를 조회한다. +2. 부모별 자식 수와 `wms/N` 개수를 집계한다. +3. `N330~N334`가 부모별 1건인지 확인한다. + +### 4.3 브라우저 검증 + +1. 30·50·80년을 각각 단독 ON/OFF한다. +2. Network에서 `GetMap`과 `tb_ms_flood_dngr_map`을 필터링한다. +3. `CQL_FILTER`가 각각 `030`, `050`, `080`인지 확인한다. +4. `SLD_BODY`에 `N330~N334`와 기존 색상값이 있는지 확인한다. +5. 응답 상태 200과 `image/png`을 확인한다. +6. 도시침수지도 관련 WFS `GetFeature`가 발생하지 않는지 확인한다. +7. 목록 팝업 검색·행 선택 지도 이동을 회귀 확인한다. + +## 5. 관측 포인트 + +- 논리 WMS 레이어 수와 타일 HTTP 요청 수를 구분한다. +- GeoServer가 SLD 레이어명을 무시하고 기본 검정 스타일을 반환하지 않는지 결과 이미지를 확인한다. +- 자식 체크 상태는 범례 유지용이며 지도 레이어 객체가 생성되면 실패다. + +## 6. 다음 검증 순서 + +- 개발환경 검증 완료 후 운영 프론트 배포 → 운영 DB DML → 운영 Network·체감 성능 순으로 확인한다. + +## 7. 흡수한 기존 문서 + +- 별도 SN 검증 계획 문서는 없었다. diff --git a/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md new file mode 100644 index 000000000..dd1fd7ccb --- /dev/null +++ b/apps/sn/docs/작업검증/SN-도시침수지도-정보제공포털-목록보기/002-qa-SN-도시침수지도-정보제공포털-목록보기-통합검증결과.md @@ -0,0 +1,69 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 결과 + +## 1. 문서 목적 + +- 2026-08-05 WMS·SLD 전환 구현의 실제 수행 검증과 현재 완료 상태를 기록한다. + +## 2. 수행한 검증 + +### 2.1 코드 검증 + +| 항목 | 결과 | 비고 | +|---|---|---| +| SLD Rule 생성 | 통과 | `N330~N334` 5개 | +| RGBA 변환 | 통과 | 색상과 fill/stroke opacity 분리 | +| 설정 누락·중복 차단 | 통과 | 정확히 5건이 아니면 생성 중단 | +| ESLint | 통과 | 오류 0건, 기존 import 정렬 경고만 존재 | +| `@common/business_components` 빌드 | 통과 | 의존 작업 포함 4/4 성공 | +| `git diff --check` | 통과 | whitespace 오류 없음 | + +### 2.2 DB 검증 + +| 부모 | 빈도 필터 | 부모 상태 | 자식 수 | 자식 WMS 수 | 자식 메타데이터 수 | 결과 | +|---:|---|---|---:|---:|---:|---| +| 58 | `080` | `wms/Y` | 5 | 5 | 5 | 정상 | +| 59 | `030` | `wms/Y` | 5 | 5 | 5 | 정상 | +| 60 | `050` | `wms/Y` | 5 | 5 | 5 | 정상 | + +- 각 부모의 자식은 `N330~N334`가 1건씩 존재했다. +- 자식의 `exprs_yn=N`, `actl_lyr_yn=N`, 기존 필터·색상·선 두께가 유지됐다. +- 100년 빈도는 전환 대상에 포함되지 않았다. + +### 2.3 화면·네트워크 검증 + +- 사용자가 30·50·80년 레이어 ON/OFF 정상 동작을 확인했다. +- 30년 실제 요청에서 다음 값을 확인했다. + - `SERVICE=WMS` + - `VERSION=1.1.1` + - `REQUEST=GetMap` + - `LAYERS=hasudo:tb_ms_flood_dngr_map` + - `SRS=EPSG:5179` + - `FORMAT=image/png` + - `TRANSPARENT=true` + - `CQL_FILTER=sbmrg_frq_expln='030'` + - SLD `NamedLayer/Name=tb_ms_flood_dngr_map` + - SLD Rule `N330~N334` 5개 + - DB와 동일한 채움색·경계선색·opacity·선 두께 + +## 3. 현재 검증 상태 + +- 개발 DB 전환, 화면 ON/OFF, 30년 WMS·SLD 요청, 공통 패키지 빌드까지 정상이다. +- 일반 표출의 최종 구조는 부모 3개 TileWMS와 자식 15개 SLD·범례 메타데이터로 확정한다. +- 기능 상태는 `완료`로 기록하고 운영 반영 확인은 배포 절차 점검으로 관리한다. + +## 4. 미수행·운영 확인 항목 + +- 50년·80년의 개별 Network 파라미터 캡처는 남아 있다. +- 응답 헤더의 `Content-Type=image/png` 별도 캡처는 남아 있다. +- 전체 SN Next 빌드는 실행 중인 개발 서버 보호를 위해 수행하지 않았다. +- 운영 환경의 WFS 미발생과 최초 화면 체감 성능은 운영 적용 직후 확인한다. + +## 5. 롤백 기준 + +- DB는 부모를 `wfs/N` 및 빈 필터로, 자식을 `wfs/Y`로 복원한다. +- 프론트는 `gis.wms-sld.js`와 WMS 연동 변경을 되돌리면 기존 WFS 구조로 복귀한다. +- 자식 필터·범례 색상값은 롤백 시에도 보존한다. + +## 6. 흡수한 기존 문서 + +- 별도 SN 검증 결과 문서는 없었다. diff --git a/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md new file mode 100644 index 000000000..b89b7604e --- /dev/null +++ b/apps/sn/docs/작업내용/SN-도시침수지도-정보제공포털-목록보기/001-fe-SN-도시침수지도-정보제공포털-목록보기-통합구현기록.md @@ -0,0 +1,83 @@ +# SN 도시침수지도(정보제공포털) 목록보기 통합 구현 기록 + +## 1. 문서 목적 + +- 도시침수지도 목록보기와 일반 지도 표출의 현재 구현 상태를 기능 단위로 통합 기록한다. +- 본 문서에는 2026-08-05 maintenance에서 확정된 WMS·SLD 전환을 최종 일반 표출 기준으로 기록한다. + +## 2. 입력 근거 + +- 공통 설계: `IIPBS/docs/작업계획/SN-도시침수지도-정보제공포털-목록보기/001-de-SN-도시침수지도-정보제공포털-목록보기-설계.md` +- WMS·SLD 매뉴얼: `IIPBS/docs/작업계획/공통-GeoServer-WMS-SLD적용/001-공통-GeoServer-WMS-SLD적용-매뉴얼.md` +- 레이어 설정: `sm_bs_sn.tb_sn_lyr_stng` +- 대상 레이어: `tb_ms_flood_dngr_map` + +## 3. 공통 계약 + +- 화면 노출 빈도는 30년, 50년, 80년이며 100년은 추가하지 않는다. +- 부모 59·60·58은 각각 빈도 `030`, `050`, `080`의 실제 WMS 레이어다. +- 자식 15건은 `actlLyrYn='N'`으로 유지하며 침수심도 SLD와 범례의 메타데이터로 사용한다. +- 백엔드는 기존 레이어 설정 응답 계약을 유지한다. + +## 4. 구현 라운드 요약 + +### 4.1 2026-08-04 목록보기 구현 + +- 도시침수지도 목록 팝업, 검색·페이징·정렬, 그룹 범위 이동을 구현했다. +- 목록 데이터와 범위 조회는 대용량 원본 GeoJSON을 직접 반환하지 않는 구조로 정리했다. +- 이 라운드의 일반 지도 WFS 복원 기록은 2026-08-05 WMS 전환으로 대체한다. + +### 4.2 2026-08-05 일반 지도 WMS·SLD 전환 + +- `gis.wms-sld.js` + - 부모 번호로 자식 레이어 설정 5건을 선별하고 순서를 정렬한다. + - 자식 필터에서 `N330~N334`를 추출해 SLD `Rule` 5개를 만든다. + - RGBA를 16진 색상과 opacity로 변환하고 XML 특수문자를 이스케이프한다. + - 정확히 5건의 유효 설정이 아닐 때 `null`을 반환해 잘못된 스타일 표출을 차단한다. +- `gis.layer.js` + - TileWMS 요청에 `CQL_FILTER`, `SLD_BODY`, PNG 투명 배경을 적용한다. + - SLD 내부 레이어명은 접두사 없는 `tb_ms_flood_dngr_map`을 사용한다. + - WMS 레이어의 `drawOrdr`, `minViewSize`, 문자열 ID를 정상 반영한다. +- `gis.core.js` + - SLD 검증 실패로 WMS 레이어가 생성되지 않은 경우 지도 추가를 안전하게 중단한다. +- `GisTree.js` + - 실제 WMS 부모를 자동 재생 대상에 포함한다. + - 숨겨진 자식은 범례 메타데이터로 유지하되 실제 레이어와 자동 재생 대상에서는 제외한다. + +## 5. 기능별 현재 상태 + +| 기능 | 현재 상태 | +|---|---| +| 목록 팝업 | 기존 구현 유지 | +| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 | +| 일반 지도 통신 | 빈도 부모 3개 TileWMS | +| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 | +| 침수심도 스타일 | 자식 5건 기반 동적 SLD | +| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 | +| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 | +| 기존 다른 레이어 | 기존 WFS 경로 유지 | + +## 6. BE 공유 사항 + +- 신규 API나 응답 필드는 필요하지 않다. +- 레이어 설정 DTO의 `flterSql`, `upperLyrSn`, `lnColor`, `polgnColor`, `lnWdth`를 계속 제공해야 한다. +- DB 전환 DML은 공통 `IIPBS/docs/DB작업.sql`에서 관리한다. + +## 7. 검증 요약 + +- SLD 단위검사 통과 +- ESLint 오류 0건 +- `@common/business_components` 빌드 성공 +- 개발 DB 부모 3건·자식 15건 정합성 정상 +- 30·50·80년 ON/OFF 정상 +- 30년 WMS 요청과 SLD 규칙 5개 정상 + +## 8. 남은 리스크 + +- TileWMS는 논리 레이어 1개당 화면 타일 수만큼 `GetMap`을 요청하므로 HTTP 요청 개수를 정확히 3개로 판단하면 안 된다. +- 운영 프록시의 URL 길이 제한과 GeoServer `SLD_BODY` 허용 설정은 운영 반영 시 재확인한다. +- 전체 SN Next 빌드는 개발 서버 종료 후 별도 수행한다. + +## 9. 흡수한 기존 문서 + +- 별도 SN 기능 문서는 없었다. 공통 2026-08-04 바이브코딩 기록의 목록보기 핵심 상태를 본 문서에 요약했다. diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index c86c2d567..d97e91dc2 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -130,6 +130,18 @@ export const windowPopupDefinitions = { width: 1380, height: 820, }, + sbmrgDngrMapPopup: { + key: "sbmrgDngrMapPopup", + title: "침수위험지도 목록", + width: 1160, + height: 720, + }, + floodDngrMapPopup: { + key: "floodDngrMapPopup", + title: "도시침수지도 목록", + width: 1160, + height: 720, + }, pipeAnalysisPopup: { key: "pipeAnalysisPopup", title: "관로분석", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 2f3049327..bd5f9cc64 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -38,6 +38,8 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", + MAP_SEARCH_FIT_EXTENT: "sn:popup:mapSearchFitExtent", + MAP_SEARCH_SHOW_FLOOD_GROUP: "sn:popup:mapSearchShowFloodGroup", GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady", GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync", GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index 5f6a50268..f6fdeab8b 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -18,6 +18,8 @@ import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; import ReportTabPopupContent from "./ui/ReportTabPopupContent"; import SearchModalPopupContent from "./ui/SearchModalPopupContent"; import SinkholePopupContent from "./ui/SinkholePopupContent"; +import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent"; +import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; @@ -111,6 +113,14 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.sinkholePopup, component: SinkholePopupContent, }, + sbmrgDngrMapPopup: { + ...windowPopupDefinitions.sbmrgDngrMapPopup, + component: SbmrgDngrMapPopupContent, + }, + floodDngrMapPopup: { + ...windowPopupDefinitions.floodDngrMapPopup, + component: FloodDngrMapPopupContent, + }, pipeAnalysisPopup: { ...windowPopupDefinitions.pipeAnalysisPopup, component: PipeAnalysisPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx new file mode 100644 index 000000000..b3231a12a --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx @@ -0,0 +1,7 @@ +import FloodDngrMapPopupPage from "@/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage"; +import type { WindowPopupContentProps } from "../model/types"; + +/** 도시침수지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */ +export default function FloodDngrMapPopupContent(_props: WindowPopupContentProps) { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx new file mode 100644 index 000000000..116b5cd9b --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx @@ -0,0 +1,7 @@ +import SbmrgDngrMapPopupPage from "@/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage"; +import type { WindowPopupContentProps } from "../model/types"; + +/** 침수위험지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */ +export default function SbmrgDngrMapPopupContent(_props: WindowPopupContentProps) { + return ; +} diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index daae30fe4..ba0039ae6 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -346,7 +346,13 @@ export default React.memo(function GisTree({ // orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별 const getTargetNodesInOrder = () => { - const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm)); + const target = nodes.filter((node) => { + const hasChildren = nodes.some((child) => child.parent === node.id); + const isWmsGroupLayer = node.data?.srvcNm === 'wms' && hasChildren; + return node.data?.exprsYn === 'Y' + && layerTypes.includes(node.data?.lyrTyNm) + && (node.data?.actlLyrYn === 'N' || isWmsGroupLayer); + }); // drawOrdr 값을 기준으로 오름차순 정렬 target.sort((a, b) => { diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js index e9e652183..3b092c5b1 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.core.js +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -392,6 +392,7 @@ GisApp.core.prototype = { */ addWmsLayer: function (layerCodeObj) { let layer = GisApp.Module.layer.getWmsLayer(layerCodeObj); + if (!layer) return null; this.deleteLayer(layer.get('id')); this.map.addLayer(layer); return layer; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js index 97d3eb6db..2745a5ff4 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.layer.js +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -20,6 +20,11 @@ import GisOlStyle from './gis.style.js'; import Tile from 'ol/layer/Tile.js'; import TileWMS from 'ol/source/TileWMS.js'; import gisCore from './gis.core.js'; +import { + buildFloodDngrSldBody, + FLOOD_DNGR_MAP_LAYER_TYPE, + getFloodDngrSldSettings, +} from './gis.wms-sld.js'; /** * GisApp.layer @@ -179,6 +184,7 @@ GisOlLayer.prototype = { let url = GisApp.GEOSERVER_WMS_URL; let toProj = GisApp.TO_PROJ; const lyrTyNm = options.lyrTyNm; + const filter = String(options?.flterSql ?? '').trim(); let params = {}; params['LAYERS'] = this.storageNm + lyrTyNm; params['SERVICE'] = 'WMS'; @@ -186,7 +192,21 @@ GisOlLayer.prototype = { params['REQUEST'] = 'GetMap'; params['SRS'] = toProj; params['FORMAT'] = 'image/png'; + params['TRANSPARENT'] = true; params['TILED'] = true; + params['STYLES'] = ''; + if (filter) params['CQL_FILTER'] = filter; + + if (lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE) { + const sldSettings = getFloodDngrSldSettings(GisApp.layerCode, options?.id); + const sldBody = buildFloodDngrSldBody(lyrTyNm, sldSettings); + if (!sldBody) { + console.warn('[GisApp] 도시침수지도 WMS SLD 생성에 필요한 침수심도 설정 5건이 유효하지 않습니다.', options); + return null; + } + params['SLD_BODY'] = sldBody; + } + const wmsLayer = new Tile({ ...layerOptions, id: layerOptions?.id, @@ -196,8 +216,9 @@ GisOlLayer.prototype = { serverType: 'geoserver', }), }); - wmsLayer.setZIndex(options.draw0rdr); - wmsLayer.setMinZoom(options.minViewSize); + wmsLayer.set('id', String(layerOptions?.id)); + wmsLayer.setZIndex(Number(options?.drawOrdr) || 1); + wmsLayer.setMinZoom(Number(options?.minViewSize) || 0); this.layers.push(wmsLayer); return wmsLayer; }, diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js index 58fdc2592..bf2eb2f07 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.select.js +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -29,9 +29,11 @@ GisOlSelect.prototype = { dragBox: null, dragPolygon: null, selectedFeatures: [], + focusedBoundaryFeatures: null, selectFeatureFlag: false, initialize: function (map, options) { this.map = map; + this.focusedBoundaryFeatures = new WeakSet(); Object.assign(this.defaults, options); this.setInteraction(); this.pointerMoveEventHandler(); @@ -107,7 +109,8 @@ GisOlSelect.prototype = { } } const geometryType = feature?.getGeometry?.()?.getType?.(); - if (geometryType === 'LineString' || geometryType === 'MultiLineString') { + const focusedBoundary = me.focusedBoundaryFeatures.has(feature); + if (focusedBoundary || geometryType === 'LineString' || geometryType === 'MultiLineString') { styles.push(...focusedLineStyle); } else { styles.push(baseStyle); @@ -329,6 +332,7 @@ GisOlSelect.prototype = { selectedFeatures.remove(feature); }); selectedFeatures.clear(); + this.focusedBoundaryFeatures = new WeakSet(); }, /** * SELECT 이벤트 활성화 @@ -491,6 +495,20 @@ GisOlSelect.prototype = { auto: auto, }); }, + /** + * 폴리곤을 선형 피처와 동일한 다중 경계 스타일로 강조한다. + */ + selectFocusedBoundaryFeature: function (feature, auto) { + let me = this; + me.focusedBoundaryFeatures.add(feature); + me.selectedFeatures.push(feature); + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + auto: auto, + }); + }, }; export default GisOlSelect; diff --git a/packages/common/business_components/src/ui/map/lib/gis.wms-sld.js b/packages/common/business_components/src/ui/map/lib/gis.wms-sld.js new file mode 100644 index 000000000..131378ae0 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.wms-sld.js @@ -0,0 +1,103 @@ +const FLOOD_DNGR_MAP_LAYER_TYPE = 'tb_ms_flood_dngr_map'; +const FLOOD_DEPTH_ATTRIBUTE = 'sbmrg_dpthl_cd'; +const FLOOD_DEPTH_CODES = ['N330', 'N331', 'N332', 'N333', 'N334']; + +const escapeXml = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const toHex = (value) => Math.round(value).toString(16).padStart(2, '0'); + +const parseCssColor = (value) => { + const normalized = String(value ?? '').trim(); + const rgbaMatch = normalized.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/i); + if (rgbaMatch) { + const red = Number(rgbaMatch[1]); + const green = Number(rgbaMatch[2]); + const blue = Number(rgbaMatch[3]); + const opacity = rgbaMatch[4] == null ? 1 : Number(rgbaMatch[4]); + if ([red, green, blue].some((channel) => channel < 0 || channel > 255) || opacity < 0 || opacity > 1) { + return null; + } + return { + hex: `#${toHex(red)}${toHex(green)}${toHex(blue)}`, + opacity, + }; + } + + const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!hexMatch) return null; + const rawHex = hexMatch[1].length === 3 + ? hexMatch[1].split('').map((character) => character + character).join('') + : hexMatch[1]; + return { + hex: `#${rawHex.toLowerCase()}`, + opacity: 1, + }; +}; + +const getFloodDepthCode = (filterSql) => { + const match = String(filterSql ?? '').match(/\bsbmrg_dpthl_cd\s*=\s*'([^']+)'/i); + const depthCode = match?.[1]?.trim(); + return FLOOD_DEPTH_CODES.includes(depthCode) ? depthCode : null; +}; + +const getLineWidth = (value) => { + const lineWidth = Number(value); + return Number.isFinite(lineWidth) && lineWidth >= 0 && lineWidth <= 20 ? lineWidth : null; +}; + +/** + * 빈도 부모 레이어 바로 아래에 있는 침수심도 설정만 선별한다. + * 부모는 WMS 요청을 담당하고, 반환된 자식 5건은 SLD 규칙의 필터와 색상 원본으로 사용한다. + */ +export const getFloodDngrSldSettings = (layerCode, parentLayerId) => Object.values(layerCode ?? {}) + .filter((layerSetting) => ( + String(layerSetting?.upperLyrSn ?? '') === String(parentLayerId ?? '') + && layerSetting?.lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE + )) + .sort((left, right) => Number(left?.sortOrdr ?? 0) - Number(right?.sortOrdr ?? 0)); + +/** + * DB의 침수심도 필터와 RGBA 스타일을 GeoServer가 해석할 수 있는 SLD 1.0 XML로 변환한다. + * 다섯 침수심도 코드나 색상값 중 하나라도 유효하지 않으면 잘못된 기본 스타일 표출을 막기 위해 null을 반환한다. + */ +export const buildFloodDngrSldBody = (layerName, layerSettings) => { + if (!Array.isArray(layerSettings) || layerSettings.length !== FLOOD_DEPTH_CODES.length) return null; + + const settingsByDepthCode = new Map(); + for (const layerSetting of layerSettings) { + const depthCode = getFloodDepthCode(layerSetting?.flterSql); + if (depthCode && !settingsByDepthCode.has(depthCode)) { + settingsByDepthCode.set(depthCode, layerSetting); + } + } + + if (settingsByDepthCode.size !== FLOOD_DEPTH_CODES.length) return null; + + const rules = []; + for (const depthCode of FLOOD_DEPTH_CODES) { + const layerSetting = settingsByDepthCode.get(depthCode); + const fillColor = parseCssColor(layerSetting?.polgnColor); + const strokeColor = parseCssColor(layerSetting?.lnColor); + const lineWidth = getLineWidth(layerSetting?.lnWdth); + if (!fillColor || !strokeColor || lineWidth == null) return null; + + rules.push( + `${escapeXml(depthCode)}${escapeXml(layerSetting?.lyrNm || depthCode)}` + + `${FLOOD_DEPTH_ATTRIBUTE}${escapeXml(depthCode)}` + + `${fillColor.hex}${fillColor.opacity}` + + `${strokeColor.hex}${strokeColor.opacity}${lineWidth}` + ); + } + + return `` + + `` + + `${escapeXml(layerName)}${rules.join('')}` + + ``; +}; + +export { FLOOD_DNGR_MAP_LAYER_TYPE };