D3.js与vue3力导向图开发全流程

发布于:2025-06-10 ⋅ 阅读:(25) ⋅ 点赞:(0)

父组件-使用D3ForceGraph组件

<template>
  <div class="p-4 bg-gray-100 min-h-screen">
    <h1 class="text-2xl font-bold mb-4 text-center">
      图谱 (D3.js 力导向图)
    </h1>
    <div class="chart-wrapper bg-white shadow-lg rounded-lg overflow-hidden">
      <D3ForceGraph :graph-data="chartData" :width="800" :height="600" />
    </div>

    <div class="mt-8 p-4 bg-white shadow-lg rounded-lg">
      <h2 class="text-xl font-semibold mb-2">图表说明</h2>
      <p class="text-gray-700">
        这是一个使用 D3.js
        实现的力导向图,用于展示“美国产品知识图谱”的示例数据。图中节点代表不同的实体(如国家、品牌、产品等),连线代表它们之间的关系。
      </p>
    </div>

    <div class="mt-8 p-4 bg-white shadow-lg rounded-lg">
      <h2 class="text-xl font-semibold mb-2">技术实现</h2>
      <p class="text-gray-700">
        该图表使用 Vue 3、TypeScript 和 D3.js 构建,并采用 Tailwind CSS
        进行样式设计。 D3.js 用于处理复杂的图形渲染和交互,Vue
        用于组件化和数据绑定。
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import D3ForceGraph from "./D3ForceGraph.vue";
import { mockGraphData, type GraphData } from "./mockD3Data";

const chartData = ref<GraphData>(mockGraphData);
</script>

<style scoped>
.chart-wrapper {
  /* You can add specific wrapper styles here if needed */
  /* For example, to ensure it has a defined aspect ratio or max-width */
  max-width: 1000px; /* Example max-width */
  margin: 0 auto; /* Center the chart wrapper */
}
</style>

子组件-创建D3ForceGraph组件

<template>
  <div
    ref="containerEl"
    class="w-full h-full bg-slate-200 rounded-lg shadow-md relative"
  ></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import * as d3 from "d3";
import type { GraphData, Node as NodeType } from "./mockD3Data";

// Extend D3's SimulationNodeDatum with our Node properties
interface SimulationNode extends NodeType, d3.SimulationNodeDatum {}
interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> {}

const props = defineProps<{
  graphData: GraphData;
  width?: number;
  height?: number;
}>();

const containerEl = ref<HTMLDivElement | null>(null);
let simulation: d3.Simulation<SimulationNode, SimulationLink> | null = null;
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
let g: d3.Selection<SVGGElement, unknown, null, undefined> | null = null;

let currentNodes: SimulationNode[] = [];
let currentLinks: SimulationLink[] = [];

const NODE_COLORS = {
  country: "gold", // Yellow for country
  category: "pink", // Purple for categories
  brand: "red", // Coral/Red for brand parent
  "brand-detail": "green", // CornflowerBlue for brand children
  contributor: "blue", // MediumSeaGreen for contributors
};

const NODE_SIZES = {
  country: 45,
  category: 35,
  brand: 40,
  "brand-detail": 25,
  contributor: 28,
};

const FONT_SIZES = {
  country: "12px",
  category: "10px",
  brand: "11px",
  "brand-detail": "9px",
  contributor: "10px",
};

function getNodeColor(node: SimulationNode): string {
  return NODE_COLORS[node.type] || "#CCCCCC"; // Default color
}

function getNodeSize(node: SimulationNode): number {
  return NODE_SIZES[node.type] || 20; // Default size
}

function getFontSize(node: SimulationNode): string {
  return FONT_SIZES[node.type] || "10px";
}

