From 621d22279981b48c0d96c38793161b7e00987544 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 10:55:16 +0900 Subject: [PATCH 1/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 2/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 ( From 2e6cbad49ce85363b9e7fec2ed49ce5a331610bd Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 15:52:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?remove,=20refactor:=20-=20comboSearchBar.ts?= =?UTF-8?q?x=20=EC=82=AD=EC=A0=9C=20-=20any=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20procSearchBar=20=EC=8B=9C=EA=B5=B0?= =?UTF-8?q?=EA=B5=AC,=20=EC=8B=9C=EC=84=A4=20=EA=B0=80=EB=82=98=EB=8B=A4?= =?UTF-8?q?=EC=88=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/map/searchBar/comboSearchBar.tsx | 341 ------------------ .../src/ui/map/searchBar/procSearchBar.tsx | 39 +- .../src/ui/map/searchBar/searchBar.tsx | 4 +- 3 files changed, 19 insertions(+), 365 deletions(-) delete mode 100644 packages/common/business_components/src/ui/map/searchBar/comboSearchBar.tsx diff --git a/packages/common/business_components/src/ui/map/searchBar/comboSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/comboSearchBar.tsx deleted file mode 100644 index e59fd762c..000000000 --- a/packages/common/business_components/src/ui/map/searchBar/comboSearchBar.tsx +++ /dev/null @@ -1,341 +0,0 @@ -"use client"; -import { SelectBox, type SelectDataType } from "../../../ui"; -import { Button } from "@common/components/ui"; -import { useCallback, useEffect, useState } from "react"; - -const geoserverUrl = - "/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json"; - -const FACILITY_TYPE_URL_MAP: Record = { - tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설 - tb_wr_bdst_ruse_fclt: - "/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000", //재이용시설 post - tb_wr_rnwtr_utztn_fclt: "/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}", //빗물이용시설 get - tb_wr_rswtr_fclt: "/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}", //중수도시설 get - tb_wr_htdfl_fclt: "/reuse-service/drain/facilityList/1/1000/{sggCd}", //온배수시설 get - tb_wr_ruse_fclt_dmd_co: "/reuse-service/reuseFc/request/1/1000/{sggCd}", //수요처 get - tb_wr_tmpr_dmd_co: - "/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}", //잠재수요처 get -}; - -interface SearchBarProps { - onFeatureSelect: (feature: any) => void; - gisMaps: React.RefObject; - facilityTypes: { text: string; val: string }[]; -} - -export const ComboSearchBar = ({ - onFeatureSelect, - gisMaps, - facilityTypes, -}: SearchBarProps) => { - const [sidoValue, setSidoValue] = useState("0"); - const [sigunValue, setSigunValue] = useState("0"); - const [sidoData, setSidoData] = useState([ - { text: "선택", val: "0" }, - ]); - const [sigunData, setSigunData] = useState([ - { text: "선택", val: "0" }, - ]); - const [filteredSigunData, setFilteredSigunData] = useState([ - { text: "선택", val: "0" }, - ]); - - const [facilityData, setFacilityData] = useState([ - { text: "선택", val: "0" }, - ]); - const [facilityValue, setFacilityValue] = useState("0"); - - const [facilityType, setFacilityType] = useState( - facilityTypes?.length === 1 && facilityTypes[0] - ? facilityTypes[0].val - : "0", - ); - - //시도, 시군구 조회 - const fetchSido = async () => { - const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd`; - const res = await fetch(url).then(res => res.json()); - - const list = res.features - .map((f: any) => ({ - text: f.properties.mctpv_korn_nm, - val: f.properties.mctpv_cd, - })).sort((a, b) => { - // 커스텀 순서 정의 - const customOrder = [ - "서울특별시", - "부산광역시", - "대구광역시", - "인천광역시", - "광주광역시", - "대전광역시", - "울산광역시", - "세종특별자치시", - "경기도", - "강원특별자치도", // 경기도 다음에 배치 - "충청북도", - "충청남도", - "전북특별자치도", // 충청남도 다음에 배치 - "전라남도", - "경상북도", - "경상남도", - "제주특별자치도" - ]; - - const indexA = customOrder.indexOf(a.text); - const indexB = customOrder.indexOf(b.text); - - // 둘 다 커스텀 순서에 있으면 해당 순서로 - if (indexA !== -1 && indexB !== -1) { - return indexA - indexB; - } - - // 하나만 커스텀 순서에 있으면 그것을 앞으로 - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - - // 둘 다 커스텀 순서에 없으면 기본 알파벳 순서 - return a.text.localeCompare(b.text, 'ko'); - }); - setSidoData([{ text: "선택", val: "0" }, ...list]); - }; - - const fetchSigungu = async () => { - const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`; - const res = await fetch(url).then(res => res.json()); - - const list = res.features - .map((f: any) => ({ - text: f.properties.sgg_korn_nm, - val: f.properties.sgg_cd, - })) - .sort((a, b) => a.text.localeCompare(b.text, 'ko')); - setSigunData(list); - }; - - //시설명 콤보박스 조회 - const fetchFacilitiesBySggNm = async ( - sggCd: string, - sggNm: string, - sidoNm: string, - currentFacilityType?: string, - ) => { - const type = currentFacilityType ?? facilityType; - if (!type || type === "0") return; - - const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type]; - if (!baseUrlTemplate) return; - - const year = new Date().getFullYear().toString(); - let url = baseUrlTemplate - .replace("{year}", year) - .replace("{sggCd}", sggCd) - .replace("{sidoNm}", encodeURIComponent(sidoNm)) - .replace("{sggNm}", encodeURIComponent(sggNm)); - - let list: SelectDataType[] = []; - - if ( - type === "tb_il_swage_prcs_fclt" //하수처리시설 - ) { - const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`; - url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`; - const res = await fetch(url); - const json = await res.json(); - list = json.features.map((f: any) => ({ - text: f.properties.swtrmt_fclt_nm ?? "(이름없음)", - val: f.id, - })); - } else if ( - type === "tb_wr_bdst_ruse_fclt" // 재이용시설 - ) { - //재용에서 참조하는 하수처리시설 목록 - const paramsRes = await fetch("/reuse-service/reuseFc/pubFc"); - const paramsJson = await paramsRes.json(); - const params = paramsJson.data; - - const res = await fetch(url, { - method: "POST", - headers: { - accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.ruseUtztnFcltNm ?? "(이름없음)", - val: item.pbnstRuseFcltId, - })); - } else if ( - type === "tb_wr_ruse_fclt_dmd_co" //수요처 - ) { - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.dmdCoNm ?? "(이름없음)", - val: item.dmdInfoId, - })); - } else if ( - type === "tb_wr_tmpr_dmd_co" //잠재수요처 - ) { - // GET 방식 - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.dmdCoNm ?? "(이름없음)", - val: item.fctryMngSn, - })); - } else { - // GET 방식 - const res = await fetch(url); - const json = await res.json(); - list = (json.data || []).map((item: any) => ({ - text: item.bdstNm ?? "(이름없음)", - val: item.bdstInfoSn, - })); - } - - setFacilityData([{ text: "선택", val: "0" }, ...list]); - setFacilityValue("0"); - }; - - //시도, 시군구 초기 데이터 조회 - useEffect(() => { - fetchSido(); - fetchSigungu(); - }, []); - - //시군구 데이터 초기화 - const initializeSigunData = useCallback( - (sidoCd: string) => { - if (sidoCd === "0") { - setFilteredSigunData([{ text: "선택", val: "0" }]); - setSigunValue("0"); - return; - } - const prefix = sidoCd.substring(0, 2); - const filtered = sigunData.filter((item) => item.val.startsWith(prefix)); - setFilteredSigunData([{ text: "선택", val: "0" }, ...filtered]); - setSigunValue("0"); - }, - [sigunData], - ); - - //콤보박스 선택시 연관 콤보박스 처리 - const onSelectValue = useCallback( - (val: string, type: string) => { - if (type === "ctpvCd") { - setSidoValue(val); - initializeSigunData(val); - setFacilityData([{ text: "선택", val: "0" }]); - setFacilityValue("0"); - } else if (type === "sggCd") { - setSigunValue(val); - const sidoNm = - sidoData.find((item) => item.val === sidoValue)?.text || ""; - const sggNm = sigunData.find((item) => item.val === val)?.text || ""; - const sggCd = sigunData.find((item) => item.val === val)?.val || ""; - if (sggNm && sidoNm) - fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType); - } else if (type === "facilityType") { - setFacilityType(val); - setFacilityValue("0"); - setFacilityData([{ text: "선택", val: "0" }]); - const sidoNm = - sidoData.find((item) => item.val === sidoValue)?.text || ""; - const sggNm = - sigunData.find((item) => item.val === sigunValue)?.text || ""; - const sggCd = - sigunData.find((item) => item.val === sigunValue)?.val || ""; - if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val); - } else if (type === "fcltCd") { - setFacilityValue(val); - } - }, - [initializeSigunData, sigunData, sigunValue, sidoData, sidoValue], - ); - - //조회 버튼 클릭 - const handleSearch = () => { - if (!gisMaps.current || facilityValue === "0") return; - const layers = gisMaps.current.getLayers().getArray(); - for (const layer of layers) { - const source = layer.getSource?.(); - const feature = source?.getFeatureById?.(facilityValue); - if (feature) { - onFeatureSelect(feature); - return; - } - } - alert("해당 처리시설을 지도에서 찾을 수 없습니다."); - }; - - return ( -
-
-
-
- {facilityTypes.length > 1 && ( - - onSelectValue(val.toString(), "facilityType") - } - value={facilityType} - /> - )} - - onSelectValue(val.toString(), "ctpvCd") - } - value={sidoValue} - /> - - onSelectValue(val.toString(), "sggCd") - } - value={sigunValue} - /> - - onSelectValue(val.toString(), "fcltCd") - } - value={facilityValue} - /> -
-
- -
-
-
-
- ); -}; diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx index bcf474c2b..c43e3646a 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -18,19 +18,10 @@ interface SearchBarProps { export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { const [sidoValue, setSidoValue] = useState("0"); const [sigunValue, setSigunValue] = useState("0"); - const [sidoData, setSidoData] = useState([ - { text: "시도", val: "0" }, - ]); - const [sigunData, setSigunData] = useState([ - { text: "시군구", val: "0" }, - ]); - const [filteredSigunData, setFilteredSigunData] = useState([ - { text: "시군구", val: "0" }, - ]); - - const [facilityData, setFacilityData] = useState([ - { text: "하수처리시설", val: "0" }, - ]); + const [sidoData, setSidoData] = useState([{ text: "시도", val: "0" }]); + const [sigunData, setSigunData] = useState([{ text: "시군구", val: "0" }]); + const [filteredSigunData, setFilteredSigunData] = useState([{ text: "시군구", val: "0" }]); + const [facilityData, setFacilityData] = useState([{ text: "하수처리시설", val: "0" }]); const [facilityValue, setFacilityValue] = useState("0"); const [facilityType, setFacilityType] = useState( @@ -48,7 +39,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { text: f.properties.mctpv_korn_nm, val: f.properties.mctpv_cd, })) - .sort((a, b) => { + .sort((a: any, b: any) => { // 커스텀 순서 정의 const customOrder = [ "서울특별시", @@ -92,10 +83,12 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`; const res = await fetch(url).then(res => res.json()); - const list = res.features.map((f: any) => ({ - text: f.properties.sgg_korn_nm, - val: f.properties.sgg_cd, - })); + const list = res.features + .map((f: any) => ({ + text: f.properties.sgg_korn_nm, + val: f.properties.sgg_cd, + })) + .sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko')); setSigunData(list); }; @@ -128,10 +121,12 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => { url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`; const res = await fetch(url); const json = await res.json(); - list = json.features.map((f: any) => ({ - text: f.properties.swtrmt_fclt_nm ?? "(이름없음)", - val: f.id, - })); + list = json.features + .map((f: any) => ({ + text: f.properties.swtrmt_fclt_nm ?? "(이름없음)", + val: f.id, + })) + .sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko')); } else if ( type === "tb_wr_bdst_ruse_fclt" // 재이용시설 ) { diff --git a/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx index dd65019a8..919b3c4a9 100644 --- a/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx @@ -59,7 +59,7 @@ export const SearchBar = ({ const sidoList = json .filter((item: any) => item.lwpoCd?.length === 2) .map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm })) - .sort((a, b) => { + .sort((a: any, b: any) => { // 커스텀 순서 정의 const customOrder = [ "서울특별시", @@ -99,7 +99,7 @@ export const SearchBar = ({ const sigunList = json .filter((item: any) => item.lwpoCd?.length > 2) .map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm })) - .sort((a, b) => a.text.localeCompare(b.text, 'ko')); + .sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko')); setSidoData([{ text: "시도", val: "0" }, ...sidoList]); setSigunData(sigunList); }; From e815b9662fc7013e33f3f7259494e740eab0e789 Mon Sep 17 00:00:00 2001 From: swcho Date: Tue, 2 Sep 2025 16:00:49 +0900 Subject: [PATCH 4/5] =?UTF-8?q?remove:=20comboSearchBar=20index=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/business_components/src/ui/map/searchBar/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/common/business_components/src/ui/map/searchBar/index.tsx b/packages/common/business_components/src/ui/map/searchBar/index.tsx index 4ba24de3a..d578b2894 100644 --- a/packages/common/business_components/src/ui/map/searchBar/index.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/index.tsx @@ -1,3 +1,2 @@ -export * from "./comboSearchBar"; export * from "./procSearchBar"; export * from './searchBar' From 83f5d878e7b0f9090069c48fa7ad964a7dd25ae7 Mon Sep 17 00:00:00 2001 From: jsyang Date: Tue, 2 Sep 2025 16:44:43 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EB=B0=94=20y=EC=B6=95=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/chart/LineBarChart.tsx | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/common/business_components/src/ui/chart/LineBarChart.tsx b/packages/common/business_components/src/ui/chart/LineBarChart.tsx index a7319cb01..eee09fb65 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart.tsx @@ -109,6 +109,35 @@ export default function LineBarchart({ const lineMaxValue = getMaxValue(chartData, 'lineVal'); const barMaxValue = getMaxValue(chartData, 'barVal'); + /** + * 최대값과 구간 수에 따른 범례 만들기 + * maxValue : 최대값, segments : 구간 수 (구간수가 5면 눈금은 6) + */ + const makeNiceScale = (maxValue: number, segments = 5) => { + // 최대값이 이상할 경우 0, 0으로 반환 + if (!isFinite(maxValue) || maxValue <= 0) { + return { max: 0, ticks: [0] }; + } + + const exponent = Math.floor(Math.log10(maxValue)); + const base = Math.pow(10, exponent); + const fraction = maxValue / base; + + let niceFraction: number; + if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 3; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + + const niceMax = niceFraction * base; // round up to 1/2/5 * 10^k + const step = niceMax / segments; + const ticks = Array.from({ length: segments + 1 }, (_, i) => Number((i * step).toFixed(10))); + return { max: niceMax, ticks }; + }; + + const leftScale = makeNiceScale(lineMaxValue, 5); + const rightScale = makeNiceScale(barMaxValue, 5); + const getTicks = (data : any []) => { const monthSet = new Set(); const rtnData : string[] = []; @@ -182,23 +211,26 @@ export default function LineBarchart({ {yFieldText && typeof value === "number" ? value.toLocaleString() : value } + domain={[0, leftScale.max]} + ticks={leftScale.ticks} orientation="left" label={(props) => } + />} {/* 오른쪽 Y축: barVal */} {yFieldText2 && typeof value === "number" ? value.toLocaleString() : value } - label={(props) => } + domain={[0, rightScale.max]} + ticks={rightScale.ticks} + label={(props) => } reversed={true} />} } />