From 01df37d0b3c934dd3f0598f4d5a481dbb1019667 Mon Sep 17 00:00:00 2001 From: jsyang Date: Mon, 1 Sep 2025 17:01:18 +0900 Subject: [PATCH 1/4] =?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 2/4] =?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 59bd8eddfb0f3c834dd4b60c9d79d22bf940256c Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 10:57:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?remove:=20waterDepth.tsx=EC=97=90=EC=84=9C?= =?UTF-8?q?=20orderedLayers=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 3 +-- apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx | 10 ---------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 6e6997267..e1bbc45ad 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import SearchModal from "./searchModal/searchModal"; import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; -import { WaterDepth, WATER_DEPTH_LAYER_TYPES, ORDERED_LAYERS } from "./waterDepth/waterDepth"; +import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; // 타입 정의 추가 @@ -160,7 +160,6 @@ const Map = () => { 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/waterDepth/waterDepth.tsx b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx index 78849bda2..a1434d895 100644 --- a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx +++ b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx @@ -7,16 +7,6 @@ export const WATER_DEPTH_LAYER_TYPES: string[] = [ 'tb_ms_flood_dngr_map', 'tb_ms_sbmrg_dngr_map' ]; -// 지정된 순서에 따른 lyr_sn 배열 -export const ORDERED_LAYERS: string[] = [ - '27', // 침수위험지도(tb_ms_sbmrg_dngr_map) - 30년 빈도 - '28', // 침수위험지도(tb_ms_sbmrg_dngr_map) - 50년 빈도 - '3', // 침수위험지도(tb_ms_sbmrg_dngr_map) - 80년 빈도 - '4', // 침수위험지도(tb_ms_sbmrg_dngr_map) - 100년 빈도 - '59', // 도시침수지도(tb_ms_flood_dngr_map) - 30년 빈도 - '60', // 도시침수지도(tb_ms_flood_dngr_map) - 50년 빈도 - '58' // 도시침수지도(tb_ms_flood_dngr_map) - 80년 빈도 -]; interface WaterDepthProps { onClose?: () => void; From ede5ef6a213914364436ef82da7c049884fbfe87 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 11:06:37 +0900 Subject: [PATCH 4/4] =?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 (