diff --git a/apps/fo/app/pages/(map-layout)/map/page.tsx b/apps/fo/app/pages/(map-layout)/map/page.tsx index 176bc8dd8..77ac1b85c 100644 --- a/apps/fo/app/pages/(map-layout)/map/page.tsx +++ b/apps/fo/app/pages/(map-layout)/map/page.tsx @@ -3,19 +3,35 @@ import Gis from "@common/business_components/ui/map/Gis"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { useEffect, useRef, useState } from "react"; -import { SearchBar } from "../../map/searchBar/searchBar"; +import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; + +// OpenLayers overlay/extent 유틸 +import Overlay from "ol/Overlay"; +import { getCenter as getExtentCenter } from "ol/extent"; +import type { Coordinate } from "ol/coordinate"; interface FeatureData { fid?: string; ftrIdn?: string; id?: string; - getGeometry?: () => { getExtent: () => number[] }; + getId?: () => string | number | undefined; // 추가 + // OpenLayers Feature 비슷한 최소 인터페이스 + get?: (key: string) => any; + getGeometry?: () => { + getExtent: () => number[]; + getType?: () => string; + getCoordinates?: () => Coordinate; + }; setStyle?: () => void; } interface MapInstance { + addOverlay?: (ov: Overlay) => void; + removeOverlay?: (ov: Overlay) => void; + getOverlays?: () => { getArray: () => Overlay[] }; getLayers: () => { getArray: () => Array<{ + get: (key: string) => any; getSource?: () => { getFeatureById?: (id: string) => FeatureData | null }; }>; }; @@ -23,11 +39,19 @@ interface MapInstance { } const Map = () => { - const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const [treeData, setTreeData] = useState([]); const gisMaps = useRef(null); - const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 - const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태 - const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + const [isOpen, setIsOpen] = useState(false); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const [featureData, setFeatureData] = useState(null); + + // === Overlay 관리용 Ref === + const overlayRef = useRef(null); + const overlayContainerRef = useRef(null); + + const facilityTypes = [ + { text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }, + ]; const fetchData = async () => { try { @@ -35,28 +59,117 @@ const Map = () => { const reqUrl = `${API_URL}/lyrStngs/FO_NETWORK`; const response = await fetch(reqUrl, { method: "GET", - headers: { - accept: "application/json", - }, + headers: { accept: "application/json" }, }); - - if (!response.ok) { - throw new Error(`HTTP 오류! 상태: ${response.status}`); - } - - const data = await response.json(); // 여기서 json 파싱 - setTreeData(data); // 데이터 설정 + if (!response.ok) throw new Error(`HTTP 오류! 상태: ${response.status}`); + const data = await response.json(); + setTreeData(data); } catch (error) { console.error("데이터 불러오기 실패:", error); } }; - // 컴포넌트 마운트 시 1회 실행 useEffect(() => { fetchData(); }, []); - // SearchModal, GisContent 등에서 호출 + // ===== 오버레이 콘텐츠 빌더 ===== + const buildOverlayContent = (layerType: string, feature: FeatureData) => { + const props: Record = {}; + // 안전하게 몇 개 필드 추출(없으면 건너뜀) + const mayGet = (k: string) => (feature.get ? feature.get(k) : undefined); + const safe = (v: any) => (v === undefined || v === null ? "-" : String(v)); + + console.log(feature); + + // 레이어 유형별 대표 필드 추출(없으면 "-") + if (layerType === "tb_il_swage_prcs_fclt") { + // 하수처리시설 + props["하수처리시설명"] = mayGet("swtrmt_fclt_nm"); + props["시설용량(m³/d)"] = mayGet("fclt_cpct"); + props["주소"] = mayGet("rdnm_addr"); + props["가동개시일"] = mayGet("oprtng_strt_ymd"); + props["공법"] = mayGet("rprs_msrc_cn"); + } else if (layerType === "tb_ms_swage_prcs_zone") { + // 처리구역(존) + props["하수처리구역명"] = mayGet("swtrmt_zone_nm"); + props["하수처리시설명"] = mayGet("swtrmt_fclt_nm"); + } else if (layerType === "om_wqlt_station") { + // 수질관측소 + props["관측소명"] = mayGet("stn_ko") ?? mayGet("name"); + props["주소"] = mayGet("addr") ?? mayGet("indicator"); + } + + const titleMap: Record = { + tb_il_swage_prcs_fclt: "하수처리시설", + tb_ms_swage_prcs_zone: "하수처리구역", + om_wqlt_station: "관측소", + }; + const title = titleMap[layerType] || "정보"; + + // 컨테이너 재사용(없으면 생성) + if (!overlayContainerRef.current) { + overlayContainerRef.current = document.createElement("div"); + } + const el = overlayContainerRef.current; + el.className = + "ol-custom-overlay shadow-xl rounded-2xl bg-white/95 backdrop-blur p-3 border border-gray-200 min-w-[220px] max-w-[350px] "; + el.innerHTML = ` +
${title}
+ + ${Object.entries(props) + .map( + ([k, v]) => ` + + + + ` + ) + .join("")} +
${k}${safe(v)}
+ `; + return el; + }; + + // ===== 오버레이 생성/표시 ===== + const showOverlay = ( + map: MapInstance, + feature: FeatureData, + layerType: string + ) => { + // 이전 오버레이 제거 + if (overlayRef.current) { + map.removeOverlay?.(overlayRef.current); + overlayRef.current = null; + } + + // 콘텐츠 만들기 + const element = buildOverlayContent(layerType, feature); + + // 앵커 좌표 계산(포인트면 좌표, 아니면 extent 중심) + let position: Coordinate | undefined; + const geom = feature.getGeometry?.(); + if (geom?.getType && geom.getType() === "Point" && geom.getCoordinates) { + position = geom.getCoordinates(); + } else if (geom?.getExtent) { + position = getExtentCenter(geom.getExtent()); + } + + if (!position) return; + + // 오버레이 생성 & 지도에 추가 + const overlay = new Overlay({ + element, + positioning: "bottom-center", + stopEvent: false, + offset: [0, -12], + }); + overlay.setPosition(position); + map.addOverlay?.(overlay); + overlayRef.current = overlay; + }; + + // ===== 피처 선택 시 호출 ===== const onFeatureSelect = (data: FeatureData) => { const map = gisMaps.current; if (!map) { @@ -64,48 +177,58 @@ const Map = () => { return; } - // 항상 같은 select 객체 사용 - const select = GisApp.Module.select as unknown as - | { - resetSelectedFeature: () => void; - selectFeature: (feature: FeatureData, selected: boolean) => void; - } - | undefined; + type SelectModule = { + resetSelectedFeature: () => void; + selectFeature: (feature: FeatureData, selected: boolean) => void; + }; + + const maybeSelect = GisApp?.Module?.select; // 타입 그대로 둠 (any | unknown | null 가능) + + if (!maybeSelect) { + // null/undefined면 일찍 반환 + return; + } + + const select = maybeSelect as SelectModule; // 여기선 null 아님이 보장됨 if (!select) return; - // 1. 기존 선택 초기화 + // 1) 기존 선택 초기화 select.resetSelectedFeature(); - // 2. data가 OpenLayers Feature 인스턴스인지 확인 + // 2) 피처 탐색 + 레이어 유형 파악 + let featureId = String(data.getId?.() ?? ""); + let foundLayerType = featureId.split(".")[0]; let foundFeature: FeatureData | null = null; - if ( + const isOlFeatureInstance = data && typeof data.getGeometry === "function" && - typeof data.setStyle === "function" - ) { - // 이미 Feature 인스턴스라면 그대로 사용 + typeof data.setStyle === "function"; + + if (isOlFeatureInstance) { foundFeature = data; + // 어떤 레이어에서 온 건지 모르는 경우가 많아 기본값으로 시도 } else { - // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 - const featureId = data.fid || data.ftrIdn || data.id; map .getLayers() .getArray() .forEach((layer) => { + if (foundFeature) return; // 이미 찾았으면 skip const source = layer.getSource?.(); - if (source?.getFeatureById) { - const feature = source.getFeatureById(String(featureId).trim()); - if (feature) foundFeature = feature; + const feature = source?.getFeatureById?.(featureId); + if (feature) { + foundFeature = feature; + // 레이어 이름(유형) 우선순위: name -> lyrTblNm -> id + foundLayerType = + layer.get("name") || layer.get("lyrTblNm") || layer.get("id"); } }); } - // 3. 피처 선택 + // 3) 피처 선택/줌 + 오버레이 if (foundFeature && foundFeature.getGeometry) { const geometry = foundFeature.getGeometry(); if (geometry) { const extent = geometry.getExtent(); - map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], @@ -115,15 +238,41 @@ const Map = () => { }); } select.selectFeature(foundFeature, true); + + // 표출 대상 레이어만 오버레이 표시 + const normalized = (foundLayerType || "").toString().trim(); + const TARGETS = [ + "tb_il_swage_prcs_fclt", + "tb_ms_swage_prcs_zone", + "om_wqlt_station", + ]; + + if (TARGETS.includes(normalized)) { + showOverlay(map, foundFeature, normalized); + } else { + // 대상 외 레이어 클릭 시 기존 오버레이 제거 + if (overlayRef.current) { + map.removeOverlay?.(overlayRef.current); + overlayRef.current = null; + } + } } else { console.warn("OpenLayers Feature를 찾을 수 없습니다:", data); + // 실패 시 기존 오버레이 제거 + if (overlayRef.current) { + map.removeOverlay?.(overlayRef.current); + overlayRef.current = null; + } } + // 우측 패널/검색 모달 상태 setFeatureData(data); - setIsOpen(true); + setIsOpen(false); setIsSearchModalOpen(false); }; + // (선택) 다른 위치 클릭 시 오버레이 닫고 싶다면 Gis 쪽에서 지도 클릭 이벤트 받아 여기서 제거하는 훅을 연결하면 됨. + return (
{ isSearchModalOpen={isSearchModalOpen} onCheckedLayersChange={undefined} imgVisble={false} - searchBar={} + searchBar={ + + } waterDepth={undefined} />
diff --git a/apps/fo/package.json b/apps/fo/package.json index aedf37c33..8697a116a 100644 --- a/apps/fo/package.json +++ b/apps/fo/package.json @@ -17,7 +17,10 @@ "next": "15.1.7", "select": "^1.1.2", "webpack": "^5.98.0", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "ol": "^7.3.0", + "ol-ext": "^4.0.11", + "proj4": "^2.9.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index b9736fb37..1f6bc148c 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -159,7 +159,6 @@ function Gis({ // 피처 클릭시 const handleFeatureSelect = useCallback( (data) => { - console.log("handleFeatureSelect"); onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출 }, [mapInstanceRef], @@ -169,8 +168,6 @@ function Gis({ const handleFeatureFocus = useCallback( (layerName, data) => { console.log("handleFeatureFocus"); - console.log(layerName); - console.log(data); // onFeatureFocus?.(data); }, [mapInstanceRef], @@ -194,7 +191,6 @@ function Gis({ // OpenLayers 지도 객체 생성 if (!map) { - console.log("지도 객체 생성"); map = new OlMap({ layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]], renderer: "webgl", @@ -222,14 +218,12 @@ function Gis({ } else { // 📌 재마운트 시 target 없으면 다시 지정 if (!map.getTarget()) { - console.log("재마운트"); map.setTarget(mapRef.current); } } const select = GisApp.Module.select; if (select) { - console.log("select 바인딩"); if (select.selectInteraction) { map.removeInteraction(select.selectInteraction); } @@ -264,7 +258,6 @@ function Gis({ // 📌 언마운트 시 완전 제거 return () => { - console.log("지도 제거"); map.setTarget(null); // DOM 연결 해제 mapInstanceRef.current = null; // 참조 초기화 }; @@ -361,12 +354,7 @@ function Gis({ layerNmRef.current = layerNm; setLayerNm(layerNm); setIsOpen(true); - const dummyFeature = { - getProperties: () => ({}), // 최소 구조 - }; - console.log(layerNm) onOpenPopup(layerNm) - // onFeatureSelect(dummyFeature); }} imageVisible={imgVisble} isPlaying={isPlaying} diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index d7d5b5b72..38729b2b2 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -16,7 +16,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { // 피처 선택 시 콜백 등록 select.getSelectedInfo(function (features) { if (features?.[0]) { - console.log("GisSelect features", features); onFeatureSelect(features[0]); } }); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 7285caa07..53a26a3fa 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -402,7 +402,7 @@ import { activeTab === "layers" ? "block" : "hidden" }`} > -

레이어

+

시설물


{ text: f.properties.mctpv_korn_nm, val: f.properties.mctpv_cd, })); - setSidoData([{ text: "선택", val: "0" }, ...list]); + setSidoData([{ text: "시도", val: "0" }, ...list]); }; const fetchSigungu = async () => { @@ -260,7 +260,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
-
+
{ />