<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 propertiesinterfaceSimulationNodeextendsNodeType, d3.SimulationNodeDatum {}interfaceSimulationLinkextendsd3.SimulationLinkDatum<SimulationNode>{}const props = defineProps<{graphData: GraphData;
width?: number;
height?: number;}>();const containerEl = ref<HTMLDivElement |null>(null);letsimulation: d3.Simulation<SimulationNode, SimulationLink>|null=null;letsvg: d3.Selection<SVGSVGElement, unknown,null,undefined>|null=null;letg: d3.Selection<SVGGElement, unknown,null,undefined>|null=null;letcurrentNodes: SimulationNode[]=[];letcurrentLinks: SimulationLink[]=[];constNODE_COLORS={country:"gold",// Yellow for countrycategory:"pink",// Purple for categoriesbrand:"red",// Coral/Red for brand parent"brand-detail":"green",// CornflowerBlue for brand childrencontributor:"blue",// MediumSeaGreen for contributors};constNODE_SIZES={country:45,category:35,brand:40,"brand-detail":25,contributor:28,};constFONT_SIZES={country:"12px",category:"10px",brand:"11px","brand-detail":"9px",contributor:"10px",};functiongetNodeColor(node: SimulationNode): string {returnNODE_COLORS[node.type]||"#CCCCCC";// Default color}functiongetNodeSize(node: SimulationNode): number {returnNODE_SIZES[node.type]||20;// Default size}functiongetFontSize(node: SimulationNode): string {returnFONT_SIZES[node.type]||"10px";}functioninitializeGraph(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.return10;// 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 childrenconst 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;elseif(
sourceNode.type ==="category"||
targetNode.type ==="category")
dist =40;elseif(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();}functionupdateGraph(){if(!g ||!simulation)return;// Linksconst 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)");// Nodesconst 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();}functionticked(){
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 arrowheadconst 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})`);}functionhandleNodeClick(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);}});}elseif(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 delaysetTimeout(()=>{
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);}});}elseif(clickedNode.children && clickedNode.children.length >0){// Collapse
clickedNode.isExpanded =false;const nodesToRemove =newSet<string>();functiongatherNodesToRemove(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();}functionhandleNodeMouseOver(event: MouseEvent,hoveredNode: SimulationNode){if(!g)return;
g.selectAll(".node, .link").style("opacity",0.2);const highlightSet =newSet<string>();const linksToHighlight =newSet<SimulationLink>();functiongetRelated(node: SimulationNode,isPrimaryHover: boolean){
highlightSet.add(node.id);// Highlight direct children if expandedif(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 parentif(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);}functionhandleNodeMouseOut(){if(!g)return;
g.selectAll(".node, .link").style("opacity",1);
g.selectAll<SVGLineElement, SimulationLink>(".link").attr("stroke","#555").attr("stroke-width",1.5);}functiondrag(sim: d3.Simulation<SimulationNode, SimulationLink>){functiondragstarted(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;}functiondragged(event: d3.D3DragEvent<SVGGElement, SimulationNode, SimulationNode>){
event.subject.fx = event.x;
event.subject.fy = event.y;}functiondragended(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()=>{awaitnextTick();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 statesconst oldNodeMap =newMap(
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 childrenif(n.isExpanded && n._children && n._children.length >0){
n.children = n._children;
n._children =undefined;}elseif(!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()=>{awaitnextTick();if(props.graphData && containerEl.value){// Preserve state on resize as wellconst 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.vueexportinterfaceNode{id: string;name: string;group: number;// Can be used for coloring or initial categorizationtype:"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;}exportinterfaceLink{source: string;// Node IDtarget: string;// Node ID
value?: number;// Optional value for link strength or styling}exportinterfaceGraphData{nodes: Node[];links: Link[];}constbrandChildren: 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",},];constproductChildren: 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",},];exportconstmockGraphData: 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"},],};