From 41504e6e0ff6339c48c31213d132b795846d994b Mon Sep 17 00:00:00 2001 From: ljpark Date: Thu, 26 Mar 2026 10:53:43 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=201=EC=B0=A8=20=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aged-pipe/AgedPipeDetailPanel.tsx | 55 +++ .../aged-pipe/AgedPipeFilterPanel.tsx | 101 +++++ .../aged-pipe/AgedPipeListModal.tsx | 180 ++++++++ .../(map-layout)/aged-pipe/agedPipeUtils.ts | 221 ++++++++++ .../app/html/(map-layout)/aged-pipe/page.tsx | 403 ++++++++++++++++++ .../html/06. 노후관로관리_시나리오_v1.0.md | 4 +- apps/sn/app/html/AGENTS.md | 2 +- 7 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx create mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx create mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx create mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts create mode 100644 apps/sn/app/html/(map-layout)/aged-pipe/page.tsx diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx new file mode 100644 index 000000000..cf6db8572 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@common/components"; +import { + AGED_PIPE_DETAIL_FIELDS, + getElapsedYears, + getPipeDisplayValue, +} from "./agedPipeUtils"; + +type Props = { + featureData: any; + onClose: () => void; +}; + +export default function AgedPipeDetailPanel({ featureData, onClose }: Props) { + const source = featureData?.values_ ?? featureData ?? {}; + const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-"); + const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd); + + return ( +
+
+
+
노후관로 상세
+
시설관리번호 {facilityNo}
+
+ 경과년도 {elapsedYears ?? "-"}년 +
+
+ +
+ +
+
+ {AGED_PIPE_DETAIL_FIELDS.map((field) => ( +
+
{field.title}
+
+ {String(getPipeDisplayValue(source, field.id) ?? "-")} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx new file mode 100644 index 000000000..5a94014a2 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Button, Input } from "@common/components"; +import useStore from "@/store/useStore"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type OverlayProps = { + onClose?: () => void; + isPlaying?: boolean; + setIsPlaying?: (value: boolean) => void; + totalSteps?: number; + currentStep?: number; +}; + +type Props = OverlayProps & { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: () => void; +}; + +export default function AgedPipeFilterPanel({ + filters, + onFiltersChange, + onApply, +}: Props) { + const { ctpv, sgg } = useStore(); + const sggOptions = filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + + return ( +
+
+
노후관로 기준
+
+ 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ +
+ + + + + +
+ + +
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx new file mode 100644 index 000000000..bf6a8fb02 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useRef } from "react"; +import { Button, Input } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; +import { + AGED_PIPE_LIST_HEADLIST, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + rows: AgedPipeRow[]; + total: number; + currentPage: number; + isLoading: boolean; + onSearch: (page?: number) => void; + onDownload: () => void; + onRowDoubleClick: (row: AgedPipeRow) => void; +}; + +export default function AgedPipeListModal({ + open, + onOpenChange, + filters, + onFiltersChange, + rows, + total, + currentPage, + isLoading, + onSearch, + onDownload, + onRowDoubleClick, +}: Props) { + const { ctpv, sgg } = useStore(); + const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null); + const sggOptions = filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + const now = Date.now(); + const lastClick = lastClickRef.current; + if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) { + lastClickRef.current = null; + onRowDoubleClick(row); + return; + } + + lastClickRef.current = { index, clickedAt: now }; + }; + + if (!open) return null; + + return ( +
+
+ +
+
+
노후관로 목록
+
+ 지도 위에 띄워지는 윈도우형 팝업 미리보기 +
+
+ +
+ +
+
+ + + + + + +
+ + +
+
+ +
+ + headName={<>노후관로 조회 결과} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => onSearch(page)} + curPage={currentPage} + widthFixed={true} + /> +
+ +
+ 목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다. +
+
+
+
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts new file mode 100644 index 000000000..baff4b003 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts @@ -0,0 +1,221 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { + MS_SWAGE_PPL_CODES, + MS_SWAGE_PPL_USE_CODES, + MsSwagePplCol, +} from "../map/components/common/table"; + +export type AgedPipeFilters = { + elapsedYears: number; + ctpvCd: string; + sggCd: string; +}; + +export type AgedPipeRow = { + fid: string; + lyrTypeNm: string; + rowNumber: number; + fcltMngNo: string; + instlYmd: string; + pdmtVl: string | number; + prlgVl: string | number; + usgCd: string; + mqltCd: string; + elapsedYears: number | string; + ctpvNm?: string; + sggNm?: string; + [key: string]: unknown; +}; + +type PipeSource = Record; + +const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); +const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); + +export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { + elapsedYears: 20, + ctpvCd: "0", + sggCd: "0", +}; + +export const AGED_PIPE_LIST_HEADLIST = [ + { id: "rowNumber", title: "NO", widthPercent: 6 }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, + { id: "instlYmd", title: "설치일자", widthPercent: 12 }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, + { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, + { id: "usgCd", title: "용도", widthPercent: 14 }, + { id: "mqltCd", title: "재질", widthPercent: 18 }, + { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, +]; + +export const AGED_PIPE_EXCEL_HEADER = { + rowNumber: "NO", + fcltMngNo: "시설관리번호", + instlYmd: "설치일자", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + usgCd: "용도", + mqltCd: "재질", + elapsedYears: "경과년도", +}; + +export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol; + +const getToday = () => new Date(); + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +export const getCutoffYmd = (elapsedYears: number) => { + const today = getToday(); + const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); + return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; +}; + +const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getCodePrefix = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + if (String(directName ?? "").trim()) return String(directName); + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4) return null; + + const year = Number(normalized.slice(0, 4)); + const month = Number(normalized.slice(4, 6) || "1"); + const day = Number(normalized.slice(6, 8) || "1"); + if (!year) return null; + + const today = getToday(); + let elapsed = today.getFullYear() - year; + if ( + today.getMonth() + 1 < month || + (today.getMonth() + 1 === month && today.getDate() < day) + ) { + elapsed -= 1; + } + + return Math.max(elapsed, 0); +}; + +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const cqlFilters: string[] = []; + cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`); + + if (filters.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`); + } else if (filters.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`); + } + + return cqlFilters; +}; + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false; + + const regionCode = getCodePrefix(source); + if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd); + if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd); + return true; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getCodePrefix(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); + const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); + const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; + const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"), + rowNumber: (page - 1) * 10 + index + 1, + fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"), + instlYmd: String(instlYmd ?? "-"), + pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), + prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), + usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), + mqltCd: + materialMap.get(trimmedMaterial) ?? + String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), + elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "-", + ctpvNm: resolveRegionName( + codePrefix, + formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, + ctpvOptions, + 2, + ), + sggNm: resolveRegionName( + codePrefix, + formatted.sggNm ?? source.sggNm ?? source.sgg_nm, + sggOptions, + 5, + ), + }; +}; + +export const getPipeDisplayValue = (source: PipeSource, key: string) => { + const formatted = formatDisplayData(source) as PipeSource; + const keyMap: Record = { + fclt_mng_no: ["fclt_mng_no", "fcltMngNo"], + inst_mng_no: ["inst_mng_no", "instMngNo"], + instl_ymd: ["instl_ymd", "instlYmd"], + mapsht_no: ["mapsht_no", "mapshtNo"], + pdmt_vl: ["pdmt_vl", "pdmtVl"], + prlg_vl: ["prlg_vl", "prlgVl"], + usg_cd: ["usg_cd", "usgCd"], + mqlt_cd: ["mqlt_cd", "mqltCd"], + avg_grdn: ["avg_grdn", "avgGrdn"], + drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"], + excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"], + tmpt_dpth: ["tmpt_dpth", "tmptDpth"], + tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"], + scend_dpth: ["scend_dpth", "scendDpth"], + scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"], + trfln_cnt: ["trfln_cnt", "trflnCnt"], + vrtc_len: ["vrtc_len", "vrtcLen"], + wdth_len: ["wdth_len", "wdthLen"], + }; + + const candidates = keyMap[key] ?? [key]; + for (const candidate of candidates) { + const value = formatted[candidate] ?? source[candidate]; + if (value !== undefined && value !== null && value !== "") { + if (candidate === "usg_cd" || candidate === "usgCd") { + return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value; + } + if (candidate === "mqlt_cd" || candidate === "mqltCd") { + return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value; + } + return value; + } + } + + return "-"; +}; diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx new file mode 100644 index 000000000..e3ebe4d3a --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; +import useStore from "@/store/useStore"; +import { getData } from "@/components/lib/api"; +import AgedPipeFilterPanel from "./AgedPipeFilterPanel"; +import AgedPipeDetailPanel from "./AgedPipeDetailPanel"; +import AgedPipeListModal from "./AgedPipeListModal"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + buildAgedPipeCqlFilters, + matchesAgedPipeFilters, + toDisplayPipeRow, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const agedPipeHighlightStyle = new Style({ + stroke: new Stroke({ + color: "rgba(239, 68, 68, 0.95)", + width: 5, + }), + zIndex: 999, +}); + +const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +const EmptySearchView = () => null; +const DESIGN_PREVIEW_FEATURE = { + fid: "preview-aged-pipe", + lyrTypeNm: PIPE_LAYER_TYPE, + fcltMngNo: "SN-PPL-000123", + instMngNo: "INST-4458", + instlYmd: "19981215", + mapshtNo: "M-12-34", + pdmtVl: 450, + prlgVl: 132.5, + usgCd: "합류관", + mqltCd: "철근콘크리트관(RC)", + avgGrdn: 0.0021, + drtwMltvArea: 2140, + exclnMltvArea: 980, + tmptDpth: 2.1, + tmptMhlMngNo: "TMH-2201", + scendDpth: 2.4, + scendMhlMngNo: "SMH-2202", + trflnCnt: 2, + vrtcLen: 1.2, + wdthLen: 1.2, +}; + +const Map = () => { + const gisMaps = useRef(null); + const [mapReady, setMapReady] = useState(false); + const [treeData, setTreeData] = useState([]); + const [isDetailOpen, setIsDetailOpen] = useState(true); + const [featureData, setFeatureData] = useState(DESIGN_PREVIEW_FEATURE); + const [draftFilters, setDraftFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [isListOpen, setIsListOpen] = useState(true); + const [listRows, setListRows] = useState([ + { + fid: "preview-aged-pipe", + lyrTypeNm: PIPE_LAYER_TYPE, + rowNumber: 1, + fcltMngNo: "SN-PPL-000123", + instlYmd: "1998-12-15", + pdmtVl: 450, + prlgVl: 132.5, + usgCd: "합류관", + mqltCd: "철근콘크리트관(RC)", + elapsedYears: 27, + ctpvNm: "서울특별시", + sggNm: "강남구", + }, + { + fid: "preview-aged-pipe-2", + lyrTypeNm: PIPE_LAYER_TYPE, + rowNumber: 2, + fcltMngNo: "SN-PPL-000124", + instlYmd: "2001-03-10", + pdmtVl: 300, + prlgVl: 88.4, + usgCd: "오수관", + mqltCd: "염화비닐관(PVC)", + elapsedYears: 25, + ctpvNm: "서울특별시", + sggNm: "강남구", + }, + ]); + const [totalCount, setTotalCount] = useState(2); + const [currentPage, setCurrentPage] = useState(1); + const [isListLoading, setIsListLoading] = useState(false); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const { ctpv, sgg } = useStore(); + + useEffect(() => { + const fetchData = async () => { + try { + let data; + const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { + method: "GET", + }); + if (response.ok) { + const contentLength = response.headers.get("content-length"); + if (contentLength === "0") data = null; + + const text = await response.text(); + if (!text || text.trim() === "") data = null; + else data = JSON.parse(text); + } + + setTreeData(data); + } catch (error) { + console.error("노후관로 레이어 설정 조회 중 오류:", error); + throw new Error("Invalid JSON response from server"); + } + }; + + fetchData(); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + if (gisMaps.current) { + setMapReady(true); + window.clearInterval(timer); + } + }, 300); + + return () => window.clearInterval(timer); + }, []); + + const fetchAgedPipeRows = useCallback( + async (page: number, filters: AgedPipeFilters, size: number) => { + const queryParams = new URLSearchParams({ + lyrTypeNm: PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + + const cqlFilters = buildAgedPipeCqlFilters(filters); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const rows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: rows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadAgedPipeList = useCallback( + async (page = 1, filters = appliedFilters) => { + setIsListLoading(true); + try { + const response = await fetchAgedPipeRows(page, filters, 10); + setListRows(response.rows); + setTotalCount(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setListRows([]); + setTotalCount(0); + } finally { + setIsListLoading(false); + } + }, + [appliedFilters, fetchAgedPipeRows], + ); + + useEffect(() => { + loadAgedPipeList(1, appliedFilters); + }, [appliedFilters, loadAgedPipeList]); + + const handleApplyFilters = useCallback(() => { + const normalized = { + ...draftFilters, + elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, + }; + setDraftFilters(normalized); + setAppliedFilters(normalized); + + if (isListOpen) { + loadAgedPipeList(1, normalized); + } + }, [draftFilters, isListOpen, loadAgedPipeList]); + + const handleFeatureSelect = useCallback((data: any) => { + const map = gisMaps.current; + if (!map) return; + + const select = GisApp.Module.select as any; + if (!select) return; + select.resetSelectedFeature(); + + let foundFeature = null; + if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") { + foundFeature = data; + } else { + const featureId = data?.fid ?? data?.ftrIdn ?? data?.id; + map + .getLayers() + .getArray() + .forEach((layer: any) => { + const source = layer.getSource?.(); + if (!source?.getFeatureById) return; + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + }); + } + + if (foundFeature?.getGeometry) { + map.getView().fit(foundFeature.getGeometry().getExtent(), { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + select.selectFeature(foundFeature, true); + } + + setFeatureData(data); + setIsDetailOpen(true); + }, []); + + const handleOpenPipeList = useCallback(() => { + setIsListOpen(true); + loadAgedPipeList(1, appliedFilters); + }, [appliedFilters, loadAgedPipeList]); + + const handleTreePopup = useCallback( + (layerNm: string) => { + if (String(layerNm).includes("하수관로")) { + handleOpenPipeList(); + } + }, + [handleOpenPipeList], + ); + + const handleListRowDoubleClick = useCallback( + async (row: AgedPipeRow) => { + try { + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid); + } catch (error) { + console.error("노후관로 위치 이동 중 오류:", error); + } + + setFeatureData(row); + setIsDetailOpen(true); + setIsListOpen(false); + }, + [], + ); + + const handleDownload = useCallback(async () => { + try { + setIsListLoading(true); + const response = await fetchAgedPipeRows(1, appliedFilters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 목록"], + headerRange: "A1:H1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsListLoading(false); + } + }, [appliedFilters, fetchAgedPipeRows]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const map = gisMaps.current; + const cleanups: Array<() => void> = []; + const filterFn = (feature: any) => + matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters); + + const decorateLayer = (layer: any) => { + if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return; + + const originalStyle = + layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.(); + if (!layer.get("_agedPipeOriginalStyle")) { + layer.set("_agedPipeOriginalStyle", originalStyle); + } + + layer.setStyle((feature: any, resolution: number) => { + const baseStyle = + typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle; + const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : []; + if (filterFn(feature)) { + styles.push(agedPipeHighlightStyle); + } + return styles; + }); + + const source = layer.getSource?.(); + if (source?.on) { + const onLoadEnd = () => layer.changed?.(); + source.on("featuresloadend", onLoadEnd); + cleanups.push(() => source.un?.("featuresloadend", onLoadEnd)); + } + + layer.changed?.(); + }; + + map.getLayers().getArray().forEach(decorateLayer); + const layers = map.getLayers(); + const onAdd = (event: any) => decorateLayer(event.element); + layers.on?.("add", onAdd); + cleanups.push(() => layers.un?.("add", onAdd)); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, [appliedFilters, mapReady]); + + const FilterOverlay = useMemo( + () => + function FilterOverlayComponent(props: any) { + return ( + + ); + }, + [draftFilters, handleApplyFilters], + ); + + return ( + <> +
+ undefined} + searchBar={<>} + waterDepth={FilterOverlay} + showZoomLevel={true} + defaultZoom={7.5} + defaultLonLat={[127.75, 36]} + /> +
+ + { + const normalized = { + ...draftFilters, + elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, + }; + setDraftFilters(normalized); + setAppliedFilters(normalized); + loadAgedPipeList(page, normalized); + }} + onDownload={handleDownload} + onRowDoubleClick={handleListRowDoubleClick} + /> + + ); +}; + +export default Map; diff --git a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md index 968fdc849..2e05f8e89 100644 --- a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md +++ b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md @@ -4,6 +4,7 @@ ## 화면구성 ### GIS 기본화면 +-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다. - 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘) - 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ] - 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시 @@ -12,7 +13,8 @@ - GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업 - 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출 -### 노후관로정보 목록화면 +### 노후관로정보 목록화면 +- 윈도우형 팝업 - GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업 - 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드) - 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도 diff --git a/apps/sn/app/html/AGENTS.md b/apps/sn/app/html/AGENTS.md index f1c03522e..348d1c2d1 100644 --- a/apps/sn/app/html/AGENTS.md +++ b/apps/sn/app/html/AGENTS.md @@ -3,7 +3,7 @@ - 작업시 html 디렉토리 이외의 수정이 되지 않도록 해야 한다. - 추가 *.md 파일의 요청대로 만들되 상위의 를 건드리지 않고 apps\sn\app\html 에 추가파일을 만들어서 처리한다. - 기존의 package/common/component 와 package/common/business_component를 잘 활용한다. - +- 의 시설물 layer는 api 호출 결과 값 설정된 대로 zoom level에 따라 모두 보여야 한다. ## Encoding - UTF-8 - 한글이 깨지지 않도록 주의할 것