function initializeGraph(initialData: GraphData) {
  if (!containerEl.value) return;

  const width = props.width || containerEl.value.clientWidth;
  const height = props.height || containerEl.value.clientHeight;

  d3.select(containerEl.value).select("svg").remove();

  svg = d3
    .select(containerEl.value)
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", [-width / 2, -height / 2, width, height].join(" "))
    .style("background-color", "hsl(220, 30%, 90%)"); // Light blue-gray background like UI

  // Define arrow marker
  svg
    .append("defs")
    .append("marker")
    .attr("id", "arrowhead")
    .attr("viewBox", "-0 -5 10 10")
    .attr("refX", function (_this: SVGMarkerElement, _d: any) {
      // 设置标记箭头与路径终点的水平偏移量
      // Dynamically adjust refX based on target node size if possible, or use a sensible default
      // This is tricky as marker is defined once. A common approach is to adjust link line end.
      return 10; // Default, adjust as needed
    })
    .attr("refY", 0)
    .attr("orient", "auto")
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("xoverflow", "visible")
    .append("svg:path")
    .attr("d", "M 0,-5 L 10 ,0 L 0,5")
    .attr("fill", "#555")
    .style("stroke", "none");

  g = svg.append("g");

  const zoom = d3.zoom<SVGSVGElement, unknown>().on("zoom", (event) => {
    g?.attr("transform", event.transform);
  });
  svg.call(zoom);

  // Initialize with root and its direct children
  const rootNode = initialData.nodes.find((n) => n.isRoot);
  if (rootNode) {
    currentNodes = [rootNode as SimulationNode];
    initialData.nodes.forEach((n) => {
      if (n.parent === rootNode.id) {
        currentNodes.push(n as SimulationNode);
      }
    });
    currentLinks = initialData.links.filter(
      (l) =>
        currentNodes.find((cn) => cn.id === l.source) &&
        currentNodes.find((cn) => cn.id === l.target)
    ) as SimulationLink[];
    // Ensure all nodes start collapsed unless specified
    currentNodes.forEach((n) => {
      if (n.id === rootNode.id) {
        n.isExpanded = true; // Root is expanded by default
      } else {
        n.isExpanded = false;
      }
      if (n.children && !n.isExpanded) {
        // If has children and not expanded, move to _children
        n._children = n.children;
        n.children = undefined;
      }
    });
  }

  // Set initial positions for better layout
  currentNodes.forEach((node) => {
    if (node.type === "country") {
      node.fx = 0;
      node.fy = 0;
    }
  });
  // 8. 创建力导向模拟
  simulation = d3
    .forceSimulation<SimulationNode, SimulationLink>(currentNodes)
    .force(
      "link",
      d3
        .forceLink<SimulationNode, SimulationLink>(currentLinks)
        .id((d) => d.id)
        .distance((d) => {
          const sourceNode = d.source as SimulationNode;
          const targetNode = d.target as SimulationNode;
          let dist = 30;
          if (sourceNode.type === "country" || targetNode.type === "country")
            dist = 40;
          else if (
            sourceNode.type === "category" ||
            targetNode.type === "category"
          )
            dist = 40;
          else if (sourceNode.type === "brand" || targetNode.type === "brand")
            dist = 40;
          return (
            dist + getNodeSize(sourceNode) / 2 + getNodeSize(targetNode) / 2
          ); // Adjust distance based on node sizes
        })
    )
    .force("charge", d3.forceManyBody().strength(-100)) // 设置-100的排斥力强度,使节点相互推开
    .force("center", d3.forceCenter(0, 0)) // 添加一个居中力,使节点尽量保持中心位置
    .force(
      "collision",
      d3
        .forceCollide()
        .radius((d: any) => getNodeSize(d) + 15)
        .iterations(2)
    ) // 添加一个碰撞力,使节点之间avoids碰撞
    .on("tick", ticked);

  updateGraph();
}

