refactor, remove, add: GIS main 브랜치와 동기화

This commit is contained in:
swcho
2025-08-09 23:43:37 +09:00
parent f685884ed7
commit f644bd6b7e
10 changed files with 1115 additions and 661 deletions
@@ -156,18 +156,30 @@ function Gis({
setFeatureData(null);
};
// Feature Click Event
// 피처 클릭시
const handleFeatureSelect = useCallback(
(data) => {
console.log("handleFeatureSelect");
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[mapInstanceRef],
);
//피처로 포커스 이동
const handleFeatureFocus = useCallback(
(layerName, data) => {
console.log("handleFeatureFocus");
console.log(layerName);
console.log(data);
// onFeatureFocus?.(data);
},
[mapInstanceRef],
);
// 지도 생성 및 초기화
useEffect(() => {
if (!mapRef.current) return;
if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
//if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단
// EPSG:5179 등록
proj4.defs([
@@ -178,33 +190,53 @@ function Gis({
]);
register(proj4);
// OpenLayers 지도 객체 생성
const map = new OlMap({
layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]],
renderer: "webgl",
target: mapRef.current,
interactions: defaults({ doubleClickZoom: false }),
controls: defaultControls({
zoom: false,
rotate: false,
attributionOptions: {
collapsible: false,
},
}).extend([]),
view: new View({
projection: GisApp.TO_PROJ,
zoom: gisCore.defaults.zoom,
center: olProj.transform(
[127.4875, 37.4916],
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
),
minZoom: gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom,
}),
});
let map = mapInstanceRef.current;
gisMaps.current = map;
// OpenLayers 지도 객체 생성
if (!map) {
console.log("지도 객체 생성");
map = new OlMap({
layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]],
renderer: "webgl",
target: mapRef.current,
interactions: defaults({ doubleClickZoom: false }),
controls: defaultControls({
zoom: false,
rotate: false,
attributionOptions: {
collapsible: false,
},
}).extend([]),
view: new View({
projection: GisApp.TO_PROJ,
zoom: gisCore.defaults.zoom,
center: olProj.transform(
[127.4875, 37.4916],
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
),
minZoom: gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom,
}),
});
} 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);
}
select.map = map; // map 바인딩
select.setInteraction();
select.pointerMoveEventHandler();
}
// 미니맵 추가
if (isMiniMap) {
@@ -221,12 +253,21 @@ function Gis({
map.addControl(overviewMap);
}
gisMaps.current = map;
// 스케일 컨트롤 및 베이스 레이어 설정
gisCore.__proto__.map = map;
gisCore.scaleControl();
gisCore.changeBaseLayer("base");
setMapObject({ map: map });
mapInstanceRef.current = map; // 생성 완료 플래그 설정
// 📌 언마운트 시 완전 제거
return () => {
console.log("지도 제거");
map.setTarget(null); // DOM 연결 해제
mapInstanceRef.current = null; // 참조 초기화
};
}, []);
// treeData 변경 시 레이어 코드 생성
@@ -240,11 +281,6 @@ function Gis({
// 트리 데이터를 레이어 코드 객체로 변환
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, "lyrSn");
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
//setTreeData(data);
// 편집 가능한 레이어 목록 생성
//const layerItems = getLayerItems(layerCode);
//setEditLayerItems(prev => [...layerItems])
}, [treeData]);
// droughtDates를 전역(GisApp)에 반영
@@ -287,9 +323,9 @@ function Gis({
const handleCheckedLayersChange = useCallback(
(checkedIds, checkedNodesData) => {
setTimeout(() => {
setCheckedLayers(checkedIds);
setCheckedLayersData(checkedNodesData);
onCheckedLayersChange?.(checkedIds, checkedNodesData);
setCheckedLayers(checkedIds);
setCheckedLayersData(checkedNodesData);
onCheckedLayersChange?.(checkedIds, checkedNodesData);
}, 1000);
},
[onCheckedLayersChange],
@@ -328,7 +364,9 @@ function Gis({
const dummyFeature = {
getProperties: () => ({}), // 최소 구조
};
onFeatureSelect(dummyFeature);
console.log(layerNm)
onOpenPopup(layerNm)
// onFeatureSelect(dummyFeature);
}}
imageVisible={imgVisble}
isPlaying={isPlaying}
@@ -370,10 +408,8 @@ function Gis({
)}
{/* 시도/시군구 조회 영역 */}
{SearchBar && (
<div
className="absolute left-1/2 top-4 z-30 -translate-x-1/2 rounded w-max"
>
<SearchBar />
<div className="absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded">
{SearchBar}
</div>
)}
<GisToolBar />
@@ -1,74 +1,61 @@
import { Search } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useGisContext } from './Gis';
import './lib/css/gis.edit.css';
import GisApp from './lib/gis.core';
import { Search } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useGisContext } from "./Gis";
import "./lib/css/gis.edit.css";
import GisApp from "./lib/gis.core";
export default function GisContent({ edit = false, onFeatureSelect, isSearch, setIsSearchModalOpen }) {
const { gisMap, gisCore } = useGisContext();
const [visibleComponents, setVisibleComponents] = useState({});
const addressResultLayer = useRef(null);
const [playShow, setPlayShow] = useState({ show: false });
export default function GisContent({
edit = false,
onFeatureSelect,
isSearch,
setIsSearchModalOpen,
}) {
const { gisMap, gisCore } = useGisContext();
//셀렉트 버튼 이벤트
// const clickFeature = useCallback(feature => {
// if (feature) {
// const layer = feature.getLayer();
// setVisibleComponents(prevState => ({
// ...prevState,
// }));
// }
// }, []);
//셀렉트 버튼 이벤트
const selectRslt = useCallback((feature) => {
GisApp.Module.layer.highLightFeature(
[feature],
{
id: GisApp.hlLayerId,
zIndex: 10,
opacity: 1,
selectable: true,
},
{ width: 30 },
);
clickFeature(feature);
}, []);
const selectRslt = useCallback(feature => {
GisApp.Module.layer.highLightFeature(
[feature],
{
id: GisApp.hlLayerId,
zIndex: 10,
opacity: 1,
selectable: true,
},
{ width: 30 }
);
clickFeature(feature);
}, []);
useEffect(() => {
if (gisMap) {
setVisibleComponents((prevState) => ({
...prevState,
}));
}
}, [gisMap]);
useEffect(() => {
if (gisMap) {
setVisibleComponents(prevState => ({
...prevState,
}));
}
}, [gisMap]);
if (!gisMap) return null;
if (!gisMap) return null;
return (
<>
<GisTree />
<GisMapToggle />
<GisLonLat />
<GisToolBar />
<GisSelect onFeatureSelect={onFeatureSelect} />
return (
<>
{/*<GisTree />*/}
{/* <GisMapToggle />
<GisLonLat />
<GisToolBar /> */}
{/* <GisSelect onFeatureSelect={onFeatureSelect} /> */}
{/* 검색 모달 */}
{isSearch && (
<div className="absolute right-8 top-[254px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
className="overflow-hidden w-[40px] h-[40px] bg-white
border border-[#9aa7b8]
rounded
shadow-[0_0_5px_#999]
flex items-center justify-center
p-0
"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</>
);
{/* 검색 모달 */}
{isSearch && (
<div className="absolute right-8 top-[254px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</>
);
}
@@ -1,46 +1,42 @@
import React, { useContext, useEffect } from 'react';
import { GisContext } from './Gis';
import GisApp from './lib/gis.core';
import GisOlSelect from './lib/gis.select';
import React, { useContext, useEffect } from "react";
import { GisContext } from "./Gis";
import GisApp from "./lib/gis.core";
import GisOlSelect from "./lib/gis.select";
export default React.memo(function GisSelect({ onFeatureSelect }) {
const { gisMap } = useContext(GisContext);
const { gisMap } = useContext(GisContext);
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 이미 select가 있으면 재생성하지 않음
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
useEffect(() => {
if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
console.log("GisSelect features", features);
onFeatureSelect(features[0]);
}
});
}, [gisMap, onFeatureSelect]);
// 이미 select가 있으면 재생성하지 않음
if (!GisApp.Module.select) {
const select = new GisOlSelect(gisMap);
GisApp.Module.select = select;
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
// 피처 선택 시 콜백 등록
select.getSelectedInfo(function (features) {
if (features?.[0]) {
console.log('GisSelect features', features);
onFeatureSelect(features[0]);
}
});
}
}, [gisMap, onFeatureSelect]);
// select.getSelectedInfo(function (features) {
// //clickFeature(features[0]);
// if(features.length > 0) {
// const layer = features[0];
// // console.log('GisSelect layer', layer);
// onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달
// }
// });
// const select = new GisOlSelect(gisMap);
// GisApp.Module.select = select;
// select.getSelectedInfo(function (features) {
// //clickFeature(features[0]);
// if(features.length > 0) {
// const layer = features[0];
// // console.log('GisSelect layer', layer);
// onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달
// }
// });
return (
<>
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
return (
<>
{/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}>
<h3>선,면,다각형을 이용한 시설물 선택 영역</h3>
<ul>
<li>
@@ -55,6 +51,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
</li>
</ul>
</div> */}
</>
);
</>
);
});
@@ -1,84 +0,0 @@
// import React, { useState } from 'react';
// import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs';
// import GisTree from './GisTree';
// import GisSearch from './GisSearch';
// import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
// import SearchModal from '@/map/SearchModal';
// function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) {
// const [activeTab, setActiveTab] = useState('layers');
// return (
// <>
// <div
// className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40
// ${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`}
// >
// <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
// <TabsList className="flex-none mt-4 border-none rounded-none">
// <TabsTrigger value="layers" className="flex-1 h-10">레이어</TabsTrigger>
// {/* {isSearch && (
// <button
// onClick={() => setIsSearchModalOpen(true)}
// className="h-10 w-10 flex items-center justify-center px-0"
// >
// <Search size={18} className="text-gray-700" />
// </button>
// // <TabsTrigger value="search" className="flex-1 h-10 flex items-center justify-center">
// // <Search size={18} />
// // </TabsTrigger>
// )} */}
// </TabsList>
// {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */}
// <div
// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'
// }`}
// >
// <GisTree />
// </div>
// {/* GisSearch도 동일하게 처리 */}
// {/* {isSearch && (
// <div
// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${
// activeTab === 'search' ? 'block' : 'hidden'
// }`}
// >
// <GisSearch />
// </div>
// )} */}
// {/* 🔍 검색 모달 */}
// {/* <SearchModal
// open={isSearchModalOpen}
// onOpenChange={setIsSearchModalOpen}
// onFeatureSelect={onFeatureSelect}
// /> */}
// </Tabs>
// {/* 접기 버튼 */}
// <button
// onClick={() => setVisible(false)}
// className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm"
// >
// <ChevronLeft size={16} />
// </button>
// </div>
// {/* 열기 버튼 */}
// {!visible && (
// <button
// onClick={() => setVisible(true)}
// className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm"
// >
// <ChevronRight size={16} />
// </button>
// )}
// </>
// );
// }
// export default React.memo(GisTab);
@@ -1,446 +1,493 @@
import {
MultiBackend,
Tree,
getBackendOptions
} from "@minoru/react-dnd-treeview";
import { ChevronLeft, ChevronRight } from 'lucide-react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useGisContext } from './Gis';
import GisApp from './lib/gis.core';
export default React.memo(function GisTree({
getBackendOptions,
DndProvider,
} from "@minoru/react-dnd-treeview";
import { ChevronLeft, ChevronRight } from "lucide-react";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useGisContext } from "./Gis";
import GisApp from "./lib/gis.core";
export default React.memo(function GisTree({
checkedNodes = [],
setCheckedNodes = () => { },
setCheckedNodes = () => {},
visible,
setVisible,
onCheckedChange,
onLayerIconClick,
imageVisible = false,
isPlaying = false,
setIsPlaying = () => { },
setIsPlaying = () => {},
targetLayerTypes = [],
orderedLayers = [],
checkInterval = 3000,
onProgressChange = () => { },
}) {
onProgressChange = () => {},
}) {
const { gisCore, treeData } = useGisContext();
const [checked, setChecked] = useState([]);
const [expanded, setExpanded] = useState([]);
const [nodes, setNodes] = useState([]);
const [activeTab, setActiveTab] = useState('layers');
const [activeTab, setActiveTab] = useState("layers");
const currentAnimationIndexRef = useRef(0);
const targetNodesRef = useRef([]); // 대상 노드들을 저장할 ref
const isPausedRef = useRef(false); // 일시정지 상태를 저장할 ref
const timeoutIdRef = useRef(null); // setTimeout ID를 저장할 ref
useEffect(() => {
if (treeData && treeData.length > 0) {
const nodeData = setNodeValues(treeData);
setNodes(nodeData);
gisCore.setAllLayersOnMap(false);
}
}, [treeData]);
// 트리 노드 체크 시 레이어 표시/숨김
useEffect(() => {
if (nodes.length > 0) {
// 기본으로 체크된 노드들 찾기
const defaultChecked = getDefaultCheckedNodes(nodes);
setChecked(defaultChecked);
console.log('defaultChecked : ', defaultChecked);
// 기본으로 확장된 노드들 설정
const defaultExpanded = Array.from(new Set([
...defaultChecked,
...defaultChecked.flatMap((id) => getAncestors(id, nodes))
]));
setExpanded(defaultExpanded);
// 기본 체크된 레이어들을 지도에 표시
gisCore.setLayerOnMap(defaultChecked, true);
// 초기 체크된 레이어 정보를 상위로 전달
const defaultCheckedNodesData = nodes.filter(node => defaultChecked.includes(node.id));
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
}
}, [nodes]); // onCheckedChange를 의존성에 추가
// 초기 표시 여부가 'Y'인 레이어들 찾기
const getDefaultCheckedNodes = (nodes, values = []) => {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.data.initEprssYn === 'Y') {
values.push(node.data.lyrSn);
}
}
return values;
};
const targetNodesRef = useRef([]);
const isPausedRef = useRef(false);
const timeoutIdRef = useRef(null);
// ===== 유틸 =====
const getDescendants = (id, all) => {
const children = all.filter((n) => n.parent === id);
return children.reduce(
(acc, child) => acc.concat(child.id, ...getDescendants(child.id, all)),
[]
);
const children = all.filter((n) => n.parent === id);
return children.reduce(
(acc, child) => acc.concat(child.id, ...getDescendants(child.id, all)),
[]
);
};
const getAncestors = (id, all) => {
const node = all.find((n) => n.id === id);
if (!node || node.parent == null) return [];
return [node.parent, ...getAncestors(node.parent, all)];
const node = all.find((n) => n.id === id);
if (!node || node.parent == null) return [];
return [node.parent, ...getAncestors(node.parent, all)];
};
const getChildren = (parentId, all) =>
all.filter((n) => n.parent === parentId);
const areAllChildrenChecked = (parentId, all, checkedIds) => {
const children = getChildren(parentId, all);
return children.length > 0 && children.every((c) => checkedIds.includes(c.id));
};
const updateParentCheckState = (nodeId, all, checkedIds) => {
const parent = all.find((n) => n.id === nodeId)?.parent;
if (parent == null) return checkedIds;
if (areAllChildrenChecked(parent, all, checkedIds)) {
if (!checkedIds.includes(parent)) {
checkedIds.push(parent);
}
} else {
checkedIds = checkedIds.filter((id) => id !== parent);
}
return updateParentCheckState(parent, all, checkedIds);
};
const isEveryChildChecked = (parentId, all, checked) => {
const children = all.filter((n) => n.parent === parentId);
return children.every((c) => checked.includes(c.id));
const children = getChildren(parentId, all);
return children.length > 0 && children.every((c) => checked.includes(c.id));
};
const handleCheck = (id) => {
setChecked((prevChecked) => {
const isChecked = prevChecked.includes(id);
let newChecked = [...prevChecked];
if (isChecked) {
const descendants = getDescendants(id, nodes);
newChecked = newChecked.filter((cid) => cid !== id && !descendants.includes(cid));
const ancestors = getAncestors(id, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) {
newChecked = newChecked.filter((cid) => cid !== p);
}
});
} else {
const descendants = getDescendants(id, nodes);
newChecked = Array.from(new Set([...newChecked, id, ...descendants]));
const ancestors = getAncestors(id, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) {
newChecked.push(p);
}
});
// setExpanded((prev) => Array.from(new Set([...prev, ...ancestors])));
}
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 체크된 노드 정보와 함께 노드 데이터를 상위로 전달
const checkedNodesData = nodes.filter(node => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
// ===== 기본 체크 노드 =====
const getDefaultCheckedNodes = (nodes) => {
let checkedIds = nodes
.filter((n) => n.data.initEprssYn === "Y")
.map((n) => n.id);
nodes.forEach((node) => {
checkedIds = updateParentCheckState(node.id, nodes, checkedIds);
});
return Array.from(new Set(checkedIds));
};
const setNodeValues = (result) => {
return result.map((item) => {
const src = makeLegendSrc(item);
let label = item.lyrNm;
return {
id: item.lyrSn,
text: label,
parent: item.upperLyrSn,
droppable: !!item.children,
data: item,
icon: src ? <img src={src} alt={item.lyrNm} /> : '',
};
});
};
const makeLegendSrc = layerinfo => {
if (!layerinfo.gobjTy) return null;
let src;
if (layerinfo.iconNm) {
src = GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
const ss = makeLegendImage(layerinfo);
src = ss.src;
}
return src;
};
const makeLegendImage = layerinfo => {
let image = document.createElement('img');
let canvas = document.createElement('canvas');
canvas.width = 16;
canvas.height = 16;
let ctx = canvas.getContext('2d');
ctx.beginPath();
if (layerinfo.gobjTy === 'lineLabelPoligon') {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
} else if (layerinfo.gobjTy === 'line') {
ctx.moveTo(1, 15);
ctx.lineTo(5, 1);
ctx.lineTo(9, 15);
ctx.lineTo(15, 1);
} else {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
}
if (layerinfo.gobjTy === 'lineLabelPoligon') {
ctx.fillStyle = layerinfo.polgnColor;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
if (layerinfo.gobjTy === 'line') {
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
image.src = canvas.toDataURL('image/png');
return image;
};
const handleLayerImgClick = (nodeNm, nodeId, layerNm) => {
if (!checked.includes(nodeId)) alert('시설을 선택해주세요')
else onLayerIconClick(nodeNm)
}
// 특정 노드를 체크/언체크하는 함수
const checkSpecificNode = useCallback((nodeId, shouldCheck = true) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isCurrentlyChecked = prevChecked.includes(nodeId);
if (shouldCheck && !isCurrentlyChecked) {
// 체크하기
const descendants = getDescendants(nodeId, nodes);
newChecked = Array.from(new Set([...newChecked, nodeId, ...descendants]));
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) {
newChecked.push(p);
}
});
} else if (!shouldCheck && isCurrentlyChecked) {
// 언체크하기
const descendants = getDescendants(nodeId, nodes);
newChecked = newChecked.filter((cid) => cid !== nodeId && !descendants.includes(cid));
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) {
newChecked = newChecked.filter((cid) => cid !== p);
}
});
}
// 지도에 레이어 표시/숨김 적용
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 상위로 변경 사항 전달
const checkedNodesData = nodes.filter(node => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
// ===== 초기 로딩 =====
useEffect(() => {
if (treeData && treeData.length > 0) {
const nodeData = setNodeValues(treeData);
setNodes(nodeData);
gisCore.setAllLayersOnMap(false);
}
}, [treeData]);
useEffect(() => {
if (nodes.length > 0) {
const defaultChecked = getDefaultCheckedNodes(nodes);
setChecked(defaultChecked);
const defaultExpanded = Array.from(
new Set([
...defaultChecked,
...defaultChecked.flatMap((id) => getAncestors(id, nodes)),
])
);
setExpanded(defaultExpanded);
gisCore.setLayerOnMap(defaultChecked, true);
const defaultCheckedNodesData = nodes.filter((node) =>
defaultChecked.includes(node.id)
);
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
}
}, [nodes]);
// 침수위험지도 실행 함수
const handleAutoCheckAnimation = useCallback((isEnabled, layerTypes = [], interval = 3000) => {
// 기존 타임아웃 정리
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
// ===== 체크 이벤트 =====
const handleCheck = (id) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isChecked = prevChecked.includes(id);
if (isChecked) {
const descendants = getDescendants(id, nodes);
newChecked = newChecked.filter(
(cid) => cid !== id && !descendants.includes(cid)
);
} else {
const descendants = getDescendants(id, nodes);
newChecked = Array.from(new Set([...newChecked, id, ...descendants]));
}
newChecked = updateParentCheckState(id, nodes, newChecked);
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
const checkedNodesData = nodes.filter((node) =>
newChecked.includes(node.id)
);
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
};
// ===== 특정 노드 체크/해제 =====
const checkSpecificNode = useCallback(
(nodeId, shouldCheck = true) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isCurrentlyChecked = prevChecked.includes(nodeId);
if (shouldCheck && !isCurrentlyChecked) {
const descendants = getDescendants(nodeId, nodes);
newChecked = Array.from(
new Set([...newChecked, nodeId, ...descendants])
);
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) {
newChecked.push(p);
}
});
} else if (!shouldCheck && isCurrentlyChecked) {
const descendants = getDescendants(nodeId, nodes);
newChecked = newChecked.filter(
(cid) => cid !== nodeId && !descendants.includes(cid)
);
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) {
newChecked = newChecked.filter((cid) => cid !== p);
}
});
}
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
const checkedNodesData = nodes.filter((node) =>
newChecked.includes(node.id)
);
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
},
[nodes]
);
// ===== 자동 애니메이션 =====
const handleAutoCheckAnimation = useCallback(
(isEnabled, layerTypes = [], interval = 3000) => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
if (!isEnabled || layerTypes.length === 0) {
// 애니메이션 비활성화 시 상태 초기화
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
onProgressChange(0, 0);
return;
}
const getTargetNodesInOrder = () => {
const orderedNodes = [];
orderedLayers.forEach((lyrSn) => {
const node = nodes.find(
(n) => n.id === lyrSn && layerTypes.includes(n.data?.lyrTyNm)
);
if (node) {
orderedNodes.push(node);
}
});
return orderedNodes;
};
const targetNodes = getTargetNodesInOrder();
if (targetNodes.length === 0) return;
if (targetNodesRef.current.length === 0) {
targetNodesRef.current = targetNodes;
currentAnimationIndexRef.current = 0;
onProgressChange(targetNodes.length, 0);
targetNodes.forEach((node) => {
checkSpecificNode(node.id, false);
});
}
isPausedRef.current = false;
const processNextLayer = () => {
if (isPausedRef.current || !isPlaying) return;
const currentTargetNodes = targetNodesRef.current;
const currentIndex = currentAnimationIndexRef.current;
if (currentIndex < currentTargetNodes.length) {
checkSpecificNode(currentTargetNodes[currentIndex].id, true);
if (currentIndex > 0) {
checkSpecificNode(currentTargetNodes[currentIndex - 1].id, false);
}
currentAnimationIndexRef.current++;
onProgressChange(
currentTargetNodes.length,
currentAnimationIndexRef.current
);
timeoutIdRef.current = setTimeout(processNextLayer, interval);
} else {
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
onProgressChange(0, 0);
return;
}
// 대상 노드들을 찾아서 특정 순서로 정렬 후 배열로 반환
const getTargetNodesInOrder = () => {
const orderedNodes = [];
orderedLayers.forEach(lyrSn => {
const node = nodes.find(n => n.id === lyrSn && layerTypes.includes(n.data?.lyrTyNm));
if (node) {
orderedNodes.push(node);
}
});
return orderedNodes;
};
const targetNodes = getTargetNodesInOrder();
if (targetNodes.length === 0) {
console.warn('대상 레이어를 찾을 수 없습니다:', layerTypes);
return;
}
// 처음 시작하는 경우에만 초기화
if (targetNodesRef.current.length === 0) {
targetNodesRef.current = targetNodes;
currentAnimationIndexRef.current = 0;
console.log('자동 체크 대상 노드들 (순서대로):', targetNodes.map(n => ({ id: n.id, name: n.text, type: n.data?.lyrTyNm })));
// 진행률 초기화 (전체 단계 수 설정)
onProgressChange(targetNodes.length, 0);
// 모든 대상 레이어를 먼저 언체크
targetNodes.forEach(node => {
checkSpecificNode(node.id, false);
});
}
// 애니메이션 재개/시작
isPausedRef.current = false;
const processNextLayer = () => {
// 일시정지 상태이거나 애니메이션이 비활성화되면 중단
if (isPausedRef.current || !isPlaying) {
return;
}
const currentTargetNodes = targetNodesRef.current;
const currentIndex = currentAnimationIndexRef.current;
if (currentIndex < currentTargetNodes.length) {
checkSpecificNode(currentTargetNodes[currentIndex].id, true);
if (currentIndex > 0) {
checkSpecificNode(currentTargetNodes[currentIndex - 1].id, false);
}
currentAnimationIndexRef.current++;
// 진행률 업데이트
onProgressChange(currentTargetNodes.length, currentAnimationIndexRef.current);
// 다음 레이어 처리를 위한 타이머 설정
timeoutIdRef.current = setTimeout(processNextLayer, interval);
} else {
// 애니메이션 완료 - 상태 초기화 및 완료 신호 전송
console.log('애니메이션 완료');
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
timeoutIdRef.current = null;
// 진행률 100% 완료
onProgressChange(currentTargetNodes.length, currentTargetNodes.length);
// 잠시 후 진행률 초기화
setTimeout(() => {
onProgressChange(0, 0);
}, 1000);
setIsPlaying?.(false);
}
};
processNextLayer();
}, [nodes, checkSpecificNode, setIsPlaying, isPlaying, onProgressChange]);
// 애니메이션 일시정지 함수
const pauseAnimation = useCallback(() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
isPausedRef.current = true;
}, []);
// 외부에서 전달받은 props로 자동 체크 제어
useEffect(() => {
if (isPlaying) handleAutoCheckAnimation(true, targetLayerTypes, checkInterval); // 재생 시작/재개
else pauseAnimation(); // 일시정지
}, [isPlaying, handleAutoCheckAnimation, targetLayerTypes, checkInterval, pauseAnimation]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
}
onProgressChange(
currentTargetNodes.length,
currentTargetNodes.length
);
setTimeout(() => {
onProgressChange(0, 0);
}, 1000);
setIsPlaying?.(false);
}
};
}, []);
if (!treeData || treeData.length === 0) return null;
return (
<>
<div
className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40
${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`}
>
<div
className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'}`}
>
<h3 className="text-3xl text-gray-600 text-bold font-bold mx-2 my-4 ">레이어</h3>
<hr className="mb-4" />
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => (
<div
className="flex items-center gap-2 py-1 px-2 hover:bg-gray-100 rounded"
style={{ marginInlineStart: depth * 16 + (!node.droppable ? 16 : 0) }}
>
{node.droppable && (
<i onClick={onToggle} className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? 'rotate-90' : ''}`}
title={isOpen ? '닫기' : '열기'}>
</i>
)}
<input type="checkbox" checked={checked.includes(node.id)} onChange={() => handleCheck(node.id)} className="cursor-pointer" />
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">{node.text}</span>
{imageVisible ? depth > 0 && <span><img className="cursor-pointer w-5 h-5" src='/images/map/layericon/Layer_Select_BTN.png' onClick={() => { handleLayerImgClick(node.text, node.id, node.data.lyrTyNm) }} alt={node.text}></img></span> : null}
</div>
)}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? '0');
const bOrder = parseInt(b.data?.sortOrdr ?? '0');
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
{/* 접기 버튼 */}
<button
onClick={() => setVisible(false)}
className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm"
>
<ChevronLeft size={16} />
</button>
</div>
{/* 열기 버튼 */}
{!visible && (
<button
onClick={() => setVisible(true)}
className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm"
>
<ChevronRight size={16} />
</button>
)}
</>
processNextLayer();
},
[nodes, checkSpecificNode, setIsPlaying, isPlaying, onProgressChange]
);
});
const pauseAnimation = useCallback(() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
isPausedRef.current = true;
}, []);
useEffect(() => {
if (isPlaying)
handleAutoCheckAnimation(true, targetLayerTypes, checkInterval);
else pauseAnimation();
}, [
isPlaying,
handleAutoCheckAnimation,
targetLayerTypes,
checkInterval,
pauseAnimation,
]);
useEffect(() => {
return () => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
}
};
}, []);
// ===== 노드 세팅 =====
const setNodeValues = (result) => {
return result.map((item) => {
const src = makeLegendSrc(item);
return {
id: item.lyrSn, // 반드시 고유값
text: item.lyrNm,
parent: item.upperLyrSn || null, // 루트는 null
droppable: true, // children 여부 모를 땐 true로 둬야 트리 확장 가능
data: item,
icon: src ? <img src={src} alt={item.lyrNm} /> : "",
};
});
};
const makeLegendSrc = (layerinfo) => {
if (!layerinfo.gobjTy) return null;
if (layerinfo.iconNm) {
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
return makeLegendImage(layerinfo).src;
}
};
const makeLegendImage = (layerinfo) => {
let image = document.createElement("img");
let canvas = document.createElement("canvas");
canvas.width = 16;
canvas.height = 16;
let ctx = canvas.getContext("2d");
ctx.beginPath();
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
} else if (layerinfo.gobjTy === "line") {
ctx.moveTo(1, 15);
ctx.lineTo(5, 1);
ctx.lineTo(9, 15);
ctx.lineTo(15, 1);
} else {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
}
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.fillStyle = layerinfo.polgnColor;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
if (layerinfo.gobjTy === "line") {
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
image.src = canvas.toDataURL("image/png");
return image;
};
const handleLayerImgClick = (nodeNm, nodeId) => {
if (!checked.includes(nodeId)) alert("시설을 선택해주세요");
else onLayerIconClick(nodeNm);
};
if (!treeData || treeData.length === 0) return null;
return (
<>
<div
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${
visible
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
}`}
>
<div
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${
activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600">레이어</h3>
<hr className="mb-4" />
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => {
const isVisible = node.data.exprsYn === "Y";
const hasVisibleChildren = nodes.some(
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
return (
<div
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
style={{
marginInlineStart: depth * 16,
display: isVisible ? "flex" : "none",
}}
>
{hasVisibleChildren ? (
<i
onClick={onToggle}
className={`diveicon di-arrow-forward mr-2 text-base text-label ${
isOpen ? "rotate-90" : ""
}`}
title={isOpen ? "닫기" : "열기"}
/>
) : (
<span style={{ display: "inline-block", width: 16 }} />
)}
<input
type="checkbox"
checked={checked.includes(node.id)}
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{node.text}
</span>
{imageVisible && depth > 0 && (
<span>
<img
className="h-5 w-5 cursor-pointer"
src="/images/map/layericon/Layer_Select_BTN.png"
onClick={() =>
handleLayerImgClick(
node.data.lyrTyNm,
node.id
)
}
alt={node.text}
/>
</span>
)}
</div>
);
}}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
<button
onClick={() => setVisible(false)}
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronLeft size={16} />
</button>
</div>
{!visible && (
<button
onClick={() => setVisible(true)}
className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronRight size={16} />
</button>
)}
</>
);
});
@@ -16,11 +16,8 @@ import { Point } from 'ol/geom';
import { getTopLeft } from 'ol/extent';
import TileWMTS from 'ol/source/WMTS';
import WMTSTileGrid from 'ol/tilegrid/WMTS';
// import { get as getProjection } from 'ol/proj';
import proj4 from 'proj4';
import { register } from 'ol/proj/proj4';
import olSourceImageWMS from 'ol/source/ImageWMS';
import { GeoJSON } from 'ol/format';
import GisOlEditLayer from './gis.edit';
import GisOlLayer from './gis.layer';
@@ -34,7 +31,6 @@ GisApp.layerInfos = [];
GisApp.DROUGHT_DATES = { startYmd: '', endYmd: '' };
//기본정보
GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows';
GisApp.SN_GEOSERVER_URL = '/sewerage-service/api/v1/map/geometry';
GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms';
//맵 정보
@@ -44,9 +40,8 @@ GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z
GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`;
GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`;
// TODO: NGII 정보
GisApp.NGII_API_KEY = '04trYP9_xwLAfALjwZ-B8g'; // NGII에서 발급받은 API 키
GisApp.NGII_WMTS_URL = `https://map.ngii.go.kr/openapi/Gettile.do?apikey=${GisApp.NGII_API_KEY}`;
// NGII 정보(배경지도)
GisApp.NGII_WMTS_URL = process.env.NEXT_PUBLIC_NGII_URL;
GisApp.EMAP_URL = '/emap/';
GisApp.EMAP_SATELLITE_URL = '/emap/satellite/';
@@ -563,18 +558,24 @@ GisApp.core.prototype = {
*/
setLayerOnMap: function (layerIds, visible, addQurryFlter) {
for (let i in layerIds) {
let id = layerIds[i];
let layer = this.getLayerById(id);
if (layer) {
layer.setVisible(visible);
} else if (visible) {
const layerCode = GisApp.layerCode[id];
const actlLyrYn = layerCode.actlLyrYn;
if(actlLyrYn == 'N') continue;
if(!layerCode.lyrTyNm) continue;
const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || '');
layerCode['flterSql'] = queryFlter;
//layer = this.addWmsLayer(layerCode);
layer = this.addWfsLayer(layerCode);
if(layerCode.srvcNm == 'wms'){
layer = this.addWmsLayer(layerCode);
}else {
layer = this.addWfsLayer(layerCode);
}
}
}
if (GisApp.Module.select) {
@@ -88,7 +88,7 @@ GisOlStyle.prototype = {
}
let baseResolution = 2;
let minScale = 0.4;
let minScale = 0.8;
let maxScale = 1.5;
let scale = baseResolution / resolution > maxScale ? maxScale : baseResolution / resolution;
scale = Math.max(minScale, Math.min(maxScale, scale));
@@ -176,7 +176,7 @@ GisOlStyle.prototype = {
const properties = feature.getProperties();
const overLayId = feature?.get('overLayId');
const overLayMinZoom = layer.get('overLayMinZoom') || '12';
const overLayMinZoom = layer.get('overLayMinZoom') || '10';
const overLayOffset = layer?.get('overLayOffset');
let findoOverlay = this.overlays[overLayId];
if (findoOverlay) {
@@ -193,7 +193,8 @@ GisOlStyle.prototype = {
if (typeof overlayHtml === 'string') {
//const sanitizedHtml = DOMPurify.sanitize(overlayHtml);
overlayContainer.innerHTML = sanitizedHtml;
//overlayContainer.innerHTML = sanitizedHtml;
overlayContainer.innerHTML = overlayHtml;
}
// React 컴포넌트일 경우 createPortal() 사용
else if (React.isValidElement(overlayHtml)) {
@@ -205,7 +206,7 @@ GisOlStyle.prototype = {
element: overlayContainer,
positioning: 'bottom-center',
stopEvent: false,
offset: overLayOffset ? overLayOffset : [0, 0], // 오프셋 설정 (위치 조정)
offset: overLayOffset ? overLayOffset : [80, 0], // 오프셋 설정 (위치 조정)
});
this.overlays[overLayId] = overlay;
@@ -274,7 +275,8 @@ GisOlStyle.prototype = {
let fontExtnColor = layer.get('fontExtnColor');
let fontExtnWdth = Number(layer.get('fontExtnWdth') || '1');
let fontIntnColor = layer.get('fontIntnColor');
let minViewSize = layer.get('minViewSize');
//let minViewSize = layer.get('minViewSize') || 13;
let minViewSize = 14;
if (feature == null) {
return;
}
@@ -282,14 +284,14 @@ GisOlStyle.prototype = {
let label = prop[layer.get('lblAtrbNm')];
let scaleFactor = 1 / resolution; // 해상도가 낮아질수록 글자 크기 증가
const zoom = GisApp.Module.core.map.getView().getZoom();
if (zoom < minViewSize - 3) {
if (zoom <= minViewSize) {
label = '';
}
let text = new Text({
stroke: new Stroke({
color: '#FFFFFF', // 흰색 테두리
width: fontExtnWdth,
}),
// stroke: new Stroke({
// color: '#FFFFFF', // 흰색 테두리
// width: fontExtnWdth,
// }),
fill: new Fill({
color: fontIntnColor,
width: fontExtnWdth,
@@ -0,0 +1,122 @@
// SearchInputText.tsx
"use client";
import { Input } from "@common/components/ui";
import { useEffect, useRef, useState } from "react";
interface SearchInputTextProps {
initValue?: string;
setValue: (val: string) => void;
setRtnData: (val: { fnm: string; fid: string }) => void;
searchList: { fnm: string; fid: string }[];
onEnter?: () => void;
className?: string;
}
export default function SearchInputText({
initValue,
setValue,
setRtnData,
searchList,
onEnter,
className,
}: SearchInputTextProps) {
const [showResults, setShowResults] = useState(false);
const [text, setText] = useState(initValue ?? "");
const [highlightIndex, setHighlightIndex] = useState(-1);
const wrapperRef = useRef(null);
useEffect(() => {
setText(initValue ?? "");
}, [initValue]);
useEffect(() => {
if (searchList.length > 0) {
setShowResults(true);
} else {
setShowResults(false);
}
setHighlightIndex(-1);
}, [searchList]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
wrapperRef.current &&
!(wrapperRef.current as any).contains(event.target)
) {
setShowResults(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [wrapperRef]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
setValue(e.target.value);
setHighlightIndex(-1);
};
const handleSelect = (item: { fnm: string; fid: string }) => {
setText(item.fnm);
setValue(item.fnm);
setRtnData(item);
setShowResults(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (highlightIndex >= 0 && highlightIndex < searchList.length) {
const selected = searchList[highlightIndex];
if (selected) handleSelect(selected);
} else if (onEnter) {
onEnter();
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((prev) =>
prev < searchList.length - 1 ? prev + 1 : prev
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : -1));
}
};
return (
<div className="relative" ref={wrapperRef}>
<Input
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="시설명을 입력하세요"
className={
className + " rounded-lg border px-2 py-1 text-sm h-[29.5px]"
}
/>
{showResults && (
<div className="absolute z-10 mt-1 max-h-200 w-full overflow-auto rounded border bg-white shadow">
{searchList.length > 0 ? (
searchList.map((item, index) => (
<div
key={index}
className={`cursor-pointer px-3 py-1 hover:bg-gray-100 ${
highlightIndex === index ? "bg-blue-100" : ""
}`}
onClick={() => handleSelect(item)}
>
{item.fnm}
</div>
))
) : (
<div className="px-3 py-2 text-sm text-gray-500">
검색 결과가 없습니다.
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,3 @@
export * from "./comboSearchBar";
export * from "./procSearchBar";
export * from './searchBar'
@@ -0,0 +1,344 @@
"use client";
import { SelectBox, type SelectDataType } from "../../../ui";
import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import SearchInputText from "./SearchInpuText";
import GisApp from "../lib/gis.core";
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt:
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_il_swage_prcs_fclt&outputFormat=application/json&propertyName=swtrmt_fclt_cd,swtrmt_fclt_nm&cql_filter=",
tb_wr_bdst_ruse_fclt:
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000/",
tb_wr_rnwtr_utztn_fclt:
"/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}/{fcltyNm}",
tb_wr_rswtr_fclt:
"/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}/{fcltyNm}",
tb_wr_htdfl_fclt:
"/reuse-service/drain/facilityList/1/1000/{sggCd}/{fcltyNm}",
tb_wr_ruse_fclt_dmd_co:
"/reuse-service/reuseFc/request/1/1000/{sggCd}/{fcltyNm}",
tb_wr_tmpr_dmd_co:
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}/{fcltyNm}",
};
interface SearchBarProps {
gisMaps: React.RefObject<any>;
facilityTypes: { text: string; val: string }[];
onFeatureFocus: any;
}
export const SearchBar = ({
gisMaps,
facilityTypes,
onFeatureFocus,
}: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
);
const [fcltyNm, setFcltyNm] = useState("");
const [searchList, setSearchList] = useState<{ fnm: string; fid: string }[]>(
[]
);
const [sidoData, setSidoData] = useState<SelectDataType[]>([
{ text: "시도", val: "0" },
]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const fetchSido = async () => {
const res = await fetch("/cms-service/api/v1/code/cache/ADSTDSTRCD/Y");
const json = await res.json();
const sidoList = json
.filter((item: any) => item.lwpoCd?.length === 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }));
const sigunList = json
.filter((item: any) => item.lwpoCd?.length > 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }));
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
setSigunData(sigunList);
};
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setFilteredSigunData([{ text: "시도", val: "0" }]);
setSigunValue("0");
return;
}
const prefix = sidoCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData]
);
useEffect(() => {
fetchSido();
}, []);
const onSelectValue = useCallback(
(val: string, type: string) => {
if (type === "ctpvCd") {
setSidoValue(val);
initializeSigunData(val);
setSearchList([]);
setFcltyNm("");
} else if (type === "sggCd") {
setSigunValue(val);
setSearchList([]);
setFcltyNm("");
} else if (type === "facilityType") {
setFacilityType(val);
setSearchList([]);
setFcltyNm("");
}
},
[initializeSigunData]
);
const handleSearch = async () => {
if (!fcltyNm) return alert("시설명을 입력하세요.");
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
const type = facilityType;
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
if (!baseUrlTemplate) return;
let url = baseUrlTemplate;
const year = new Date().getFullYear().toString();
url = url
.replace("{year}", year)
.replace("{sggCd}", sigunValue)
.replace("{sidoNm}", encodeURIComponent(sidoNm))
.replace("{sggNm}", encodeURIComponent(sggNm))
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
let list: SelectDataType[] = [];
if (type === "tb_il_swage_prcs_fclt") {
const cqlFilter = `ctpv_nm='${sidoNm}' and sgg_nm='${sggNm}' and swtrmt_fclt_nm like '%${fcltyNm}%'`;
url += encodeURIComponent(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,
}));
} else if (
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
) {
//재이용에서 참조하는 하수처리시설 목록
const procRes = await fetch("/reuse-service/reuseFc/pubFc");
const procJson = await procRes.json();
const procData = procJson.data;
const procResult = procData
.filter((code: string) => code.slice(0, 5) === sigunValue)
.map((code: string) => {
return code;
});
//처리시설명으로 처리시설코드 조회
let procUrl2 =
"/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
procUrl2 = procUrl2
.replace("{sggCd}", sigunValue)
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
const procRes2 = await fetch(procUrl2, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(procResult),
});
const procJson2 = await procRes2.json();
const procResult2 = (procJson2.data || []).map((item: any) => item.fid);
//최종 처리시설코드로 재이용시설조회
const res = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(procResult2),
});
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" //잠재수요처
) {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.fctryMngSn,
}));
} else {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.bdstNm ?? "(이름없음)",
val: item.bdstInfoSn,
}));
}
if (list.length === 0) {
alert("검색 결과가 없습니다.");
setSearchList([]);
setFcltyNm("");
return;
}
const newList = list.map((item: any) => {
const code = item.val;
const name = item.text;
return { fnm: name, fid: code };
});
setSearchList(newList);
};
//해당 시설물로 이동
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;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
}
});
// 3. 피처 선택
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 {
console.warn("geometry를 찾을 수 없습니다:", data);
}
} else {
console.warn("Feature를 찾을 수 없습니다:", data);
}
onFeatureFocus(foundFeature);
};
return (
<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">
{facilityTypes.length > 1 && (
<SelectBox
labelClass="w-[120px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={[
{ text: "시설 구분", val: "0" },
...facilityTypes,
]}
onSelectValue={(val:any) =>
onSelectValue(val.toString(), "facilityType")
}
value={facilityType}
/>
)}
<SelectBox
labelClass="w-[40px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={sidoData}
onSelectValue={(val:any) => onSelectValue(val.toString(), "ctpvCd")}
value={sidoValue}
/>
<SelectBox
labelClass="w-[65px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={filteredSigunData}
onSelectValue={(val:any) => onSelectValue(val.toString(), "sggCd")}
value={sigunValue}
/>
<SearchInputText
initValue={fcltyNm}
setValue={(val: string = "") => {
setFcltyNm(val);
}}
setRtnData={(selected: { fnm: string; fid: string }) => {
// 선택된 항목이 있을 경우 onFeatureSelect 실행
const type = facilityType;
handleFeatureFocus({
layerNm: type,
...selected,
});
}}
searchList={searchList}
onEnter={handleSearch}
className="mr-5 w-[150px]"
/>
</div>
<div className="flex flex-row space-x-2">
<Button
color="primary"
className="mx-2 mt-1"
size="sm"
onClick={handleSearch}
>
조회
</Button>
</div>
</div>
</div>
</div>
);
}