From ea490bcdbca391f9d08339fceb3bb50de882dfe3 Mon Sep 17 00:00:00 2001 From: dongsilove Date: Thu, 12 Mar 2026 23:37:15 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20map=20=EC=A1=B0=ED=9A=8C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/map/searchBar/procSearchBar.tsx | 260 +++++++++++++----- 1 file changed, 184 insertions(+), 76 deletions(-) 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 79e195925..6b799b825 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -1,13 +1,15 @@ "use client"; import { SelectBox, type SelectDataType } from "../../../ui"; +import { Button } from "@common/components/ui"; import { useCallback, useEffect, useState } from "react"; +import { transform } from "ol/proj"; import GisApp from "../lib/gis.core"; const geoserverUrl = "/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json"; const FACILITY_TYPE_URL_MAP: Record = { - tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설 + tb_il_swage_prcs_fclt: geoserverUrl, }; interface SearchBarProps { @@ -15,30 +17,125 @@ interface SearchBarProps { facilityTypes: { text: string; val: string }[]; } +interface CenterSelectDataType extends SelectDataType { + center?: [number, number]; +} + export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { const [sidoValue, setSidoValue] = useState("0"); const [sigunValue, setSigunValue] = useState("0"); - const [sidoData, setSidoData] = useState([{ text: "시도", val: "0" }]); - const [sigunData, setSigunData] = useState([{ text: "시군구", val: "0" }]); - const [filteredSigunData, setFilteredSigunData] = useState([{ text: "시군구", val: "0" }]); - const [facilityData, setFacilityData] = useState([{ text: "하수처리시설", val: "0" }]); + const [sidoData, setSidoData] = useState([ + { text: "시도", val: "0" }, + ]); + const [sigunData, setSigunData] = useState([ + { text: "시군구", val: "0" }, + ]); + const [filteredSigunData, setFilteredSigunData] = useState([ + { text: "시군구", val: "0" }, + ]); + const [facilityData, setFacilityData] = useState([ + { text: "하수처리시설", val: "0" }, + ]); const [facilityValue, setFacilityValue] = useState("0"); const [facilityType, setFacilityType] = useState( - facilityTypes?.length === 1 && facilityTypes[0] - ? facilityTypes[0].val - : "0", + facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0", ); - 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`; - const res = await fetch(url).then(res => res.json()); + const getGeometryCenter = (geometry: any): [number, number] | null => { + if (!geometry?.coordinates) return null; - const list = res.features + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + const visit = (coords: any) => { + if (!Array.isArray(coords)) return; + if ( + coords.length >= 2 && + typeof coords[0] === "number" && + typeof coords[1] === "number" + ) { + const x = coords[0]; + const y = coords[1]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + return; + } + coords.forEach(visit); + }; + + visit(geometry.coordinates); + + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(maxX) || + !Number.isFinite(maxY) + ) { + return null; + } + + return [(minX + maxX) / 2, (minY + maxY) / 2]; + }; + + const getMapCenterCoordinate = (centerCoord: [number, number]): [number, number] => { + const [x, y] = centerCoord; + const looksLikeLonLat = Math.abs(x) <= 180 && Math.abs(y) <= 90; + const looksLikeLatLonSwapped = Math.abs(x) <= 90 && Math.abs(y) <= 180; + + if (looksLikeLonLat) { + return transform(centerCoord, GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number]; + } + + if (looksLikeLatLonSwapped) { + return transform([y, x], GisApp.FROM_PROJ, GisApp.TO_PROJ) as [number, number]; + } + + return centerCoord; + }; + + const moveMapToCenter = (centerCoord?: [number, number], zoom?: number) => { + if (!centerCoord) return; + + const map = gisMaps.current; + if (!map?.getView) return; + + const view = map.getView(); + const center = getMapCenterCoordinate(centerCoord); + if (!Number.isFinite(center[0]) || !Number.isFinite(center[1])) return; + + const projection = view.getProjection?.(); + const extent = projection?.getExtent?.(); + if (extent) { + const [minX, minY, maxX, maxY] = extent; + if (center[0] < minX || center[0] > maxX || center[1] < minY || center[1] > maxY) { + console.warn("중심좌표가 지도 범위를 벗어나 이동을 중단합니다.", { center }); + return; + } + } + + view.animate({ + center, + ...(typeof zoom === "number" ? { zoom } : {}), + duration: 500, + }); + }; + + const fetchSido = async () => { + const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&srsName=${GisApp.TO_PROJ}&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd,geom`; + const res = await fetch(url).then((r) => r.json()); + + const list = (res.features || []) .map((f: any) => ({ text: f.properties.mctpv_korn_nm, val: f.properties.mctpv_cd, + center: getGeometryCenter(f.geometry) ?? undefined, })) + // .sort((a: any, b: any) => a.text.localeCompare(b.text, "ko")); .sort((a: any, b: any) => { // 커스텀 순서 정의 const customOrder = [ @@ -76,23 +173,25 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { // 둘 다 커스텀 순서에 없으면 기본 알파벳 순서 return a.text.localeCompare(b.text, 'ko'); }); + setSidoData([{ text: "시도", val: "0" }, ...list]); }; const fetchSigungu = async () => { - const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`; - const res = await fetch(url).then(res => res.json()); + const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&srsName=${GisApp.TO_PROJ}&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm,geom`; + const res = await fetch(url).then((r) => r.json()); - const list = res.features + const list = (res.features || []) .map((f: any) => ({ text: f.properties.sgg_korn_nm, val: f.properties.sgg_cd, + center: getGeometryCenter(f.geometry) ?? undefined, })) - .sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko')); + .sort((a: any, b: any) => a.text.localeCompare(b.text, "ko")); + setSigunData(list); }; - //시설명 콤보박스 조회 const fetchFacilitiesBySggNm = async ( sggCd: string, sggNm: string, @@ -114,23 +213,18 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { let list: SelectDataType[] = []; - if ( - type === "tb_il_swage_prcs_fclt" //하수처리시설 - ) { + 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 + 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" // 재이용시설 - ) { - //재용에서 참조하는 하수처리시설 목록 + .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; @@ -148,19 +242,14 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { text: item.ruseUtztnFcltNm ?? "(이름없음)", val: item.pbnstRuseFcltId, })); - } else if ( - type === "tb_wr_ruse_fclt_dmd_co" //수요처 - ) { + } 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" //잠재수요처 - ) { - // GET 방식 + } else if (type === "tb_wr_tmpr_dmd_co") { const res = await fetch(url); const json = await res.json(); list = (json.data || []).map((item: any) => ({ @@ -168,7 +257,6 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { val: item.fctryMngSn, })); } else { - // GET 방식 const res = await fetch(url); const json = await res.json(); list = (json.data || []).map((item: any) => ({ @@ -177,27 +265,25 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { })); } - // 가나다순 정렬 후 set - const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, 'ko')); - setFacilityData([{ text: "시도", val: "0" }, ...sorted]); + const sorted = list.slice().sort((a, b) => a.text.localeCompare(b.text, "ko")); + setFacilityData([{ text: "하수처리시설", val: "0" }, ...sorted]); setFacilityValue("0"); }; - //시도, 시군구 초기 데이터 조회 useEffect(() => { fetchSido(); fetchSigungu(); }, []); - //시군구 데이터 초기화 const initializeSigunData = useCallback( - (sidoCd: string) => { - if (sidoCd === "0") { + (nextSidoCd: string) => { + if (nextSidoCd === "0") { setFilteredSigunData([{ text: "시군구", val: "0" }]); setSigunValue("0"); return; } - const prefix = sidoCd.substring(0, 2); + + const prefix = nextSidoCd.substring(0, 2); const filtered = sigunData.filter((item) => item.val.startsWith(prefix)); setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]); setSigunValue("0"); @@ -205,7 +291,6 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { [sigunData], ); - //콤보박스 선택시 연관 콤보박스 처리 const onSelectValue = useCallback( (val: string, type: string) => { if (type === "ctpvCd") { @@ -213,54 +298,78 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { initializeSigunData(val); setFacilityData([{ text: "하수처리시설", val: "0" }]); setFacilityValue("0"); - } else if (type === "sggCd") { + return; + } + + if (type === "sggCd") { setSigunValue(val); - const sidoNm = - sidoData.find((item) => item.val === sidoValue)?.text || ""; + const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || ""; const sggNm = sigunData.find((item) => item.val === val)?.text || ""; const sggCd = sigunData.find((item) => item.val === val)?.val || ""; - if (sggNm && sidoNm) - fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType); - } else if (type === "facilityType") { + if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType); + return; + } + + if (type === "facilityType") { setFacilityType(val); setFacilityValue("0"); setFacilityData([{ text: "하수처리시설", val: "0" }]); - 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 || ""; + 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, val); - } else if (type === "fcltCd") { - setFacilityValue(val); + return; + } - // 하수처리시설일 때 바로 handleFeatureFocus 호출 + if (type === "fcltCd") { + setFacilityValue(val); if (val !== "0") { handleFeatureFocus({ fid: val, - layerNm: "tb_il_swage_prcs_fclt", // 레이어 이름 + layerNm: "tb_il_swage_prcs_fclt", }); } } }, - [initializeSigunData, sigunData, sigunValue, sidoData, sidoValue], + [initializeSigunData, sigunData, sigunValue, sidoData, sidoValue, facilityType], ); + const handleSearch = useCallback(() => { + if (sidoValue === "0") { + alert("시도를 선택해 주세요"); + return; + } + + const selectedSido = sidoData.find((item) => item.val === sidoValue); + if (!selectedSido?.center) return; + + if (sigunValue !== "0") { + const selectedSigun = + filteredSigunData.find((item) => item.val === sigunValue) ?? + sigunData.find((item) => item.val === sigunValue); + + if (selectedSigun?.center) { + moveMapToCenter(selectedSigun.center, 12); + return; + } + } + + moveMapToCenter(selectedSido.center, 11); + }, [sidoValue, sigunValue, sidoData, filteredSigunData, sigunData]); + const handleFeatureFocus = (data: any) => { const map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); return; } - // 항상 같은 select 객체 사용 + const select = GisApp.Module.select as any; if (!select) return; - // 1. 기존 선택 초기화 - //select.resetSelectedFeature(); - // 2. data가 OpenLayers Feature 인스턴스인지 확인 + let foundFeature: any = null; - let featureId = data.fid; + const featureId = data.fid; + map .getLayers() .getArray() @@ -271,7 +380,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { if (feature) foundFeature = feature; } }); - // 3. 피처 선택 + if (foundFeature) { const geometry = foundFeature.getGeometry(); if (geometry) { @@ -284,7 +393,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { nearest: true, }); } else { - alert("위치정보를 찾을 수 없습니다.") + alert("위치정보를 찾을 수 없습니다."); console.warn("geometry를 찾을 수 없습니다:", data); } } else { @@ -303,9 +412,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { selectClass="rounded-lg bg-white" label="" selectArray={sidoData} - onSelectValue={(val: any) => - onSelectValue(val.toString(), "ctpvCd") - } + onSelectValue={(val: any) => onSelectValue(val.toString(), "ctpvCd")} value={sidoValue} /> { selectClass="rounded-lg bg-white" label="" selectArray={filteredSigunData} - onSelectValue={(val: any) => - onSelectValue(val.toString(), "sggCd") - } + onSelectValue={(val: any) => onSelectValue(val.toString(), "sggCd")} value={sigunValue} /> { selectClass="rounded-lg bg-white" label="" selectArray={facilityData} - onSelectValue={(val: any) => - onSelectValue(val.toString(), "fcltCd") - } + onSelectValue={(val: any) => onSelectValue(val.toString(), "fcltCd")} value={facilityValue} /> +
+ +