운영실태 지도
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user