Files
frontend/packages/common/business_components/src/ui/map/GisTree.js
T

609 lines
19 KiB
JavaScript

import {
MultiBackend,
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";
/**
* 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 = () => { },
visible,
setVisible,
onCheckedChange,
onLayerIconClick,
imageVisible = false,
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
checkInterval = 3000,
onProgressChange = () => { },
}) {
const { gisCore, treeData } = useGisContext();
// 체크/확장/노드/탭 상태
const [checked, setChecked] = useState([]);
const [expanded, setExpanded] = useState([]);
const [nodes, setNodes] = useState([]);
const [activeTab, setActiveTab] = useState("layers");
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
const currentAnimationIndexRef = useRef(0);
const targetNodesRef = useRef([]);
const isPausedRef = useRef(false);
const timeoutIdRef = useRef(null);
// =====================
// 유틸리티: 트리 탐색 관련 함수들
// =====================
/**
* 주어진 id의 모든 하위(자손) 노드 id를 평탄 배열로 반환합니다.
* @param {number|string} id 기준 노드 id
* @param {Array} all 전체 노드 배열
* @returns {Array} 자손 노드 id 배열
*/
const getDescendants = (id, all) => {
const children = all.filter((n) => n.parent === id);
return children.reduce(
(acc, child) => acc.concat(child.id, ...getDescendants(child.id, all)),
[]
);
};
/**
* 주어진 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)];
};
/**
* 특정 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);
} 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")
.map((n) => n.id);
nodes.forEach((node) => {
checkedIds = updateParentCheckState(node.id, nodes, checkedIds);
});
return Array.from(new Set(checkedIds));
};
// =====================
// 초기 로딩: 트리 데이터 세팅 & 초기 체크/확장 & 지도 반영
// =====================
/**
* treeData가 들어오면 노드 변환 후, 모든 레이어를 지도에서 잠시 비활성화합니다.
*/
useEffect(() => {
if (treeData && treeData.length > 0) {
const nodeData = setNodeValues(treeData);
setNodes(nodeData);
gisCore.setAllLayersOnMap(false);
}
}, [treeData]);
/**
* nodes가 준비되면 기본 체크/확장 상태를 계산하고, 실제 지도 레이어 on/off 반영 및 콜백 호출.
*/
useEffect(() => {
if (nodes.length > 0) {
const defaultChecked = getDefaultCheckedNodes(nodes);
setChecked(defaultChecked);
// 기본 확장 = 체크된 노드 + 그 조상들
const defaultExpanded = Array.from(
new Set([
...defaultChecked,
...defaultChecked.flatMap((id) => getAncestors(id, nodes)),
])
);
setExpanded(defaultExpanded);
// 지도 반영
gisCore.setLayerOnMap(defaultChecked, true);
const defaultCheckedNodesData = nodes.filter((node) =>
defaultChecked.includes(node.id)
);
onCheckedChange?.(defaultChecked, defaultCheckedNodesData);
}
}, [nodes]);
// =====================
// 체크 이벤트: 트리에서 체크 박스 클릭 시 처리
// =====================
/**
* 단일 노드 체크/해제 처리 핸들러
* - 체크 시: 자신 + 모든 자손 체크
* - 해제 시: 자신 + 모든 자손 해제
* - 이후 부모 상태 전파 및 지도 레이어 on/off 반영, 콜백 호출
*/
const handleCheck = (id) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isChecked = prevChecked.includes(id);
if (isChecked) {
// 해제: 자신 + 자손 제거
const descendants = getDescendants(id, nodes);
newChecked = newChecked.filter(
(cid) => cid !== id && !descendants.includes(cid)
);
} else {
// 체크: 자신 + 자손 추가
const descendants = getDescendants(id, nodes);
newChecked = Array.from(new Set([...newChecked, id, ...descendants]));
}
// 부모 전파 (모든 자식 체크 시 부모 체크)
newChecked = updateParentCheckState(id, nodes, newChecked);
// 지도 반영
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 콜백
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
};
// =====================
// 외부 호출용: 특정 노드 강제 체크/해제
// =====================
/**
* 특정 nodeId를 강제로 체크/해제합니다.
* @param {number|string} nodeId 대상 노드 id
* @param {boolean} shouldCheck true면 체크, false면 해제
*
* 내부적으로 handleCheck와 동일한 부모 전파/지도 반영/콜백 과정을 수행합니다.
*/
const checkSpecificNode = useCallback(
(nodeId, shouldCheck = true) => {
setChecked((prevChecked) => {
let newChecked = [...prevChecked];
const isCurrentlyChecked = prevChecked.includes(nodeId);
if (shouldCheck && !isCurrentlyChecked) {
// 체크: 자신 + 자손 추가, 부모 전파
const descendants = getDescendants(nodeId, nodes);
newChecked = Array.from(new Set([...newChecked, nodeId, ...descendants]));
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (isEveryChildChecked(p, nodes, newChecked)) newChecked.push(p);
});
} else if (!shouldCheck && isCurrentlyChecked) {
// 해제: 자신 + 자손 제거, 부모 해제
const descendants = getDescendants(nodeId, nodes);
newChecked = newChecked.filter(
(cid) => cid !== nodeId && !descendants.includes(cid)
);
const ancestors = getAncestors(nodeId, nodes);
ancestors.forEach((p) => {
if (newChecked.includes(p)) newChecked = newChecked.filter((cid) => cid !== p);
});
}
// 지도 반영
const allIds = nodes.map((n) => n.id);
const unchecked = allIds.filter((nid) => !newChecked.includes(nid));
gisCore.setLayerOnMap(newChecked, true);
gisCore.setLayerOnMap(unchecked, false);
// 콜백
const checkedNodesData = nodes.filter((node) => newChecked.includes(node.id));
onCheckedChange?.(newChecked, checkedNodesData);
return newChecked;
});
},
[nodes]
);
// =====================
// 자동 애니메이션: 지정된 순서/간격으로 노드 체크 상태를 순차 변경
// =====================
/**
* 레이어 자동 토글 애니메이션을 시작/중지/초기화합니다.
* @param {boolean} isEnabled 재생 여부
* @param {string[]} layerTypes 대상 레이어 타입 목록 (node.data.lyrTyNm 기준)
* @param {number} interval 토글 간격(ms)
*/
const handleAutoCheckAnimation = useCallback(
(isEnabled, layerTypes = [], interval = 3000) => {
// 기존 타이머 정리
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
// 중지/초기화 경로
if (!isEnabled || layerTypes.length === 0) {
isPausedRef.current = false;
targetNodesRef.current = [];
currentAnimationIndexRef.current = 0;
onProgressChange(0, 0);
return;
}
// 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;
// 다음 레이어로 진행하는 내부 함수
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);
};
}, []);
// =====================
// 노드/아이콘 렌더링 데이터 준비
// =====================
/**
* 서버에서 내려온 트리 데이터를 Tree 컴포넌트가 요구하는 구조로 변환합니다.
* - id, text, parent, droppable, data, icon
*/
const setNodeValues = (result) => {
return result.map((item) => {
const src = makeLegendSrc(item);
return {
id: item.lyrSn, // 반드시 고유값
text: item.lyrNm,
parent: item.upperLyrSn || null, // 루트는 null
droppable: true, // children 여부 모를 땐 true로 둬야 트리 확장 가능
data: item,
icon: src ? <img src={src} alt={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;
}
};
/**
* 레이어 스타일 정보를 기반으로 작은 캔버스에 간단한 범례 이미지를 그려 dataURL을 생성합니다.
*/
const makeLegendImage = (layerinfo) => {
let image = document.createElement("img");
let canvas = document.createElement("canvas");
canvas.width = 16;
canvas.height = 16;
let ctx = canvas.getContext("2d");
ctx.beginPath();
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
} else if (layerinfo.gobjTy === "line") {
ctx.moveTo(1, 15);
ctx.lineTo(5, 1);
ctx.lineTo(9, 15);
ctx.lineTo(15, 1);
} else {
ctx.moveTo(1, 1);
ctx.lineTo(15, 1);
ctx.lineTo(15, 15);
ctx.lineTo(1, 15);
ctx.lineTo(1, 1);
}
if (layerinfo.gobjTy === "lineLabelPoligon") {
ctx.fillStyle = layerinfo.polgnColor;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
if (layerinfo.gobjTy === "line") {
ctx.lineWidth = 1;
ctx.strokeStyle = layerinfo.lnColor;
ctx.stroke();
}
image.src = canvas.toDataURL("image/png");
return image;
};
/**
* 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달.
*/
const handleLayerImgClick = (nodeNm, nodeId) => {
if (!checked.includes(nodeId)) alert("시설을 선택해주세요");
else onLayerIconClick(nodeNm);
};
// =====================
// 렌더링
// =====================
if (!treeData || treeData.length === 0) return null;
return (
<>
{/* 사이드 패널 */}
<div
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
}`}
>
<div
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
}`}
>
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
<hr className="mb-4" />
{/* DnD Tree */}
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
<Tree
tree={nodes}
rootId={null}
initialOpen={expanded}
onOpenChange={setExpanded}
render={(node, { depth, isOpen, onToggle }) => {
const isVisible = node.data.exprsYn === "Y";
const hasVisibleChildren = nodes.some(
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
return (
<div
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
style={{
marginInlineStart: depth * 16,
display: isVisible ? "flex" : "none",
}}
>
{hasVisibleChildren ? (
<i
onClick={onToggle}
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
}`}
title={isOpen ? "닫기" : "열기"}
/>
) : (
<span style={{ display: "inline-block", width: 16 }} />
)}
<input
type="checkbox"
checked={checked.includes(node.id)}
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{node.text}
</span>
{imageVisible && depth > 0 && (
<span>
<img
className="h-5 w-5 cursor-pointer"
src="/images/map/layericon/Layer_Select_BTN.png"
onClick={() =>
handleLayerImgClick(
node.data.lyrTyNm,
node.id
)
}
alt={node.text}
/>
</span>
)}
</div>
);
}}
sort={(a, b) => {
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
return aOrder - bOrder;
}}
/>
</DndProvider>
</div>
<button
onClick={() => setVisible(false)}
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronLeft size={16} />
</button>
</div>
{!visible && (
<button
onClick={() => setVisible(true)}
className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
>
<ChevronRight size={16} />
</button>
)}
</>
);
});