850 lines
31 KiB
JavaScript
850 lines
31 KiB
JavaScript
import {
|
|
MultiBackend,
|
|
Tree,
|
|
getBackendOptions,
|
|
DndProvider,
|
|
} from "@minoru/react-dnd-treeview";
|
|
import { ChevronLeft, ChevronRight, Layers } from "lucide-react";
|
|
import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react";
|
|
|
|
import { showBusinessUserMessage } from "../../lib/user-message";
|
|
import { useGisContext } from "./Gis";
|
|
import GisApp from "./lib/gis.core";
|
|
import { getBulkLayerSelection, getBulkLayerState } from "./layerBulkSelection";
|
|
|
|
/**
|
|
* GisTree (React memoized component)
|
|
*
|
|
* 계층형 GIS 레이어 트리를 렌더링하고, 체크 상태에 따라 지도상의 레이어 On/Off를 제어합니다.
|
|
* 또한 특정 순서/간격으로 레이어를 자동으로 토글하는 애니메이션 기능을 제공합니다.
|
|
*
|
|
* Props 요약
|
|
* - checkedNodes, setCheckedNodes: (선택) 외부 관리 체크 상태 (현재 내부 state 사용)
|
|
* - checkedLayerRequest: (선택) 기존 체크 상태를 유지하면서 지정 레이어를 추가로 체크하는 요청
|
|
* - 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): 애니메이션 진행상태 콜백
|
|
* - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역
|
|
* - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수
|
|
* - layerLegendIconVisible: (선택) 레이어 행 범례 아이콘 노출 여부를 결정하는 함수
|
|
* - layerIconRequiresChecked: (선택) 액션 실행 전에 레이어 체크를 요구할지 여부
|
|
* - layerActionIconOnly: (선택) 목록 액션을 텍스트 대신 목록 아이콘으로 표시할지 여부
|
|
* - detachedToggleButton: (선택) 접기/펼치기 버튼을 패널 밖 고정 핸들로 노출할지 여부
|
|
* - sectionedPanel: (선택) 트리와 선택 레이어 설정을 회색 배경 위의 독립 카드로 표시할지 여부
|
|
*/
|
|
export default React.memo(function GisTree({
|
|
checkedNodes = [],
|
|
setCheckedNodes = () => { },
|
|
checkedLayerRequest = null,
|
|
visible,
|
|
setVisible,
|
|
onCheckedChange,
|
|
onLayerIconClick,
|
|
imageVisible = false,
|
|
isPlaying = false,
|
|
setIsPlaying = () => { },
|
|
targetLayerTypes = [],
|
|
checkInterval = 3000,
|
|
onProgressChange = () => { },
|
|
layerSettingView: LayerSettingView = null,
|
|
layerIconVisible,
|
|
layerLegendIconVisible = () => true,
|
|
layerIconRequiresChecked = true,
|
|
layerActionIconOnly = false,
|
|
sinkholeLegendIconVisible = false,
|
|
inlineLayerSettingView,
|
|
treeSectionClassName = "",
|
|
toggleButtonPosition = "center",
|
|
showLayerPanelTitle = true,
|
|
detachedToggleButton = false,
|
|
sectionedPanel = false,
|
|
showBulkLayerToggle = false,
|
|
}) {
|
|
const { gisCore, treeData } = useGisContext();
|
|
|
|
// 체크/확장/노드/탭 상태
|
|
const [checked, setChecked] = useState([]);
|
|
const [expanded, setExpanded] = useState([]);
|
|
const [nodes, setNodes] = useState([]);
|
|
const [activeTab, setActiveTab] = useState("layers");
|
|
// 전체 선택이면 ON, 부분 선택이면 OFF 위치와 별도 상태 문구로 구분한다.
|
|
const bulkLayerState = getBulkLayerState(nodes, checked);
|
|
|
|
// 자동 애니메이션 관련 ref (실행 인덱스, 대상 노드 목록, 일시정지, 타이머)
|
|
const currentAnimationIndexRef = useRef(0);
|
|
const targetNodesRef = useRef([]);
|
|
const isPausedRef = useRef(false);
|
|
const timeoutIdRef = useRef(null);
|
|
// 동일한 외부 체크 요청이 리렌더링마다 반복 적용되지 않도록 마지막 요청 키를 보관한다.
|
|
const lastCheckedLayerRequestKeyRef = useRef(null);
|
|
// 체크 후 부모 설정영역까지 다시 렌더되어도 사용자가 보던 트리 위치를 유지한다.
|
|
const layerPanelScrollRef = useRef(null);
|
|
const layerPanelScrollTopRef = useRef(0);
|
|
|
|
const handleLayerPanelScroll = useCallback((event) => {
|
|
layerPanelScrollTopRef.current = event.currentTarget.scrollTop;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const scrollContainer = layerPanelScrollRef.current;
|
|
if (!scrollContainer) return;
|
|
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
scrollContainer.scrollHeight - scrollContainer.clientHeight
|
|
);
|
|
const nextScrollTop = Math.min(layerPanelScrollTopRef.current, maxScrollTop);
|
|
|
|
if (scrollContainer.scrollTop !== nextScrollTop) {
|
|
scrollContainer.scrollTop = nextScrollTop;
|
|
}
|
|
layerPanelScrollTopRef.current = nextScrollTop;
|
|
});
|
|
|
|
// =====================
|
|
// 유틸리티: 트리 탐색 관련 함수들
|
|
// =====================
|
|
|
|
/**
|
|
* 주어진 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]
|
|
);
|
|
|
|
/**
|
|
* 업무 화면이 요청한 레이어를 기존 사용자 체크 상태에 추가하고 조상 그룹을 펼친다.
|
|
* 레이어명은 공백 차이를 무시하며, 실제 지도 반영과 onCheckedChange는 수동 체크와 같은 경로를 사용한다.
|
|
*/
|
|
useEffect(() => {
|
|
const requestKey = checkedLayerRequest?.requestKey;
|
|
if (requestKey == null || nodes.length === 0) return;
|
|
if (lastCheckedLayerRequestKeyRef.current === requestKey) return;
|
|
|
|
const normalizeLayerName = (value) => String(value ?? "")
|
|
.replace(/\s+/g, "")
|
|
.trim();
|
|
const requestedLayerNames = new Set(
|
|
(checkedLayerRequest?.layerNames ?? [])
|
|
.map(normalizeLayerName)
|
|
.filter(Boolean)
|
|
);
|
|
const targetNodes = nodes.filter((node) => requestedLayerNames.has(
|
|
normalizeLayerName(node?.data?.lyrNm ?? node?.text)
|
|
));
|
|
if (targetNodes.length === 0) return;
|
|
|
|
setExpanded((current) => Array.from(new Set([
|
|
...current,
|
|
...targetNodes.map((node) => node.id),
|
|
...targetNodes.flatMap((node) => getAncestors(node.id, nodes)),
|
|
])));
|
|
targetNodes.forEach((node) => checkSpecificNode(node.id, true));
|
|
lastCheckedLayerRequestKeyRef.current = requestKey;
|
|
}, [checkedLayerRequest, checkSpecificNode, 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((node) => {
|
|
const hasChildren = nodes.some((child) => child.parent === node.id);
|
|
const isWmsGroupLayer = node.data?.srvcNm === 'wms' && hasChildren;
|
|
return node.data?.exprsYn === 'Y'
|
|
&& layerTypes.includes(node.data?.lyrTyNm)
|
|
&& (node.data?.actlLyrYn === 'N' || isWmsGroupLayer);
|
|
});
|
|
|
|
// 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;
|
|
}, []);
|
|
|
|
/** 전체 조작은 자동재생을 멈추고 트리·지도·업무 콜백에 같은 선택을 한 번 반영한다. */
|
|
const handleBulkLayerToggle = useCallback((enabled) => {
|
|
if (!gisCore || nodes.length === 0) return;
|
|
pauseAnimation();
|
|
setIsPlaying(false);
|
|
const { checkedIds, uncheckedIds, selectedNodes } = getBulkLayerSelection(nodes, enabled);
|
|
// 배경지도나 편집 오버레이가 아닌 현재 트리에 등록된 레이어만 변경한다.
|
|
gisCore.setLayerOnMap(uncheckedIds, false);
|
|
gisCore.setLayerOnMap(checkedIds, true);
|
|
setChecked(checkedIds);
|
|
onCheckedChange?.(checkedIds, selectedNodes);
|
|
}, [gisCore, nodes, onCheckedChange, pauseAnimation, setIsPlaying]);
|
|
|
|
/**
|
|
* 재생/정지/대상/간격 변경에 반응하여 애니메이션을 관리합니다.
|
|
*/
|
|
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.iconNm) {
|
|
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
|
|
}
|
|
if (!layerinfo.gobjTy) return null;
|
|
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, node) => {
|
|
if (layerIconRequiresChecked && !checked.includes(nodeId)) showBusinessUserMessage("시설을 선택해주세요");
|
|
else onLayerIconClick(nodeNm, node);
|
|
};
|
|
|
|
// =====================
|
|
// 렌더링
|
|
// =====================
|
|
|
|
// 빈 레이어 트리로 지도만 재사용하는 화면에서는 패널 외곽과 제목도 표시하지 않는다.
|
|
if (!treeData || treeData.length === 0) return null;
|
|
|
|
const toggleButtonPositionClass =
|
|
toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2";
|
|
const thinScrollbarClass =
|
|
"[scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]";
|
|
const treeSectionClassNames = [treeSectionClassName].filter(Boolean).join(" ");
|
|
// 섹션형 패널은 지도 트리와 선택 레이어 설정을 각각 독립된 카드로 구분한다.
|
|
const panelShellClassName = sectionedPanel
|
|
? "gap-1 border-r-0 bg-[#E5E5E5] py-1 pl-1 pr-[4.5px]"
|
|
: "border-r border-border bg-background";
|
|
const treePanelClassName = sectionedPanel
|
|
? "min-h-0 grow flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-none"
|
|
: "min-h-0 grow overflow-hidden border-none bg-background shadow-none";
|
|
const treeContentScrollClassName = sectionedPanel
|
|
? `min-h-0 flex-1 overflow-y-scroll ${thinScrollbarClass}`
|
|
: `h-full overflow-y-auto p-2 ${showLayerPanelTitle ? "pt-0" : "pt-2"} ${thinScrollbarClass}`;
|
|
|
|
return (
|
|
<>
|
|
{/* 사이드 패널 */}
|
|
<div
|
|
className={`gis-layer-panel absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden text-foreground duration-300 ${sectionedPanel ? "transition-transform ease-in-out" : "transition-all"} ${panelShellClassName} ${visible
|
|
? sectionedPanel ? "translate-x-0" : "translate-x-0 opacity-100"
|
|
: sectionedPanel ? "pointer-events-none -translate-x-full" : "pointer-events-none -translate-x-full opacity-0"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`${treePanelClassName} ${activeTab === "layers" ? (sectionedPanel ? "flex" : "block") : "hidden"
|
|
}`}
|
|
>
|
|
{sectionedPanel && (
|
|
<div className="gis-layer-section-header flex h-[38px] shrink-0 items-center gap-2 border-b border-border bg-background px-3">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-[#EAF3FF] text-[#168CFF]">
|
|
<Layers size={14} strokeWidth={2} />
|
|
</span>
|
|
<h3 className="text-[1.4rem] font-bold text-foreground">레이어 관리</h3>
|
|
{showBulkLayerToggle && (
|
|
<button type="button" role="switch" aria-label="전체 레이어"
|
|
aria-checked={bulkLayerState.allChecked}
|
|
aria-description={bulkLayerState.partiallyChecked ? "일부 레이어 켜짐. 누르면 전체를 켭니다." : undefined}
|
|
title={bulkLayerState.allChecked ? "레이어 전체 끄기" : "레이어 전체 켜기"}
|
|
disabled={!gisCore || bulkLayerState.disabled}
|
|
className="ml-auto flex shrink-0 items-center gap-1 rounded-sm text-[1.1rem] focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
onClick={() => handleBulkLayerToggle(!bulkLayerState.allChecked)}>
|
|
<span>{bulkLayerState.partiallyChecked ? "일부 켜짐" : "전체"}</span>
|
|
<span aria-hidden="true" className={`relative inline-flex h-[18px] w-[32px] items-center rounded-full transition-colors ${bulkLayerState.allChecked ? "bg-[#168CFF]" : "bg-gray-300"}`}>
|
|
<span className={`h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${bulkLayerState.allChecked ? "translate-x-[16px]" : "translate-x-[2px]"}`} />
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={layerPanelScrollRef}
|
|
onScroll={handleLayerPanelScroll}
|
|
className={treeContentScrollClassName}
|
|
>
|
|
{showLayerPanelTitle && (
|
|
<>
|
|
<h3 className="gis-layer-panel-title mx-2 my-4 text-center text-3xl font-bold text-label">시설물</h3>
|
|
<hr className="mb-4 border-border" />
|
|
</>
|
|
)}
|
|
{/* DnD Tree */}
|
|
<div className={treeSectionClassNames || undefined}>
|
|
<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"
|
|
);
|
|
const nodeLabel = String(node?.data?.lyrNm ?? "").trim();
|
|
const isSinkholeRootTreeNode =
|
|
String(node.id) === "SN_SINKHOLE" ||
|
|
nodeLabel === "지반침하정보";
|
|
const hideTreeLegendIcon =
|
|
(isSinkholeRootTreeNode && !sinkholeLegendIconVisible) ||
|
|
!layerLegendIconVisible(node);
|
|
const showLayerActionButton = layerIconVisible?.(node);
|
|
const showLayerActionIcon = depth > 0 && imageVisible;
|
|
|
|
const inlineLayerSetting = inlineLayerSettingView?.(node);
|
|
|
|
return (
|
|
<div style={{ display: isVisible ? "block" : "none" }}>
|
|
<div
|
|
className={`gis-layer-row flex w-full items-center gap-2 rounded px-2 py-1 ${sectionedPanel ? "hover:bg-[#f5f5f5]" : "hover:bg-accent"}`}
|
|
style={{
|
|
marginInlineStart: depth * 16,
|
|
width: `calc(100% - ${depth * 16}px)`,
|
|
}}
|
|
>
|
|
{hasVisibleChildren ? (
|
|
<i
|
|
onClick={onToggle}
|
|
className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? "rotate-90" : ""
|
|
}`}
|
|
title={isOpen ? "닫기" : "열기"}
|
|
/>
|
|
) : (
|
|
<span className="shrink-0" style={{ display: "inline-block", width: 16 }} />
|
|
)}
|
|
<input
|
|
type="checkbox"
|
|
checked={checked.includes(node.id)}
|
|
onChange={() => handleCheck(node.id)}
|
|
className="cursor-pointer"
|
|
/>
|
|
{!hideTreeLegendIcon && node.icon && <span>{node.icon}</span>}
|
|
<span
|
|
className="min-w-0 flex-1 truncate whitespace-nowrap text-[1.3rem] font-medium text-label"
|
|
title={String(node.text ?? "")}
|
|
>
|
|
{node.text}
|
|
</span>
|
|
{showLayerActionButton && (
|
|
<button
|
|
type="button"
|
|
className={layerActionIconOnly
|
|
? "gis-layer-action-button ml-auto grid h-[24px] w-[24px] shrink-0 place-items-center rounded-sm bg-transparent p-0 text-[#168CFF] transition-colors hover:bg-accent"
|
|
: "gis-layer-action-button ml-auto shrink-0 rounded-sm border border-primary bg-background px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-primary transition-colors hover:bg-accent"}
|
|
onClick={() =>
|
|
handleLayerImgClick(
|
|
node.data.lyrTyNm,
|
|
node.id,
|
|
node
|
|
)
|
|
}
|
|
title={`${node.text} 목록보기`}
|
|
aria-label={`${node.text} 목록보기`}
|
|
>
|
|
{layerActionIconOnly ? (
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 24 24"
|
|
className="h-[18px] w-[18px]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
>
|
|
<path d="M9 6h11M9 12h11M9 18h11" />
|
|
<path d="M4 6h.01M4 12h.01M4 18h.01" strokeWidth="3" />
|
|
</svg>
|
|
) : (
|
|
"목록보기"
|
|
)}
|
|
</button>
|
|
)}
|
|
{!showLayerActionButton && showLayerActionIcon && (
|
|
<span className="shrink-0">
|
|
<img
|
|
className="h-5 w-5 cursor-pointer"
|
|
src="/images/map/layericon/Layer_Select_BTN.png"
|
|
onClick={() =>
|
|
handleLayerImgClick(
|
|
node.data.lyrTyNm,
|
|
node.id,
|
|
node
|
|
)
|
|
}
|
|
alt={node.text}
|
|
/>
|
|
</span>
|
|
)}
|
|
</div>
|
|
{inlineLayerSetting && (
|
|
<div className="mt-2 pb-3 pr-1" style={{ marginInlineStart: depth * 8 + 12 }}>
|
|
{typeof inlineLayerSetting === "function"
|
|
? React.createElement(inlineLayerSetting)
|
|
: inlineLayerSetting}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
sort={(a, b) => {
|
|
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
|
|
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
|
|
return aOrder - bOrder;
|
|
}}
|
|
/>
|
|
</DndProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{LayerSettingView && (
|
|
<div
|
|
className={sectionedPanel
|
|
? "gis-layer-setting shrink-0 bg-transparent p-0"
|
|
: "gis-layer-setting shrink-0 bg-background px-2 pb-3"}
|
|
>
|
|
<LayerSettingView />
|
|
</div>
|
|
)}
|
|
{!detachedToggleButton && (
|
|
<button
|
|
onClick={() => setVisible(false)}
|
|
className={`gis-layer-toggle absolute right-[-3px] z-50 transform rounded-l-none rounded-r border border-l border-border bg-background px-1 py-2 text-foreground shadow-sm ${toggleButtonPositionClass}`}
|
|
>
|
|
<ChevronLeft size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{detachedToggleButton && (
|
|
<button
|
|
onClick={() => setVisible(!visible)}
|
|
className={`gis-layer-toggle absolute left-[250px] z-50 flex h-[36px] transform items-center justify-center border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition-transform duration-300 ease-in-out hover:bg-accent ${visible ? "translate-x-0" : "-translate-x-[250px]"} ${sectionedPanel
|
|
? "w-[20px] rounded-l-none rounded-r-[4px] text-[#666666]"
|
|
: "w-[20px] rounded-[4px] text-primary"
|
|
} ${toggleButtonPositionClass}`}
|
|
title={visible ? "접기" : "열기"}
|
|
aria-label={visible ? "레이어 패널 접기" : "레이어 패널 열기"}
|
|
aria-expanded={visible}
|
|
>
|
|
{visible ? (
|
|
<ChevronLeft className={sectionedPanel ? "h-8 w-8" : "h-4 w-4"} />
|
|
) : (
|
|
<ChevronRight className={sectionedPanel ? "h-8 w-8" : "h-4 w-4"} />
|
|
)}
|
|
</button>
|
|
)}
|
|
{!visible && !detachedToggleButton && (
|
|
<button
|
|
onClick={() => setVisible(true)}
|
|
className={`gis-layer-toggle absolute left-[-3px] z-50 transform rounded-r border border-r border-border bg-background px-1 py-2 text-foreground shadow-sm ${toggleButtonPositionClass}`}
|
|
title="열기"
|
|
>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
});
|