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 ?
: "",
- };
+ const src = makeLegendSrc(item);
+ return {
+ id: item.lyrSn, // 반드시 고유값
+ text: item.lyrNm,
+ parent: item.upperLyrSn || null, // 루트는 null
+ droppable: true, // children 여부 모를 땐 true로 둬야 트리 확장 가능
+ data: item,
+ icon: src ?
: "",
+ };
});
- };
-
-
+ };
+
+ /**
+ * 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
+ */
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;