From 26f2c60f4cac0194767d65bc319270d162dcf064 Mon Sep 17 00:00:00 2001 From: jsyang Date: Fri, 29 Aug 2025 14:59:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EB=B0=94=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20-=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20NaN?= =?UTF-8?q?=EC=9D=B4=EA=B1=B0=EB=82=98=20null=EC=9D=B8=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=ED=88=B4=ED=8C=81=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20-=20NULL=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=200=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=9D=BC=EC=9D=B8=EC=9D=84=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=EB=8B=A4=EC=9D=8C=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EA=B7=B8=EB=A6=AC=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/business_components/src/ui/chart/LineBarChart.tsx | 2 +- packages/common/business_components/src/ui/chart/LineChart.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/common/business_components/src/ui/chart/LineBarChart.tsx b/packages/common/business_components/src/ui/chart/LineBarChart.tsx index 2a2ba2b1c..a7319cb01 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart.tsx @@ -165,7 +165,7 @@ export default function LineBarchart({ {chartTitle} ) : null} -
+

{label}

- {payload.map((item, index) => ( + {payload.filter(item => item.value != null && String(item.value) != 'NaN' && item.value != undefined).map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
@@ -267,6 +267,7 @@ export default function Linechart({ type="linear" strokeWidth={2} dot={false} + connectNulls={true} name={String(config?.label || "X축")} stroke={config?.color} /> From 2a0f5dce25764b84a149101e8c4dec4d610e6668 Mon Sep 17 00:00:00 2001 From: swcho Date: Mon, 1 Sep 2025 13:07:46 +0900 Subject: [PATCH 2/5] =?UTF-8?q?remove:=20=EC=83=81=EB=A5=98=EA=B4=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EA=B8=B0=20=ED=95=A8=EC=88=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 6d4d7d15c..de5f8f8bf 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -29,75 +29,6 @@ export const GisContext = createContext({ checkedLayerIds: null, }); export const useGisContext = () => useContext(GisContext); -export const searchUpperPpls = async (fcltMngNo) => { - const BASE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || ""; - const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, { - method: "GET", - }); - - let data = null; - if (res.ok) { - try { - // 응답이 비어있는지 확인 - const contentLength = res.headers.get("content-length"); - if (contentLength === "0") - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - - // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 - const text = await res.text(); - if (!text || text.trim() === "") - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - - // JSON 파싱 시도 - data = JSON.parse(text); - } catch (error) { - console.error("JSON parsing error:", error); - alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - } - } - - const select = GisApp.Module.select; - const gisMap = GisApp.Module.core?.map; - let foundFeature = null; - - data.forEach((item) => { - const pplMngNo = item.ppl_mng_no.trim(); - - gisMap - .getLayers() - .getArray() - .filter((e) => e.get("lyrTyNm") == "tb_ms_swage_ppl") - .forEach((layer) => { - const source = layer.getSource?.(); - if (source?.getFeatures) { - const features = source.getFeatures(); - for (const f of features) { - if (f.values_.fclt_mng_no.trim() === pplMngNo) { - foundFeature = f; - break; - } - } - } - }); - - if (foundFeature) select.selectMultiFeature(foundFeature, true); - }); - - if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); - - let geometry = foundFeature?.getGeometry(); - if (geometry) { - let extent = geometry.getExtent(); - - gisMap.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 14, - constrainResolution: true, - nearest: true, - }); - } -}; function Gis({ isOpen, //우측 상세 레이어 팝업 From 01df37d0b3c934dd3f0598f4d5a481dbb1019667 Mon Sep 17 00:00:00 2001 From: jsyang Date: Mon, 1 Sep 2025 17:01:18 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20config=20color=EC=97=90=20dash?= =?UTF-8?q?=EB=B6=99=EC=9D=84=EC=8B=9C=20=ED=95=B4=EB=8B=B9=EC=83=89?= =?UTF-8?q?=EC=83=81=EC=9D=98=20=EB=8C=80=EC=8B=9C=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/business_components/src/ui/chart/LineChart.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/common/business_components/src/ui/chart/LineChart.tsx b/packages/common/business_components/src/ui/chart/LineChart.tsx index fa145b7ed..6fa3c4f64 100644 --- a/packages/common/business_components/src/ui/chart/LineChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineChart.tsx @@ -69,7 +69,7 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?:
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${item.value}`} + {` ${item.name} : ${item.value.toString().replace(',','')}`}
))}
@@ -269,7 +269,8 @@ export default function Linechart({ dot={false} connectNulls={true} name={String(config?.label || "X축")} - stroke={config?.color} + stroke={config?.color?.includes('dash')?config?.color?.replace('dash',''):config?.color} + strokeDasharray={config?.color?.includes("dash")?"4 4":undefined} /> ); })} From 621d22279981b48c0d96c38793161b7e00987544 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 10:55:16 +0900 Subject: [PATCH 4/5] =?UTF-8?q?remove:=20GisTree,=20Gis=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20orderedLayers=20porps=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/Gis.js | 2 - .../business_components/src/ui/map/GisTree.js | 860 +++++++++--------- 2 files changed, 439 insertions(+), 423 deletions(-) diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index de5f8f8bf..1b6bbc1f3 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -51,7 +51,6 @@ function Gis({ isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], - orderedLayers = [], checkInterval = 3000, height: height = "h-[calc(100vh-5rem)]", onOpenPopup = (data) => { }, @@ -305,7 +304,6 @@ function Gis({ isPlaying={isPlaying} setIsPlaying={setIsPlaying} targetLayerTypes={targetLayerTypes} - orderedLayers={orderedLayers} checkInterval={checkInterval} onProgressChange={handleProgressChange} /> diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 7285caa07..02dae2a02 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -3,491 +3,509 @@ import { Tree, 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({ +} 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 currentAnimationIndexRef = useRef(0); 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); - + 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 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); + 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); } - } else { - checkedIds = checkedIds.filter((id) => id !== parent); - } - return updateParentCheckState(parent, all, checkedIds); + 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 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)); + 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); - } + 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); - } + 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) - ); - } 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; + 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; }); - }, - [nodes] + }; + + // ===================== + // 외부 호출용: 특정 노드 강제 체크/해제 + // ===================== + + /** + * 특정 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 target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm)); + + // drawOrdr 값을 기준으로 오름차순 정렬 + target.sort((a, b) => { + const aOrder = a.data?.drawOrdr || 0; + const bOrder = b.data?.drawOrdr || 0; + return aOrder - bOrder; + }); + return target; + }; + + 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); + } + }; + }, []); + // ===== 노드 세팅 ===== 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; + } }; - + 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 +}); From ede5ef6a213914364436ef82da7c049884fbfe87 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 11:06:37 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20GisTree.js=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/GisTree.js | 125 ++++++++++++++++-- 1 file changed, 111 insertions(+), 14 deletions(-) diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 02dae2a02..197827010 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -10,6 +10,24 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; import { useGisContext } from "./Gis"; import GisApp from "./lib/gis.core"; +/** + * GisTree (React memoized component) + * + * 계층형 GIS 레이어 트리를 렌더링하고, 체크 상태에 따라 지도상의 레이어 On/Off를 제어합니다. + * 또한 특정 순서/간격으로 레이어를 자동으로 토글하는 애니메이션 기능을 제공합니다. + * + * Props 요약 + * - checkedNodes, setCheckedNodes: (선택) 외부 관리 체크 상태 (현재 내부 state 사용) + * - visible, setVisible: 사이드 패널 가시성 on/off + * - onCheckedChange(checkedIds, checkedNodesData): 체크 변경 시 콜백 + * - onLayerIconClick(nodeNm): 각 노드의 미리보기/아이콘 클릭 시 콜백 + * - imageVisible: 레이어 옆 아이콘(썸네일) 노출 여부 + * - isPlaying, setIsPlaying: 자동 애니메이션 재생 상태 제어 + * - targetLayerTypes: 자동 애니메이션 대상이 되는 레이어 type 배열 (예: ['tb_ms_swage_mhl']) + * - orderedLayers: 레이어 재생 순서를 지정하는 id 배열 (lyrSn) + * - checkInterval: 자동 애니메이션 토글 간격(ms) + * - onProgressChange(total, current): 애니메이션 진행상태 콜백 + */ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, @@ -25,17 +43,29 @@ export default React.memo(function GisTree({ 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( @@ -44,40 +74,64 @@ export default React.memo(function GisTree({ ); }; + /** + * 주어진 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 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 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); - } + if (!checkedIds.includes(parent)) checkedIds.push(parent); } else { checkedIds = checkedIds.filter((id) => id !== parent); } return updateParentCheckState(parent, all, checkedIds); }; + /** + * 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") @@ -90,7 +144,13 @@ export default React.memo(function GisTree({ return Array.from(new Set(checkedIds)); }; - // ===== 초기 로딩 ===== + // ===================== + // 초기 로딩: 트리 데이터 세팅 & 초기 체크/확장 & 지도 반영 + // ===================== + + /** + * treeData가 들어오면 노드 변환 후, 모든 레이어를 지도에서 잠시 비활성화합니다. + */ useEffect(() => { if (treeData && treeData.length > 0) { const nodeData = setNodeValues(treeData); @@ -99,11 +159,15 @@ export default React.memo(function GisTree({ } }, [treeData]); + /** + * nodes가 준비되면 기본 체크/확장 상태를 계산하고, 실제 지도 레이어 on/off 반영 및 콜백 호출. + */ useEffect(() => { if (nodes.length > 0) { const defaultChecked = getDefaultCheckedNodes(nodes); setChecked(defaultChecked); + // 기본 확장 = 체크된 노드 + 그 조상들 const defaultExpanded = Array.from( new Set([ ...defaultChecked, @@ -112,6 +176,7 @@ export default React.memo(function GisTree({ ); setExpanded(defaultExpanded); + // 지도 반영 gisCore.setLayerOnMap(defaultChecked, true); const defaultCheckedNodesData = nodes.filter((node) => @@ -121,7 +186,16 @@ export default React.memo(function GisTree({ } }, [nodes]); - // ===== 체크 이벤트 ===== + // ===================== + // 체크 이벤트: 트리에서 체크 박스 클릭 시 처리 + // ===================== + + /** + * 단일 노드 체크/해제 처리 핸들러 + * - 체크 시: 자신 + 모든 자손 체크 + * - 해제 시: 자신 + 모든 자손 해제 + * - 이후 부모 상태 전파 및 지도 레이어 on/off 반영, 콜백 호출 + */ const handleCheck = (id) => { setChecked((prevChecked) => { let newChecked = [...prevChecked]; @@ -312,6 +386,9 @@ export default React.memo(function GisTree({ isPausedRef.current = true; }, []); + /** + * 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다. + */ useEffect(() => { if (isPlaying) handleAutoCheckAnimation(true, targetLayerTypes, checkInterval); @@ -324,15 +401,23 @@ export default React.memo(function GisTree({ pauseAnimation, ]); + /** + * 언마운트 시 타이머 정리. + */ useEffect(() => { return () => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - } + if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current); }; }, []); - // ===== 노드 세팅 ===== + // ===================== + // 노드/아이콘 렌더링 데이터 준비 + // ===================== + + /** + * 서버에서 내려온 트리 데이터를 Tree 컴포넌트가 요구하는 구조로 변환합니다. + * - id, text, parent, droppable, data, icon + */ const setNodeValues = (result) => { return result.map((item) => { const src = makeLegendSrc(item); @@ -347,7 +432,9 @@ export default React.memo(function GisTree({ }); }; - + /** + * 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다. + */ const makeLegendSrc = (layerinfo) => { if (!layerinfo.gobjTy) return null; if (layerinfo.iconNm) { @@ -357,6 +444,9 @@ export default React.memo(function GisTree({ } }; + /** + * 레이어 스타일 정보를 기반으로 작은 캔버스에 간단한 범례 이미지를 그려 dataURL을 생성합니다. + */ const makeLegendImage = (layerinfo) => { let image = document.createElement("img"); let canvas = document.createElement("canvas"); @@ -401,11 +491,18 @@ export default React.memo(function GisTree({ return image; }; + /** + * 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달. + */ const handleLayerImgClick = (nodeNm, nodeId) => { if (!checked.includes(nodeId)) alert("시설을 선택해주세요"); else onLayerIconClick(nodeNm); }; + // ===================== + // 렌더링 + // ===================== + if (!treeData || treeData.length === 0) return null; return (