function updateGraph() {
  if (!g || !simulation) return;

  // Links
  const linkSelection = g
    .selectAll(".link")
    .data(
      currentLinks,
      (d: any) =>
        `${(d.source as SimulationNode).id}-${(d.target as SimulationNode).id}`
    );

  linkSelection.exit().remove();

  linkSelection
    .enter()
    .append("line")
    .attr("class", "link")
    .attr("stroke", "#555")
    .attr("stroke-opacity", 0.7)
    .attr("stroke-width", 1.5)
    .attr("marker-end", "url(#arrowhead)");

  // Nodes
  const nodeSelection = g
    .selectAll(".node")
    .data(currentNodes, (d: any) => d.id);

  nodeSelection.exit().remove();

  const nodeEnter = nodeSelection
    .enter()
    .append("g")
    .attr("class", "node")
    .call(drag(simulation) as any)
    .on("click", handleNodeClick)
    .on("mouseover", handleNodeMouseOver)
    .on("mouseout", handleNodeMouseOut);

  nodeEnter
    .append("circle")
    .attr("r", (d) => getNodeSize(d))
    .attr("fill", (d) => getNodeColor(d))
    .attr("stroke", "#FFF")
    .attr("stroke-width", 2);

  nodeEnter
    .append("text")
    .attr("dy", ".35em") // Vertically center
    .attr("text-anchor", "middle") // Horizontally center
    .style("font-size", (d) => getFontSize(d))
    .style("fill", (d) =>
      d.type === "country" || d.type === "contributor" ? "#000" : "#fff"
    ) // Black for country/contrib, white for others
    .style("pointer-events", "none")
    .text((d) => d.name);

  nodeSelection
    .select("circle") // Update existing circles (e.g. if size changes)
    .transition()
    .duration(300)
    .attr("r", (d) => getNodeSize(d))
    .attr("fill", (d) => getNodeColor(d));

  nodeSelection
    .select("text") // Update existing text
    .transition()
    .duration(300)
    .style("font-size", (d) => getFontSize(d))
    .style("fill", (d) =>
      d.type === "country" || d.type === "contributor" ? "#000" : "#fff"
    )
    .text((d) => d.name);

  simulation.nodes(currentNodes);
  simulation
    .force<d3.ForceLink<SimulationNode, SimulationLink>>("link")
    ?.links(currentLinks);
  simulation.alpha(0.3).restart();
}

function ticked() {
  g?.selectAll<SVGLineElement, SimulationLink>(".link").each(function (d) {
    const sourceNode = d.source as SimulationNode;
    const targetNode = d.target as SimulationNode;
    const targetRadius = getNodeSize(targetNode);
    const dx = targetNode.x! - sourceNode.x!;
    const dy = targetNode.y! - sourceNode.y!;
    const distance = Math.sqrt(dx * dx + dy * dy);
    if (distance === 0) return; // Avoid division by zero

    // Calculate the point on the circle's edge for the arrowhead
    const t = targetRadius / distance;
    const x2 = targetNode.x! - dx * t;
    const y2 = targetNode.y! - dy * t;

    d3.select(this)
      .attr("x1", sourceNode.x!)
      .attr("y1", sourceNode.y!)
      .attr("x2", x2)
      .attr("y2", y2);
  });

  g
    ?.selectAll<SVGGElement, SimulationNode>(".node")
    .attr("transform", (d) => `translate(${d.x},${d.y})`);
}

