From 3641058fd0f9a6866ddac51c70792b547519c4a3 Mon Sep 17 00:00:00 2001 From: yschoi Date: Tue, 12 Aug 2025 17:26:26 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=ED=81=B4=EB=A6=AD=EC=8B=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../popupComponent/featureClickEventPopup.tsx | 1 - apps/sn/app/(map-layout)/map/page.tsx | 56 +- .../map/searchModal/searchModal.tsx | 23 +- apps/sn/package.json | 5 +- .../business_components/src/ui/map/Gis.js | 15 +- .../business_components/src/ui/map/GisTree.js | 985 ++++++++++-------- .../src/ui/map/lib/gis.core.js | 9 +- .../src/ui/map/lib/gis.layer.js | 152 ++- 8 files changed, 734 insertions(+), 512 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index fa252f4be..24f9a3f8d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -275,7 +275,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // API에서 상세 데이터 가져오기 if (apiEndpoint) { try { - console.log(`API 호출: ${apiEndpoint}`); const result = await getData(apiEndpoint); if (result) { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 200d0fb19..6e6997267 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -1,6 +1,6 @@ "use client"; -import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; +import Gis from "@common/business_components/ui/map/Gis"; import { getData } from "../../components/lib/api"; import { useCallback, useEffect, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; @@ -43,7 +43,6 @@ const Map = () => { const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 const [isPlaying, setIsPlaying] = useState(false); - useEffect(() => { fetchData(); }, []); @@ -96,6 +95,7 @@ const Map = () => { }); } + // 3. 피처 선택 if (foundFeature) { let geometry = foundFeature.getGeometry(); @@ -138,31 +138,33 @@ const Map = () => { return ( -
- } - waterDepth={showWaterDepth ? WaterDepth : undefined} - isPlaying={isPlaying} - setIsPlaying={setIsPlaying as any} - targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} - orderedLayers={ORDERED_LAYERS as any} - checkInterval={2500} - /> -
+ <> +
+ } + waterDepth={showWaterDepth ? WaterDepth : undefined} + isPlaying={isPlaying} + setIsPlaying={setIsPlaying as any} + targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} + orderedLayers={ORDERED_LAYERS as any} + checkInterval={2500} + /> +
+ ); }; diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 71e7c8855..e7f87105c 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -10,6 +10,7 @@ import OperationTab, { operationCleaningTableHeadlist, operationInspectionTableH import ReportTab from './reportTab'; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import useStore from '../../../store/useStore'; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; @@ -184,7 +185,6 @@ const SearchModal: React.FC = ({ size: '10', sort: 'string', }); - console.log('basicType', basicType); if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = zoneCqlFilters(); // 하수처리구역 else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = facilityCqlFilters(); // 하수처리시설 @@ -195,10 +195,7 @@ const SearchModal: React.FC = ({ if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); - console.log('API URL:', `${API_URL}/v1/fclts?${queryParams.toString()}`); const res = await axios.get(`${API_URL}/v1/fclts?${queryParams.toString()}`); - console.log('필터링된 API 결과(개수):', res.data.count); - console.log('필터링된 API 결과(데이터):', res.data.data); // 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가 const dataWithRowNumber = (res.data.data || []).map((item: any, index: number) => { @@ -217,7 +214,6 @@ const SearchModal: React.FC = ({ setTableData(dataWithRowNumber); setTableCount(res.data.count || []); - console.log('tableCount 상태 업데이트 후:', res.data.count); // tableCount 로그 추가 } else if (activeTab === 'operation') { const RequestDTO: Record = { @@ -247,13 +243,9 @@ const SearchModal: React.FC = ({ const params = new URLSearchParams(RequestDTO).toString(); - console.log('API URL:', `/v1/bizInfo?${params}`); const res = await getData(`v1/bizInfo?${params}`); - console.log('필터링된 API 결과(개수):', res.totalElements); - console.log('필터링된 API 결과(데이터):', res.content); const mappedContent = (res.content || []).map((item: any, index: number) => { - console.log("item:", item); const formattedItem = formatDisplayData(item); const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); return { @@ -456,7 +448,12 @@ const SearchModal: React.FC = ({ // 기본정보 테이블 cellClick 핸들러 const handleBasicCellClick = (index?: number | undefined, headId?: String) => { if (typeof index === "number") { - onFeatureSelect(tableData[index % 10]); // 10은 page size 하드 코딩한 숫자 + + const row = tableData[index]; + const layerNm = row.lyrTypeNm; + const fid = row.fid; + const core = GisApp.Module.layer as any; + const r = core.showSearchResultsFromGeoJSON(layerNm, fid); onOpenChange(false); // SearchModal 닫기 } }; @@ -480,10 +477,6 @@ const SearchModal: React.FC = ({ RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); - console.log('운영정보 cellClick 데이터:', res); - console.log('운영정보 cellClick.data:', res.data); - console.log('운영정보 length:', res.data.length); - const mappedData = (res.data || []).map((item: any, detailIndex: number) => { const formattedItem = formatDisplayData(item); @@ -497,8 +490,6 @@ const SearchModal: React.FC = ({ }; }); - console.log('운영정보 cellClick mappedData:', mappedData); - setDetailTableData(mappedData); setDetailTableCount(res.count || 0); } catch (error) { diff --git a/apps/sn/package.json b/apps/sn/package.json index 3dd7ac0c4..493a4302a 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -34,7 +34,10 @@ "tailwindcss-animate": "^1.0.7", "tus-js-client": "^4.3.1", "zod": "^3.24.2", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "ol": "^7.3.0", + "ol-ext": "^4.0.11", + "proj4": "^2.9.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 1f6bc148c..d70deabe1 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -21,6 +21,7 @@ import GisToolBar from "./GisToolBar"; import GisTree from "./GisTree"; import "./lib/css/gis.common.css"; import GisApp from "./lib/gis.core"; +import LoadingOverlay from '@/components/ui/LoadingBar' export const GisContext = createContext({ gisCore: null, @@ -149,6 +150,7 @@ function Gis({ // 체크된 레이어 상태 관리 const [checkedLayers, setCheckedLayers] = useState([]); const [checkedLayersData, setCheckedLayersData] = useState([]); + const [isLoading, setIsLoading] = useState(true) //로딩 상태 let gisMap = null; const closePopup = () => { @@ -216,12 +218,16 @@ function Gis({ }), }); } else { - // 📌 재마운트 시 target 없으면 다시 지정 + // 재마운트 시 target 없으면 다시 지정 if (!map.getTarget()) { map.setTarget(mapRef.current); } } + map.on('loadstart', function () { + setIsLoading(false) + }); + const select = GisApp.Module.select; if (select) { if (select.selectInteraction) { @@ -316,9 +322,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], @@ -332,6 +338,7 @@ function Gis({ return ( <> + {}, + 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"); - + + // 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머) const currentAnimationIndexRef = useRef(0); const targetNodesRef = useRef([]); const isPausedRef = useRef(false); const timeoutIdRef = useRef(null); - - // ===== 유틸 ===== + + // ===================== + // 유틸리티: 트리 탐색 관련 함수들 + // ===================== + + /** + * 주어진 id의 모든 하위(자손) 노드 id를 평탄 배열로 반환합니다. + * @param {number|string} id 기준 노드 id + * @param {Array} all 전체 노드 배열 + * @returns {Array} 자손 노드 id 배열 + */ 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)), + [] + ); }; - + + /** + * 주어진 id의 모든 상위(조상) 노드 id를 상단부터 배열로 반환합니다. + * @param {number|string} id 기준 노드 id + * @param {Array} all 전체 노드 배열 + * @returns {Array} 조상 노드 id 배열 + */ 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); - + + /** + * 특정 parentId의 직계 자식 노드 리스트를 반환합니다. + */ + const getChildren = (parentId, all) => all.filter((n) => n.parent === parentId); + + /** + * parentId의 모든 직계 자식이 checkedIds에 포함되어 있는지 확인합니다. + */ const areAllChildrenChecked = (parentId, all, checkedIds) => { - const children = getChildren(parentId, all); - return children.length > 0 && children.every((c) => checkedIds.includes(c.id)); + 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 = getChildren(parentId, all); - return children.length > 0 && children.every((c) => checked.includes(c.id)); - }; - - // ===== 기본 체크 노드 ===== - 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)); - }; - - // ===== 초기 로딩 ===== - 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 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) - ); + 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 { - const descendants = getDescendants(id, nodes); - newChecked = Array.from(new Set([...newChecked, id, ...descendants])); + checkedIds = checkedIds.filter((id) => id !== parent); } - - 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; - }); + return updateParentCheckState(parent, all, checkedIds); }; - - // ===== 특정 노드 체크/해제 ===== - 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; + + /** + * parentId의 모든 직계 자식이 checked 배열에 포함되어 있는지 확인합니다. + */ + const isEveryChildChecked = (parentId, all, checked) => { + const children = getChildren(parentId, all); + return children.length > 0 && children.every((c) => checked.includes(c.id)); + }; + + // ===================== + // 기본 체크 노드 계산 + // ===================== + + /** + * 초기 렌더 시 기본으로 체크할 노드 id 목록을 반환합니다. + * - data.initEprssYn === 'Y' 인 노드 + 부모 전파 + */ + 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); }); - }, - [nodes] + + return Array.from(new Set(checkedIds)); + }; + + // ===================== + // 초기 로딩: 트리 데이터 세팅 & 초기 체크/확장 & 지도 반영 + // ===================== + + /** + * treeData가 들어오면 노드 변환 후, 모든 레이어를 지도에서 잠시 비활성화합니다. + */ + useEffect(() => { + if (treeData && treeData.length > 0) { + const nodeData = setNodeValues(treeData); + setNodes(nodeData); + gisCore.setAllLayersOnMap(false); + } + }, [treeData]); + + /** + * nodes가 준비되면 기본 체크/확장 상태를 계산하고, 실제 지도 레이어 on/off 반영 및 콜백 호출. + */ + 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]); + + // ===================== + // 체크 이벤트: 트리에서 체크 박스 클릭 시 처리 + // ===================== + + /** + * 단일 노드 체크/해제 처리 핸들러 + * - 체크 시: 자신 + 모든 자손 체크 + * - 해제 시: 자신 + 모든 자손 해제 + * - 이후 부모 상태 전파 및 지도 레이어 on/off 반영, 콜백 호출 + */ + 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; + }); + }; + + // ===================== + // 외부 호출용: 특정 노드 강제 체크/해제 + // ===================== + + /** + * 특정 nodeId를 강제로 체크/해제합니다. + * @param {number|string} nodeId 대상 노드 id + * @param {boolean} shouldCheck true면 체크, false면 해제 + * + * 내부적으로 handleCheck와 동일한 부모 전파/지도 반영/콜백 과정을 수행합니다. + */ + 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] ); - - // ===== 자동 애니메이션 ===== + + // ===================== + // 자동 애니메이션: 지정된 순서/간격으로 노드 체크 상태를 순차 변경 + // ===================== + + /** + * 레이어 자동 토글 애니메이션을 시작/중지/초기화합니다. + * @param {boolean} isEnabled 재생 여부 + * @param {string[]} layerTypes 대상 레이어 타입 목록 (node.data.lyrTyNm 기준) + * @param {number} interval 토글 간격(ms) + */ 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); + (isEnabled, layerTypes = [], interval = 3000) => { + // 기존 타이머 정리 + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; } - }); - 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); + + // 중지/초기화 경로 + if (!isEnabled || layerTypes.length === 0) { + isPausedRef.current = false; + targetNodesRef.current = []; + currentAnimationIndexRef.current = 0; + onProgressChange(0, 0); + return; } - - currentAnimationIndexRef.current++; - onProgressChange( - currentTargetNodes.length, - currentAnimationIndexRef.current - ); - timeoutIdRef.current = setTimeout(processNextLayer, interval); - } else { + + // orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별 + 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; - targetNodesRef.current = []; - currentAnimationIndexRef.current = 0; - timeoutIdRef.current = null; - onProgressChange( - currentTargetNodes.length, - currentTargetNodes.length - ); - setTimeout(() => { - onProgressChange(0, 0); - }, 1000); - setIsPlaying?.(false); - } - }; - - processNextLayer(); - }, - [nodes, checkSpecificNode, setIsPlaying, isPlaying, onProgressChange] + + // 다음 레이어로 진행하는 내부 함수 + const processNextLayer = () => { + if (isPausedRef.current || !isPlaying) return; // 일시정지/정지 처리 + + const currentTargetNodes = targetNodesRef.current; + const currentIndex = currentAnimationIndexRef.current; + + if (currentIndex < currentTargetNodes.length) { + // 현재 타겟 on, 직전 타겟 off + 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; + timeoutIdRef.current = null; + 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; - }, []); - - useEffect(() => { - if (isPlaying) - handleAutoCheckAnimation(true, targetLayerTypes, checkInterval); - else pauseAnimation(); - }, [ - isPlaying, - handleAutoCheckAnimation, - targetLayerTypes, - checkInterval, - pauseAnimation, - ]); - - useEffect(() => { - return () => { if (timeoutIdRef.current) { - clearTimeout(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); + }; + }, []); + + // ===================== + // 노드/아이콘 렌더링 데이터 준비 + // ===================== + + /** + * 서버에서 내려온 트리 데이터를 Tree 컴포넌트가 요구하는 구조로 변환합니다. + * - id, text, parent, droppable, data, icon + */ 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 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; - } + if (!layerinfo.gobjTy) return null; + if (layerinfo.iconNm) { + return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm; + } else { + return makeLegendImage(layerinfo).src; + } }; - + + /** + * 레이어 스타일 정보를 기반으로 작은 캔버스에 간단한 범례 이미지를 그려 dataURL을 생성합니다. + */ 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; + 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 (!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 ? ( - + {/* 사이드 패널 */} +
+
+

시설물

+
+ {/* DnD Tree */} + + { + 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; + }} /> - ) : ( - - )} - 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 && ( - - )} - +
+
+ +
+ {!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 5a6e705da..ad3db214a 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 @@ -650,7 +650,7 @@ GisApp.core.prototype = { */ getFeatureGeomProp: function (geometry, ftrIds, ftrCde) { let returnFeature; - this.map.getLayers().forEach(function (layer) { + this.map.getLayers().getArray().some(function (layer) { let type = layer.get('type'); if (type != 'base') { let source = layer.getSource(); @@ -673,6 +673,7 @@ GisApp.core.prototype = { featureFtrCde = properties['FTR_CDE']; if (ftrIds == featureFtrIds && ftrCde == featureFtrCde) { returnFeature = feature; + return true; } } } @@ -685,7 +686,7 @@ GisApp.core.prototype = { */ getFeatureByFtrIdn: function (typeName, ftrIdn) { let returnFeature; - this.map.getLayers().forEach(function (layer) { + this.map.getLayers().getArray().some(function (layer) { let layerTypeName = layer.get('typeName'); if (layerTypeName === typeName) { layer @@ -695,12 +696,16 @@ GisApp.core.prototype = { let featureFtrIdn = feature.get('ftrIdn'); if (featureFtrIdn === ftrIdn) { returnFeature = feature; + return true; } }); } }); return returnFeature; }, + getFeatureByFeatureId: function (featureId) { + return GisApp.Module.layer.getFeatureById(featureId); + }, /** * [사용]맵 이벤트 추가 */ diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js index 6cbc054f8..dc7b1f4c2 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.layer.js +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -33,6 +33,7 @@ const GisOlLayer = function (options) { GisOlLayer.prototype = { layers: [], cesiumLayers: [], + searchLayer: null, defaults: {}, storageNm: 'hasudo:', defaultLayerOptions: { @@ -76,7 +77,7 @@ GisOlLayer.prototype = { let toProj = GisApp.TO_PROJ; let lyrTyNm = options.lyrTyNm; let typename = this.storageNm + lyrTyNm; - + const params = {}; params['TYPENAME'] = typename; params['SERVICE'] = 'WFS'; @@ -92,11 +93,11 @@ GisOlLayer.prototype = { const [minX, minY, maxX, maxY] = extent; const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${toProj}')`; const filter = options?.flterSql && options.flterSql && options.flterSql.trim() !== '' - ? `(${options.flterSql}) AND ${bboxFilter}` - : bboxFilter; + ? `(${options.flterSql}) AND ${bboxFilter}` + : bboxFilter; params['CQL_FILTER'] = filter; - + const strParams = new URLSearchParams(params).toString(); return url + '?' + strParams; }, @@ -107,13 +108,13 @@ GisOlLayer.prototype = { /** * [사용]WFS 벡터 소스 반환 */ - getVectorTileSource: function (options) { + getVectorSourceAll: function (options) { let url = GisApp.GEOSERVER_URL; let toProj = GisApp.TO_PROJ; let lyrTyNm = options.lyrTyNm; let typename = this.storageNm + lyrTyNm; let filter = options?.flterSql && options.flterSql && options.flterSql.trim() !== ''; - + let params = {}; params['TYPENAME'] = typename; params['SERVICE'] = 'WFS'; @@ -150,12 +151,12 @@ GisOlLayer.prototype = { let cmncNm = option.cmncNm; try { - if(cmncNm == 'bbox') { + if (cmncNm == 'bbox') { source = this.getVectorSourceBbox(option); - }else { - source = this.getVectorTileSource(option); + } else { + source = this.getVectorSourceAll(option); } - }catch(error){ + } catch (error) { console.warn(option); } @@ -548,7 +549,7 @@ GisOlLayer.prototype = { cesiumLayer['properties'] = properties; me.cesiumLayers.push(cesiumLayer); if (properties?.visible) { - const zoomLevel = getOpenLayersZoom(cesiumScene); + const zoomLevel = this.getOpenLayersZoom(cesiumScene); cesiumLayer.show = zoomLevel >= Number(properties?.minZoom); } else { cesiumLayer.show = false; @@ -557,7 +558,7 @@ GisOlLayer.prototype = { }); if (me.cesiumLayers.length > 0) { cesiumScene._moveEndListener = () => { - const zoomLevel = getOpenLayersZoom(cesiumScene); // 대략적인 줌 계산 + const zoomLevel = this.getOpenLayersZoom(cesiumScene); // 대략적인 줌 계산 me.cesiumLayers.forEach(cesiumLayer => { const properties = cesiumLayer.properties; if (properties?.visible) { @@ -569,22 +570,121 @@ GisOlLayer.prototype = { cesiumScene.camera.moveEnd.addEventListener(cesiumScene._moveEndListener); } }, - getFeatureById: function(featureId){ - this.layers - .getArray() - .forEach(layer => { - const source = layer.getSource(); - const feature = source.getFeatureById(String(featureId).trim()); - if (feature) return feature; - }); + getFeatureById: function (featureId) { + let returnFeature; + this.layers.getArray().some(function (layer) { + const source = layer.getSource(); + + if (source instanceof VectorSource) { + const feature = source.getFeatureById(featureId); + if (feature) { + returnFeature = feature; + return true; + } + } + }); + + return returnFeature; + }, + getOpenLayersZoom: function (cesiumScene) { + const cameraHeight = cesiumScene.camera.positionCartographic.height; + const maxResolution = 156543.03392804097; // OpenLayers 기본 해상도 (Google Mercator 기준) + const tileSize = 256; // 기본 타일 크기 + return Math.ceil(Math.log2(maxResolution / (cameraHeight / tileSize))); + }, + ensureSearchLayer: function () { + let layer = this.searchLayer; + + if (!layer) { + layer = new VectorLayer({ + source: new VectorSource(), + zIndex: 9999 + }); + let map = GisApp.Module.core.map; + map.addLayer(layer); + } + return layer; + }, + clearSearchLayer: function () { + const layer = this.ensureSearchLayer(); + layer.getSource().clear(true); + }, + showSearchResultsFromGeoJSON: function (lyrTyNm, featureId) { + const layer = this.ensureSearchLayer(); + + // 이전 검색결과는 깨끗이 + const prev = layer.getSource(); + if (prev) prev.clear(true); + + //레이어 타입 설정(point, line 등. 이걸 style에서 사용) + const layerCode = GisApp.layerCode; + const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm); + layer.set("gobjTy", matched.gobjTy); + + // 새 소스 만들기 + const source = this.getFeatureSourceFromGeoserver(lyrTyNm, featureId); + + // 1) 로드 완료시 extent 맞추기 + const onLoadEnd = () => { + const feats = source.getFeatures(); + if (feats && feats.length > 0) { + const select = GisApp.Module.select; + select.selectFeature(feats[0], false); + } else { + console.warn('[search-layer] GeoJSON에 피처가 없습니다.'); + } + // 한번만 쓰고 이벤트 정리 + source.un('featuresloadend', onLoadEnd); + source.un('featuresloaderror', onLoadError); + }; + + const onLoadError = (e) => { + console.error('[search-layer] GeoJSON 로드 실패:', e); + source.un('featuresloadend', onLoadEnd); + source.un('featuresloaderror', onLoadError); + }; + + // 이벤트를 먼저 걸고 → setSource + source.once('featuresloadend', onLoadEnd); + source.once('featuresloaderror', onLoadError); + + // 레이어에 소스 적용(이때부터 로딩 시작) + layer.setSource(source); + + // 필요시 명시적으로 재요청 + source.refresh(); + }, + /** + * 피쳐 ID로 지오서버에서 검색해서 피처의 벡터소스를 리턴 + * @param {} lyrTyNm + * @param {*} featureId + * @returns + */ + getFeatureSourceFromGeoserver: function (lyrTyNm, featureId) { + + const typeName = this.storageNm + lyrTyNm; + const srsName = GisApp.TO_PROJ; + const baseUrl = GisApp.GEOSERVER_URL; + const source = new VectorSource({ + format: new GeoJSON(), + url: () => { + const params = new URLSearchParams({ + SERVICE: 'WFS', + VERSION: '1.1.0', + REQUEST: 'GetFeature', + TYPENAME: typeName, + SRSNAME: srsName, + OUTPUTFORMAT: 'application/json', + FEATUREID: featureId, + }); + return `${baseUrl}?${params.toString()}`; + }, + strategy: all, + }); + + return source; } }; -function getOpenLayersZoom(cesiumScene) { - const cameraHeight = cesiumScene.camera.positionCartographic.height; - const maxResolution = 156543.03392804097; // OpenLayers 기본 해상도 (Google Mercator 기준) - const tileSize = 256; // 기본 타일 크기 - return Math.ceil(Math.log2(maxResolution / (cameraHeight / tileSize))); -} export default GisOlLayer;