운영실태 지도

This commit is contained in:
yschoi
2025-08-10 18:24:57 +09:00
parent 061cd5aecc
commit a3d9c32024
6 changed files with 199 additions and 58 deletions
+191 -40
View File
@@ -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<MapInstance | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태
const [featureData, setFeatureData] = useState<FeatureData | null>(null); // 선택된 피쳐 데이터
const [isOpen, setIsOpen] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [featureData, setFeatureData] = useState<FeatureData | null>(null);
// === Overlay 관리용 Ref ===
const overlayRef = useRef<Overlay | null>(null);
const overlayContainerRef = useRef<HTMLDivElement | null>(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<string, any> = {};
// 안전하게 몇 개 필드 추출(없으면 건너뜀)
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<string, string> = {
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 = `
<div class="font-semibold text-lg text-gray-800 mb-2 text-center bg-green-400">${title}</div>
<table class="w-full text-base text-gray-800">
${Object.entries(props)
.map(
([k, v]) => `
<tr>
<td class="py-1 pr-2 text-gray-500 whitespace-nowrap">${k}</td>
<td class="py-1 font-medium">${safe(v)}</td>
</tr>`
)
.join("")}
</table>
`;
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 (
<div className="bg-container">
<Gis
@@ -142,7 +291,9 @@ const Map = () => {
isSearchModalOpen={isSearchModalOpen}
onCheckedLayersChange={undefined}
imgVisble={false}
searchBar={<SearchBar onFeatureSelect={onFeatureSelect} gisMaps={gisMaps} />}
searchBar={
<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />
}
waterDepth={undefined}
/>
</div>
+4 -1
View File
@@ -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",
@@ -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}
@@ -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]);
}
});
@@ -402,7 +402,7 @@ import {
activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600">레이어</h3>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
@@ -47,7 +47,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
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) => {
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row">
<div className="col-span-10 flex flex-row ml-5">
<SelectBox
labelClass="w-[40px]"
className="mr-10 w-[150px]"
@@ -285,7 +285,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
/>
<SelectBox
labelClass="w-[60px]"
className="mr-3 w-[180px]"
className="mr-5 w-[180px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={facilityData}