function handleNodeClick(event: MouseEvent, clickedNode: SimulationNode) {
  event.stopPropagation();

  if (clickedNode.isRoot && !clickedNode.isExpanded && clickedNode._children) {
    // Special case for root re-expansion
    clickedNode.isExpanded = true;
    clickedNode.children = clickedNode._children;
    clickedNode._children = undefined;
    clickedNode.children.forEach((child) => {
      if (!currentNodes.find((n) => n.id === child.id))
        currentNodes.push(child as SimulationNode);
      if (
        !currentLinks.find(
          (l) => l.source === clickedNode.id && l.target === child.id
        )
      ) {
        currentLinks.push({
          source: clickedNode.id,
          target: child.id,
        } as SimulationLink);
      }
    });
  } else if (clickedNode._children && clickedNode._children.length > 0) {
    // Expand
    clickedNode.isExpanded = true;
    clickedNode.children = clickedNode._children;
    clickedNode._children = undefined;

    clickedNode.children.forEach((child: any) => {
      if (!currentNodes.find((n) => n.id === child.id)) {
        child.x = clickedNode.x! + (Math.random() - 0.5) * 30;
        child.y = clickedNode.y! + (Math.random() - 0.5) * 30;
        child.fx = child.x; // Temporarily fix position for smoother expansion
        child.fy = child.y;
        currentNodes.push(child as SimulationNode);
        // Release fx/fy after a short delay
        setTimeout(() => {
          child.fx = null;
          child.fy = null;
        }, 500);
      }
      if (
        !currentLinks.find(
          (l) => l.source === clickedNode.id && l.target === child.id
        )
      ) {
        currentLinks.push({
          source: clickedNode.id,
          target: child.id,
        } as SimulationLink);
      }
    });
  } else if (clickedNode.children && clickedNode.children.length > 0) {
    // Collapse
    clickedNode.isExpanded = false;
    const nodesToRemove = new Set<string>();

    function gatherNodesToRemove(node: SimulationNode) {
      if (node.children) {
        node.children.forEach((child) => {
          nodesToRemove.add(child.id);
          const childNode = currentNodes.find((cn) => cn.id === child.id);
          if (childNode) gatherNodesToRemove(childNode); // Recursively gather
        });
      }
    }
    gatherNodesToRemove(clickedNode);

    currentNodes = currentNodes.filter((n) => !nodesToRemove.has(n.id));
    currentLinks = currentLinks.filter(
      (l) =>
        !(
          nodesToRemove.has((l.source as SimulationNode).id) ||
          nodesToRemove.has((l.target as SimulationNode).id) ||
          ((l.source as SimulationNode).id === clickedNode.id &&
            nodesToRemove.has((l.target as SimulationNode).id))
        )
    );

    clickedNode._children = clickedNode.children;
    clickedNode.children = undefined;
  }
  updateGraph();
}

function handleNodeMouseOver(event: MouseEvent, hoveredNode: SimulationNode) {
  if (!g) return;
  g.selectAll(".node, .link").style("opacity", 0.2);

  const highlightSet = new Set<string>();
  const linksToHighlight = new Set<SimulationLink>();

  function getRelated(node: SimulationNode, isPrimaryHover: boolean) {
    highlightSet.add(node.id);
    // Highlight direct children if expanded
    if (node.isExpanded && node.children) {
      node.children.forEach((child) => {
        const childNodeInstance = currentNodes.find((n) => n.id === child.id);
        if (childNodeInstance) {
          highlightSet.add(childNodeInstance.id);
          const link = currentLinks.find(
            (l) =>
              (l.source as SimulationNode).id === node.id &&
              (l.target as SimulationNode).id === childNodeInstance.id
          );
          if (link) linksToHighlight.add(link);
          // If it's the primary hovered node, also get its children's children (one more level for sub-graph feel)
          if (
            isPrimaryHover &&
            childNodeInstance.isExpanded &&
            childNodeInstance.children
          ) {
            childNodeInstance.children.forEach((grandChild) => {
              const grandChildNodeInstance = currentNodes.find(
                (n) => n.id === grandChild.id
              );
              if (grandChildNodeInstance)
                highlightSet.add(grandChildNodeInstance.id);
              const childLink = currentLinks.find(
                (l) =>
                  (l.source as SimulationNode).id === childNodeInstance.id &&
                  (l.target as SimulationNode).id === grandChild.id
              );
              if (childLink) linksToHighlight.add(childLink);
            });
          }
        }
      });
    }
    // Highlight parent
    if (node.parent) {
      const parentNode = currentNodes.find((n) => n.id === node.parent);
      if (parentNode) {
        highlightSet.add(parentNode.id);
        const linkToParent = currentLinks.find(
          (l) =>
            ((l.source as SimulationNode).id === parentNode.id &&
              (l.target as SimulationNode).id === node.id) ||
            ((l.source as SimulationNode).id === node.id &&
              (l.target as SimulationNode).id === parentNode.id)
        );
        if (linkToParent) linksToHighlight.add(linkToParent);
        // if not primary hover (i.e. a parent), don't recurse further up to avoid highlighting everything
      }
    }
  }

  getRelated(hoveredNode, true);

  g.selectAll<SVGGElement, SimulationNode>(".node")
    .filter((d) => highlightSet.has(d.id))
    .style("opacity", 1);

  g.selectAll<SVGLineElement, SimulationLink>(".link")
    .filter(
      (d) =>
        linksToHighlight.has(d) ||
        (highlightSet.has((d.source as SimulationNode).id) &&
          highlightSet.has((d.target as SimulationNode).id))
    )
    .style("opacity", 1)
    .attr("stroke", "#FF6347") // Highlight color for links
    .attr("stroke-width", 2.5);
}

