From f644bd6b7e657ceda1afdf693e7ddf1d2a861b15 Mon Sep 17 00:00:00 2001 From: swcho Date: Sat, 9 Aug 2025 23:43:37 +0900 Subject: [PATCH] =?UTF-8?q?refactor,=20remove,=20add:=20GIS=20main=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=EC=99=80=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 118 ++- .../src/ui/map/GisContent.js | 121 ++- .../src/ui/map/GisSelect.js | 70 +- .../business_components/src/ui/map/GisTab.js | 84 -- .../business_components/src/ui/map/GisTree.js | 871 +++++++++--------- .../src/ui/map/lib/gis.core.js | 21 +- .../src/ui/map/lib/gis.style.js | 22 +- .../src/ui/map/searchBar/SearchInpuText.tsx | 122 +++ .../src/ui/map/searchBar/index.tsx | 3 + .../src/ui/map/searchBar/searchBar.tsx | 344 +++++++ 10 files changed, 1115 insertions(+), 661 deletions(-) delete mode 100644 packages/common/business_components/src/ui/map/GisTab.js create mode 100644 packages/common/business_components/src/ui/map/searchBar/SearchInpuText.tsx create mode 100644 packages/common/business_components/src/ui/map/searchBar/index.tsx create mode 100644 packages/common/business_components/src/ui/map/searchBar/searchBar.tsx diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index bce7b2ef0..3d810b48d 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -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 && ( -
- +
+ {SearchBar}
)} diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js index d2594ba95..45b44c882 100644 --- a/packages/common/business_components/src/ui/map/GisContent.js +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -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 ( + <> + + + + + - return ( - <> - {/**/} - {/* - - */} - {/* */} - - {/* 검색 모달 */} - {isSearch && ( -
- -
- )} - - ); + {/* 검색 모달 */} + {isSearch && ( +
+ +
+ )} + + ); } diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index e9deb72f7..d7d5b5b72 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -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 ( - <> - {/*
+ return ( + <> + {/*

선,면,다각형을 이용한 시설물 선택 영역

  • @@ -55,6 +51,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
*/} - - ); + + ); }); diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js deleted file mode 100644 index 57d8a48e8..000000000 --- a/packages/common/business_components/src/ui/map/GisTab.js +++ /dev/null @@ -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 ( -// <> -//
-// -// -// 레이어 -// {/* {isSearch && ( -// -// // -// // -// // -// )} */} -// - -// {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} -//
-// -//
- -// {/* GisSearch도 동일하게 처리 */} -// {/* {isSearch && ( -//
-// -//
-// )} */} - - -// {/* 🔍 검색 모달 */} -// {/* */} -//
- -// {/* 접기 버튼 */} -// -//
- -// {/* 열기 버튼 */} -// {!visible && ( -// -// )} -// -// ); -// } - -// export default React.memo(GisTab); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index ed6bb00fc..7285caa07 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -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 ? {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 ( - <> -
-
-

레이어

-
- - ( -
- {node.droppable && ( - - - )} - handleCheck(node.id)} className="cursor-pointer" /> - {node.icon && {node.icon}} - {node.text} - {imageVisible ? depth > 0 && { handleLayerImgClick(node.text, node.id, node.data.lyrTyNm) }} alt={node.text}> : null} -
- )} - sort={(a, b) => { - const aOrder = parseInt(a.data?.sortOrdr ?? '0'); - const bOrder = parseInt(b.data?.sortOrdr ?? '0'); - return aOrder - bOrder; - }} - /> -
-
- {/* 접기 버튼 */} - -
- {/* 열기 버튼 */} - {!visible && ( - - )} - + + 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 ? {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 ( + <> +
+
+

레이어

+
+ + { + const isVisible = node.data.exprsYn === "Y"; + const hasVisibleChildren = nodes.some( + (child) => + child.parent === node.id && child.data.exprsYn === "Y" + ); + + return ( +
+ {hasVisibleChildren ? ( + + ) : ( + + )} + handleCheck(node.id)} + className="cursor-pointer" + /> + {node.icon && {node.icon}} + + {node.text} + + {imageVisible && depth > 0 && ( + + + handleLayerImgClick( + node.data.lyrTyNm, + node.id + ) + } + alt={node.text} + /> + + )} +
+ ); + }} + sort={(a, b) => { + const aOrder = parseInt(a.data?.sortOrdr ?? "0"); + const bOrder = parseInt(b.data?.sortOrdr ?? "0"); + return aOrder - bOrder; + }} + /> +
+
+ +
+ {!visible && ( + + )} + + ); + }); + \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js index a5268ac44..5a6e705da 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.core.js +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -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) { diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js index 8cff08dca..f38853962 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.style.js +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -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, diff --git a/packages/common/business_components/src/ui/map/searchBar/SearchInpuText.tsx b/packages/common/business_components/src/ui/map/searchBar/SearchInpuText.tsx new file mode 100644 index 000000000..97ba51fb7 --- /dev/null +++ b/packages/common/business_components/src/ui/map/searchBar/SearchInpuText.tsx @@ -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) => { + 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) => { + 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 ( +
+ + {showResults && ( +
+ {searchList.length > 0 ? ( + searchList.map((item, index) => ( +
handleSelect(item)} + > + {item.fnm} +
+ )) + ) : ( +
+ 검색 결과가 없습니다. +
+ )} +
+ )} +
+ ); +} diff --git a/packages/common/business_components/src/ui/map/searchBar/index.tsx b/packages/common/business_components/src/ui/map/searchBar/index.tsx new file mode 100644 index 000000000..4ba24de3a --- /dev/null +++ b/packages/common/business_components/src/ui/map/searchBar/index.tsx @@ -0,0 +1,3 @@ +export * from "./comboSearchBar"; +export * from "./procSearchBar"; +export * from './searchBar' diff --git a/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx new file mode 100644 index 000000000..edd2853f2 --- /dev/null +++ b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx @@ -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 = { + 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; + 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([ + { text: "시도", val: "0" }, + ]); + const [sigunData, setSigunData] = useState([ + { text: "시군구", val: "0" }, + ]); + const [filteredSigunData, setFilteredSigunData] = useState([ + { 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 ( +
+
+
+
+ {facilityTypes.length > 1 && ( + + onSelectValue(val.toString(), "facilityType") + } + value={facilityType} + /> + )} + onSelectValue(val.toString(), "ctpvCd")} + value={sidoValue} + /> + onSelectValue(val.toString(), "sggCd")} + value={sigunValue} + /> + { + 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]" + /> +
+
+ +
+
+
+
+ ); +}