From 23b13e3fdc1c126ccda90a83a322511bd7779f95 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 26 Jun 2026 17:29:16 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=98=EC=88=98=EC=B2=98=EB=A6=AC=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 61 ++++- .../001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md | 28 ++ .../src/ui/map/searchBar/procSearchBar.tsx | 246 ++++++++++-------- 3 files changed, 219 insertions(+), 116 deletions(-) create mode 100644 apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 5a8f1feee..4c4f54056 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -182,6 +182,9 @@ const getFlowDirectionModeByPathname = (pathname: string | null): FlowDirectionM const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; +const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; + +type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; // 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다. const getLayerTypeName = (layerInfo: any) => @@ -191,6 +194,45 @@ const getLayerTypeName = (layerInfo: any) => layerInfo?.lyr_type_nm ?? "", ).trim(); +const getLayerFilterSql = (layerInfo: any) => + String( + layerInfo?.filtrSql ?? + layerInfo?.filtr_sql ?? + layerInfo?.flterSql ?? + layerInfo?.filterSql ?? + layerInfo?.filter_sql ?? + "", + ) + .replace(/\s+/g, "") + .toLowerCase(); + +const getSwagePrcsFcltCapacityKind = (layerInfo: any): Exclude | null => { + if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null; + + const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace(/\s+/g, ""); + const filterSql = getLayerFilterSql(layerInfo); + + if (filterSql.includes("fclt_cpct>=500") || layerName.includes("500이상") || layerName.includes("500톤이상")) return "gte500"; + if (filterSql.includes("fclt_cpct<500") || layerName.includes("500미만") || layerName.includes("500톤미만")) return "lt500"; + + return null; +}; + + +const getSwagePrcsFcltLayerMode = (checkedNodesData: any[]): SwagePrcsFcltLayerMode => { + const checkedKinds = new Set( + checkedNodesData + .map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node)) + .filter(Boolean), + ); + const hasGte500 = checkedKinds.has("gte500"); + const hasLt500 = checkedKinds.has("lt500"); + + if (hasGte500 && hasLt500) return "all"; + if (hasGte500) return "gte500"; + if (hasLt500) return "lt500"; + return "none"; +}; // 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다. const isFlowDirectionLayerInfo = (layerInfo: any) => { @@ -2240,6 +2282,7 @@ const Map = () => { const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); + const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = useState("all"); const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState([]); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); @@ -3616,7 +3659,21 @@ const Map = () => { setIsSearchModalOpen(false); }; - const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; + const swagePrcsFcltSearchFilter = useMemo(() => { + switch (swagePrcsFcltLayerMode) { + case "gte500": + return { label: "하수처리시설(500톤 이상)", cqlFilter: "fclt_cpct >= 500" }; + case "lt500": + return { label: "하수처리시설(500톤 미만)", cqlFilter: "fclt_cpct < 500" }; + case "none": + return { label: "하수처리시설(레이어 꺼짐)", disabled: true }; + case "all": + default: + return { label: "하수처리시설(전체)" }; + } + }, [swagePrcsFcltLayerMode]); + + const facilityTypes = [{ text: "하수처리시설", val: SWAGE_PRCS_FCLT_LAYER_TYPE }]; const fetchAgedPipeRows = useCallback( async (page: number, filters: AgedPipeFilters, size: number) => { @@ -4482,6 +4539,7 @@ const Map = () => { const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { checkedNodesDataRef.current = checkedNodesData; setCheckedLayerLegendVersion((version) => version + 1); + setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData)); // 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다. const flowDirectionNode = checkedNodesData.find((node) => @@ -6629,6 +6687,7 @@ const Map = () => { gisMaps={gisMaps} facilityTypes={facilityTypes} sidoSearchZoom={isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined} + facilityLayerFilter={swagePrcsFcltSearchFilter} /> } waterDepth={undefined} diff --git a/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md b/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md new file mode 100644 index 000000000..45b33d31e --- /dev/null +++ b/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md @@ -0,0 +1,28 @@ +# FE-MAP 하수처리시설 검색 동기화 구현기록 + +## 1. 작업 목적 +- 지도 레이어 트리의 하수처리시설 500톤 기준 레이어 선택 상태와 상단 시설물 검색 조건을 동기화한다. + +## 2. 수정 대상 +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- `FE_REPO_ROOT/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` +- `COMMON_DOCS_ROOT/DB작업.sql` + +## 3. 구현 내용 +- 하수처리시설 하위 레이어명은 DB `tb_sn_lyr_stng`의 `lyr_nm`을 그대로 사용하고, 프론트 강제 치환은 제거했다. +- 레이어 선택 상태에 따라 시설물 검색 셀렉트 문구를 `하수처리시설(전체)`, `하수처리시설(500톤 이상)`, `하수처리시설(500톤 미만)`, `하수처리시설(레이어 꺼짐)`으로 표시한다. +- 하수처리시설 검색 목록 조회 시 선택된 레이어 기준에 맞춰 `fclt_cpct >= 500` 또는 `fclt_cpct < 500` 조건을 함께 적용한다. +- WFS 목록 조회의 `cql_filter`는 `URLSearchParams`로 인코딩해 500톤 기준 조건이 실제 요청에 반영되도록 보정했다. +- 시설 선택 시 현재 지도 source만 검색하지 않고, feature id 기준 GeoServer 재조회 후 검색 레이어를 선택하도록 보정했다. +- 두 레이어가 모두 꺼진 경우 하수처리시설 목록은 선택 불가 placeholder만 표시한다. +- `tb_sn_lyr_stng`의 `lyr_sn=112/113` 레이어명을 `500톤 이상`, `500톤 미만`으로 정리하는 DML 초안을 `DB작업.sql`에 추가했다. + +## 4. 검증 +- TSX 파서 검사 통과 +- `pnpm --filter @common/business_components run build` 통과 + +## 5. 영향 범위와 리스크 +- 프론트 지도 화면의 레이어명 표시와 상단 시설물 검색 목록에 한정된다. +- API 계약과 DB 스키마 변경은 없다. +- DB 기준데이터 레이어명 변경 DML은 실제 대상 DB 반영 후 지도 레이어 트리에서 확인해야 한다. +- 레이어 메타데이터의 `flterSql/filtrSql` 또는 레이어명이 변경되면 500톤 기준 판별 조건을 함께 확인해야 한다. diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx index 6d042f62a..676bd3315 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -12,11 +12,19 @@ const geoserverUrl = const FACILITY_TYPE_URL_MAP: Record = { tb_il_swage_prcs_fclt: geoserverUrl, }; +const DEFAULT_SWAGE_PRCS_FCLT_LABEL = "하수처리시설"; + +type FacilityLayerFilter = { + label: string; + cqlFilter?: string; + disabled?: boolean; +}; interface SearchBarProps { gisMaps: React.RefObject; facilityTypes: { text: string; val: string }[]; sidoSearchZoom?: number; + facilityLayerFilter?: FacilityLayerFilter; } interface CenterSelectDataType extends SelectDataType { @@ -27,6 +35,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes, sidoSearchZoom = 11, + facilityLayerFilter, }: SearchBarProps) => { const [sidoValue, setSidoValue] = useState("0"); const [sigunValue, setSigunValue] = useState("0"); @@ -39,14 +48,18 @@ export const ProcSearchBar = ({ const [filteredSigunData, setFilteredSigunData] = useState([ { text: "시군구", val: "0" }, ]); - const [facilityData, setFacilityData] = useState([ - { text: "하수처리시설", val: "0" }, + const [facilityData, setFacilityData] = useState(() => [ + { text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" }, ]); const [facilityValue, setFacilityValue] = useState("0"); const [facilityType, setFacilityType] = useState( facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0", ); + const defaultFacilityData = useCallback( + () => [{ text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" }], + [facilityLayerFilter?.label], + ); const fetchSido = async () => { const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd`; @@ -114,88 +127,119 @@ export const ProcSearchBar = ({ setSigunData(list); }; - const fetchFacilitiesBySggNm = async ( - sggCd: string, - sggNm: string, - sidoNm: string, - currentFacilityType?: string, - ) => { - const type = currentFacilityType ?? facilityType; - if (!type || type === "0") return; + const fetchFacilitiesBySggNm = useCallback( + async ( + sggCd: string, + sggNm: string, + sidoNm: string, + currentFacilityType?: string, + ) => { + const type = currentFacilityType ?? facilityType; + if (!type || type === "0") return; - const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type]; - if (!baseUrlTemplate) return; + const activeFacilityFilter = type === "tb_il_swage_prcs_fclt" ? facilityLayerFilter : undefined; + if (activeFacilityFilter?.disabled) { + setFacilityData(defaultFacilityData()); + setFacilityValue("0"); + return; + } - const year = new Date().getFullYear().toString(); - let url = baseUrlTemplate - .replace("{year}", year) - .replace("{sggCd}", sggCd) - .replace("{sidoNm}", encodeURIComponent(sidoNm)) - .replace("{sggNm}", encodeURIComponent(sggNm)); + const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type]; + if (!baseUrlTemplate) return; - let list: SelectDataType[] = []; + const year = new Date().getFullYear().toString(); + let url = baseUrlTemplate + .replace("{year}", year) + .replace("{sggCd}", sggCd) + .replace("{sidoNm}", encodeURIComponent(sidoNm)) + .replace("{sggNm}", encodeURIComponent(sggNm)); - if (type === "tb_il_swage_prcs_fclt") { - const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`; - url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`; - const res = await fetch(url); - const json = await res.json(); - list = (json.features || []) - .map((f: any) => ({ - text: f.properties.swtrmt_fclt_nm ?? "(이름없음)", - val: f.id, - })) - .sort((a: any, b: any) => a.text.localeCompare(b.text, "ko")); - } else if (type === "tb_wr_bdst_ruse_fclt") { - const paramsRes = await fetch("/reuse-service/reuseFc/pubFc"); - const paramsJson = await paramsRes.json(); - const params = paramsJson.data; + let list: SelectDataType[] = []; - const res = await fetch(url, { - method: "POST", - headers: { - accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.ruseUtztnFcltNm ?? "(이름없음)", - val: item.pbnstRuseFcltId, - })); - } else if (type === "tb_wr_ruse_fclt_dmd_co") { - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.dmdCoNm ?? "(이름없음)", - val: item.dmdInfoId, - })); - } else if (type === "tb_wr_tmpr_dmd_co") { - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.dmdCoNm ?? "(이름없음)", - val: item.fctryMngSn, - })); - } else { - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.bdstNm ?? "(이름없음)", - val: item.bdstInfoSn, - })); - } + if (type === "tb_il_swage_prcs_fclt") { + const escapeCqlText = (value: string) => value.replace(/'/g, "''"); + const cqlFilters = [ + `ctpv_nm='${escapeCqlText(sidoNm)}'`, + `sgg_nm='${escapeCqlText(sggNm)}'`, + ]; + if (activeFacilityFilter?.cqlFilter) cqlFilters.push(activeFacilityFilter.cqlFilter); + const params = new URLSearchParams({ + typeName: `hasudo:${type}`, + cql_filter: cqlFilters.join(" AND "), + }); + url = `${url}&${params.toString()}`; + const res = await fetch(url); + const json = await res.json(); + list = (json.features || []) + .map((f: any) => ({ + text: f.properties.swtrmt_fclt_nm ?? "(이름없음)", + val: f.id, + })) + .sort((a: any, b: any) => a.text.localeCompare(b.text, "ko")); + } else if (type === "tb_wr_bdst_ruse_fclt") { + const paramsRes = await fetch("/reuse-service/reuseFc/pubFc"); + const paramsJson = await paramsRes.json(); + const params = paramsJson.data; - const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, "ko")); - setFacilityData([{ text: "하수처리시설", val: "0" }, ...sorted]); - setFacilityValue("0"); - }; + const res = await fetch(url, { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + const json = await res.json(); + list = (json.data || []).map((item: any) => ({ + text: item.ruseUtztnFcltNm ?? "(이름없음)", + val: item.pbnstRuseFcltId, + })); + } else if (type === "tb_wr_ruse_fclt_dmd_co") { + const res = await fetch(url); + const json = await res.json(); + list = (json.data || []).map((item: any) => ({ + text: item.dmdCoNm ?? "(이름없음)", + val: item.dmdInfoId, + })); + } else if (type === "tb_wr_tmpr_dmd_co") { + const res = await fetch(url); + const json = await res.json(); + list = (json.data || []).map((item: any) => ({ + text: item.dmdCoNm ?? "(이름없음)", + val: item.fctryMngSn, + })); + } else { + const res = await fetch(url); + const json = await res.json(); + list = (json.data || []).map((item: any) => ({ + text: item.bdstNm ?? "(이름없음)", + val: item.bdstInfoSn, + })); + } + const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, "ko")); + setFacilityData([...defaultFacilityData(), ...sorted]); + setFacilityValue("0"); + }, + [defaultFacilityData, facilityLayerFilter, facilityType], + ); useEffect(() => { fetchSido(); fetchSigungu(); }, []); + useEffect(() => { + const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || ""; + const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || ""; + const sggCd = sigunData.find((item) => item.val === sigunValue)?.val || ""; + + if (sggNm && sidoNm) { + fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType); + return; + } + + setFacilityData(defaultFacilityData()); + setFacilityValue("0"); + }, [defaultFacilityData, facilityLayerFilter?.cqlFilter, facilityLayerFilter?.disabled, facilityType, fetchFacilitiesBySggNm, sidoData, sidoValue, sigunData, sigunValue]); const initializeSigunData = useCallback( (nextSidoCd: string) => { @@ -218,7 +262,7 @@ export const ProcSearchBar = ({ if (type === "ctpvCd") { setSidoValue(val); initializeSigunData(val); - setFacilityData([{ text: "하수처리시설", val: "0" }]); + setFacilityData(defaultFacilityData()); setFacilityValue("0"); return; } @@ -235,7 +279,7 @@ export const ProcSearchBar = ({ if (type === "facilityType") { setFacilityType(val); setFacilityValue("0"); - setFacilityData([{ text: "하수처리시설", val: "0" }]); + setFacilityData(defaultFacilityData()); const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || ""; const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || ""; const sggCd = sigunData.find((item) => item.val === sigunValue)?.val || ""; @@ -253,7 +297,7 @@ export const ProcSearchBar = ({ } } }, - [initializeSigunData, sigunData, sigunValue, sidoData, sidoValue, facilityType], + [defaultFacilityData, fetchFacilitiesBySggNm, initializeSigunData, sigunData, sigunValue, sidoData, sidoValue, facilityType], ); const handleSearch = useCallback(() => { @@ -279,48 +323,20 @@ export const ProcSearchBar = ({ moveMapToCenter(gisMaps, selectedSido.center, sidoSearchZoom); }, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData, sidoSearchZoom]); - const handleFeatureFocus = (data: any) => { - const map = gisMaps.current; - if (!map) { - console.warn("지도가 초기화되지 않았습니다."); + const handleFeatureFocus = async (data: any) => { + const layerNm = String(data?.layerNm ?? "").trim(); + let featureId = String(data?.fid ?? "").trim(); + + if (!layerNm || !featureId) return; + if (!featureId.includes(".")) featureId = `${layerNm}.${featureId}`; + + const searchLayer = GisApp.Module.layer as any; + if (!searchLayer?.showSearchResultsFromGeoJSON) { + console.warn("Search layer module is not ready:", data); return; } - const select = GisApp.Module.select as any; - if (!select) return; - - let foundFeature: any = null; - const featureId = data.fid; - - map - .getLayers() - .getArray() - .forEach((layer: any) => { - const source = layer.getSource?.(); - if (source?.getFeatureById) { - const feature = source.getFeatureById(String(featureId).trim()); - if (feature) foundFeature = feature; - } - }); - - if (foundFeature) { - const geometry = foundFeature.getGeometry(); - if (geometry) { - const extent = geometry.getExtent(); - map.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - constrainResolution: true, - nearest: true, - }); - } else { - alert("위치정보를 찾을 수 없습니다."); - console.warn("geometry를 찾을 수 없습니다:", data); - } - } else { - console.warn("Feature를 찾을 수 없습니다:", data); - } + await searchLayer.showSearchResultsFromGeoJSON(layerNm, featureId); }; return (