function handleNodeMouseOut() {
  if (!g) return;
  g.selectAll(".node, .link").style("opacity", 1);
  g.selectAll<SVGLineElement, SimulationLink>(".link")
    .attr("stroke", "#555")
    .attr("stroke-width", 1.5);
}

function drag(sim: d3.Simulation<SimulationNode, SimulationLink>) {
  function dragstarted(
    event: d3.D3DragEvent<SVGGElement, SimulationNode, SimulationNode>
  ) {
    if (!event.active) sim.alphaTarget(0.3).restart();
    event.subject.fx = event.subject.x;
    event.subject.fy = event.subject.y;
  }
  function dragged(
    event: d3.D3DragEvent<SVGGElement, SimulationNode, SimulationNode>
  ) {
    event.subject.fx = event.x;
    event.subject.fy = event.y;
  }
  function dragended(
    event: d3.D3DragEvent<SVGGElement, SimulationNode, SimulationNode>
  ) {
    if (!event.active) sim.alphaTarget(0);
    if (!event.subject.isRoot) {
      // Keep root node fixed if it was initially
      event.subject.fx = null;
      event.subject.fy = null;
    }
  }
  return d3
    .drag<SVGGElement, SimulationNode>()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended);
}

onMounted(async () => {
  await nextTick();
  if (props.graphData && containerEl.value) {
    const initialNodes = JSON.parse(JSON.stringify(props.graphData.nodes));
    const initialLinks = JSON.parse(JSON.stringify(props.graphData.links));
    initializeGraph({ nodes: initialNodes, links: initialLinks });
  }
});

watch(
  () => props.graphData,
  (newData) => {
    if (newData && containerEl.value) {
      const newNodes = JSON.parse(JSON.stringify(newData.nodes));
      const newLinks = JSON.parse(JSON.stringify(newData.links));
      // Before re-initializing, try to preserve positions and expanded states
      const oldNodeMap = new Map(
        currentNodes.map((n) => [
          n.id,
          { x: n.x, y: n.y, fx: n.fx, fy: n.fy, isExpanded: n.isExpanded },
        ])
      );
      newNodes.forEach((n: SimulationNode) => {
        const oldState = oldNodeMap.get(n.id);
        if (oldState) {
          n.x = oldState.x;
          n.y = oldState.y;
          n.fx = oldState.fx;
          n.fy = oldState.fy;
          n.isExpanded = oldState.isExpanded;
          // If it was expanded and has children in new data, keep them as children
          if (n.isExpanded && n._children && n._children.length > 0) {
            n.children = n._children;
            n._children = undefined;
          } else if (!n.isExpanded && n.children && n.children.length > 0) {
            n._children = n.children;
            n.children = undefined;
          }
        }
      });
      initializeGraph({ nodes: newNodes, links: newLinks });
    }
  },
  { deep: true }
);

watch([() => props.width, () => props.height], async () => {
  await nextTick();
  if (props.graphData && containerEl.value) {
    // Preserve state on resize as well
    const currentDataCopy = {
      nodes: JSON.parse(JSON.stringify(currentNodes)),
      links: JSON.parse(JSON.stringify(currentLinks)),
    };
    initializeGraph(currentDataCopy);
  }
});

onUnmounted(() => {
  if (simulation) {
    simulation.stop();
  }
  if (containerEl.value) {
    d3.select(containerEl.value).select("svg").remove();
  }
  svg = null;
  g = null;
  simulation = null;
  currentNodes = [];
  currentLinks = [];
});
</script>

