import React, { useRef, useEffect, useMemo } from "react"; import * as d3 from "d3"; import "../../Style/TreeChart.css"; import { getStatusColor } from "./dataUtils"; const TreeChart = ({ data, onNodeClick }) => { const chartRef = useRef(); const nodePositions = useRef(new Map()); const { root, nodes, links } = useMemo(() => { if (!data || !data.items) return { root: null, nodes: [], links: [] }; const root = d3.hierarchy(data, (d) => d.items); const maxDepth = d3.max(root.descendants(), (d) => d.depth); // Фильтруем узлы, исключая последний уровень const nodes = root.descendants().filter((d) => d.depth < maxDepth); // Фильтруем связи const links = nodes.filter((d) => d.parent).map((d) => ({ source: d.parent, target: d, })); // Размещаем узлы иерархически const center = { x: 0, y: 0 }; // Центральная точка const baseRadius = 150; // Базовый радиус для 1-го уровня const branchOffset = 80; // Смещение узлов вдоль ветки const angleOffset = Math.PI / 4; // Угол смещения для дочерних ветвей const spreadFactor = 1.5; // Коэффициент растяжения для последних узлов nodes.forEach((node) => { const prev = nodePositions.current.get(node.data.id); if (prev) { node.x = prev.x; node.y = prev.y; } else { if (node.depth === 0) { // Центральный узел node.x = center.x; node.y = center.y; } else if (node.depth === 1) { // Первый уровень - равномерно по окружности const parent = node.parent; const index = parent.children.indexOf(node); const totalSiblings = parent.children.length; const radius = baseRadius * node.depth; const sectorAngle = (Math.PI * 2) / totalSiblings; const angle = index * sectorAngle; node.x = parent.x + radius * Math.cos(angle); node.y = parent.y + radius * Math.sin(angle); node.angle = angle; // Запоминаем угол для веток } else { // Второй уровень и дальше - ветка растет в направлении родителя const parent = node.parent; const siblings = parent.children || []; const index = siblings.indexOf(node); const totalSiblings = siblings.length; const direction = parent.angle || 0; const offsetAngle = ((index - (totalSiblings - 1) / 2) * angleOffset) / totalSiblings; let distance = branchOffset; if (!node.children || node.children.length === 0) { // Если это последний узел, увеличиваем расстояние distance *= spreadFactor + node.depth * 0.2; // Чем глубже, тем больше разброс } node.x = parent.x + distance * Math.cos(direction + offsetAngle); node.y = parent.y + distance * Math.sin(direction + offsetAngle); node.angle = direction + offsetAngle; } } nodePositions.current.set(node.data.id, { x: node.x, y: node.y }); }); return { root, nodes, links }; }, [data]); useEffect(() => { if (!chartRef.current) return; const svg = d3.select(chartRef.current) .attr("width", 2000) .attr("height", 2000) .attr("viewBox", [-500, -500, 1500, 1500]) .attr("style", "max-width: 100%; height: auto;"); svg.append("g").attr("class", "links"); svg.append("g").attr("class", "nodes"); svg.append("g").attr("class", "labels"); // Очищаем предыдущие элементы svg.selectAll(".links line").remove(); svg.selectAll(".nodes circle").remove(); svg.selectAll(".labels text").remove(); // Рисуем связи const linkGroup = svg.select(".links"); const link = linkGroup .selectAll("line") .data(links, (d) => `${d.source.data.id}-${d.target.data.id}`) .join("line") .attr("stroke", "#999") .attr("stroke-opacity", 0.6) .attr("x1", (d) => d.source.x) .attr("y1", (d) => d.source.y) .attr("x2", (d) => d.target.x) .attr("y2", (d) => d.target.y); // Рисуем узлы const nodeGroup = svg.select(".nodes"); const node = nodeGroup .selectAll("circle") .data(nodes, (d) => d.data.id) .join("circle") .attr("fill", (d) => getStatusColor(d.data.status)) .attr("stroke", "#fff") .attr("r", 7) .attr("cx", (d) => d.x) .attr("cy", (d) => d.y) .call(drag()); node.on("click", (event, d) => { if (onNodeClick) { onNodeClick(d.data.id, d.data.title); } }); // Рисуем текстовые метки const labelGroup = svg.select(".labels"); const text = labelGroup .selectAll("text") .data(nodes, (d) => d.data.id) .join("text") .text((d) => (nodes.length > 50 ? "" : d.data.title)) // Скрываем текст, если узлов много .attr("dx", 12) .attr("dy", 4) .style("user-select", "none") // Запрет выделения текста .style("pointer-events", "none") // Запрет взаимодействия с текстом .style("fill", "var(--TreeChart-text-color)") // Используем переменную для цвета текста .attr("x", (d) => d.x + 12) .attr("y", (d) => d.y + 4); }, [root, links, nodes, onNodeClick]); const drag = () => { function dragstarted(event, d) { d3.select(this).raise().attr("stroke", "#000"); } function dragged(event, d) { d.x = event.x; d.y = event.y; d3.select(this).attr("cx", d.x).attr("cy", d.y); // Обновляем текстовую метку d3.select(this.parentNode) .select("text") .attr("x", d.x + 12) .attr("y", d.y + 4); // Обновляем связи d3.select(chartRef.current) .selectAll(".links line") .filter((link) => link.source === d || link.target === d) .attr("x1", (link) => link.source.x) .attr("y1", (link) => link.source.y) .attr("x2", (link) => link.target.x) .attr("y2", (link) => link.target.y); } function dragended(event, d) { d3.select(this).attr("stroke", "#fff"); nodePositions.current.set(d.data.id, { x: d.x, y: d.y }); } return d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended); }; return ; }; export default TreeChart;