<style scoped>
.w-full {
  width: 100%;
}
.h-full {
  height: 100%;
}
.bg-slate-200 {
  background-color: hsl(
    220,
    30%,
    90%
  ); /* Tailwind slate-200 equivalent for the map background */
}
.rounded-lg {
  border-radius: 0.5rem;
}
.shadow-md {
  box-shadow:
    0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.relative {
  position: relative;
}

:deep(.node text) {
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    Oxygen,
    Ubuntu,
    Cantarell,
    "Open Sans",
    "Helvetica Neue",
    sans-serif;
  pointer-events: none;
  font-weight: 500;
}

:deep(.link) {
  transition:
    stroke-opacity 0.15s ease-in-out,
    stroke 0.15s ease-in-out;
}

:deep(.node) {
  transition: opacity 0.15s ease-in-out;
  cursor: pointer;
}

:deep(.node circle) {
  transition:
    r 0.3s ease,
    fill 0.3s ease;
}
:deep(.node text) {
  transition:
    font-size 0.3s ease,
    fill 0.3s ease;
}
</style>

mock数据

// Mock data for D3ForceGraph.vue

export interface Node {
  id: string;
  name: string;
  group: number; // Can be used for coloring or initial categorization
  type: "country" | "category" | "brand" | "brand-detail" | "contributor";
  fx?: number | null; // Fixed x position for D3
  fy?: number | null; // Fixed y position for D3
  children?: Node[];
  _children?: Node[]; // Store hidden children
  isRoot?: boolean; // To identify the main root node
  data?: any; // Additional data for the node
  parent?: string; // id of the parent node
  isExpanded?: boolean;
}

export interface Link {
  source: string; // Node ID
  target: string; // Node ID
  value?: number; // Optional value for link strength or styling
}

export interface GraphData {
  nodes: Node[];
  links: Link[];
}

const brandChildren: Node[] = [
  {
    id: "brand1-detail1",
    name: "品牌1",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail2",
    name: "品牌2",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail3",
    name: "品牌3",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail4",
    name: "品牌4",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail5",
    name: "品牌5",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail6",
    name: "品牌6",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-detail7",
    name: "品牌7",
    type: "brand-detail",
    group: 3,
    parent: "brand1",
  },
  {
    id: "brand1-contributor",
    name: "贡献者刘先生",
    type: "contributor",
    group: 4,
    parent: "brand1",
  },
];

const productChildren: Node[] = [
  {
    id: "product-detail1",
    name: "品牌1",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail2",
    name: "品牌2",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail3",
    name: "品牌3",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail4",
    name: "品牌4",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail5",
    name: "品牌5",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail6",
    name: "品牌6",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-detail7",
    name: "品牌7",
    type: "brand-detail",
    group: 5,
    parent: "product",
  },
  {
    id: "product-contributor",
    name: "贡献者王先生",
    type: "contributor",
    group: 4,
    parent: "product",
  },
];

export const mockGraphData: GraphData = {
  nodes: [
    {
      id: "country-usa",
      name: "美国",
      type: "country",
      group: 1,
      isRoot: true,
      isExpanded: true,
    },
    {
      id: "brand",
      name: "品牌",
      type: "category",
      group: 2,
      parent: "country-usa",
      _children: brandChildren,
    },
    {
      id: "regulation",
      name: "法规",
      type: "category",
      group: 2,
      parent: "country-usa",
      data: { value: 10000 },
    },
    {
      id: "customer",
      name: "客户",
      type: "category",
      group: 2,
      parent: "country-usa",
      data: { value: 10000 },
    },
    {
      id: "patent",
      name: "专利",
      type: "category",
      group: 2,
      parent: "country-usa",
      data: { value: 10000 },
    },
    {
      id: "product",
      name: "产品",
      type: "category",
      group: 2,
      parent: "country-usa",
      _children: productChildren,
    },
    {
      id: "price",
      name: "价格",
      type: "category",
      group: 2,
      parent: "country-usa",
      data: { value: 10000 },
    },
    {
      id: "consumer",
      name: "消费者",
      type: "category",
      group: 2,
      parent: "country-usa",
      data: { value: 10000 },
    },
  ],
  links: [
    { source: "country-usa", target: "brand" },
    { source: "country-usa", target: "regulation" },
    { source: "country-usa", target: "customer" },
    { source: "country-usa", target: "patent" },
    { source: "country-usa", target: "product" },
    { source: "country-usa", target: "price" },
    { source: "country-usa", target: "consumer" },
  ],
};

图例显示

在这里插入图片描述