跳转至

B2 · 密集力导向图

网络 180 节点力导向星系的 Interactive Big Chart 真实参考实现。示例结论为“一百八十项服务,自成星系”,副标题已经把图例、单位或时间口径写在图旁:8 个领域自行聚拢 · 节点代表调用量 · 拖动任意节点可观察网络回弹 · 点击空白处重播

技术档案

项目 约定
数据形状 网络 180 节点力导向星系
场合 大型服务网络、依赖关系
读者时间 交互探索
渲染引擎 ECharts
交互与动效 拖拽回弹、hover 聚焦
示例来源行 FORCE GRAPH · DENSE · SOURCE · SERVICE MESH

实现与使用边界

  • 页面直接复用 gallery 中 “A galaxy of one hundred eighty services” 的卡片结构和同名渲染块,没有按截图重新绘制。
  • 标题、副标题、图形、来源行四件套保持原样;数据编码、比例关系、灰阶和动画节奏由原实现决定。
  • 使用时只替换数据与文案,并保持“网络 180 节点力导向星系”这一数据契约;若数据本体改变,应回到总目录重新选型。
  • 颜色限于 Mono 灰阶,面积编码继续使用平方根换算;柱长、位置与角度编码不得断轴或视觉夸大。

模板来源:Lieflat Charts B2 Force Graph Dense,真实骨架取自 templates/big-force.html 的 “A galaxy of one hundred eighty services” 卡片与对应渲染块。

b2-force-graph-dense.html —— 自包含单页源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Lieflat Charts — B2 密集力导向图</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
<link rel="stylesheet" href="../../../../vendor/fonts/fonts.css">
<style>
  :root{--bg:#F0EFEB;--dark:#1C1C1A}
  *{margin:0;padding:0;box-sizing:border-box}
  body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
    display:flex;justify-content:center}
  .card{background:var(--dark);color:#F0EFEB;border-radius:24px;padding:34px 34px 24px;
    width:min(1100px,100%)}
  h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
  .sub{font-size:12px;color:#8F8E88;margin-bottom:10px}
  .src{font-size:9.5px;color:#55554F;margin-top:12px;letter-spacing:.08em;font-weight:500}
  #ch{height:78vh;min-height:620px;cursor:grab}
</style>
</head>
<body>
<div class="card">
  <h2>一百八十项服务,自成星系</h2>
  <div class="sub">8 个领域自行聚拢 · 节点代表调用量 · 拖动任意节点可观察网络回弹 · 点击空白处重播</div>
  <div id="ch"></div>
  <div class="src">FORCE GRAPH · DENSE · SOURCE · SERVICE MESH</div>
</div>
<script>
const INK='#1C1C1A',PAPER='#F0EFEB';
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic

// ── 8 domain hubs, each with a swarm of services; hubs interconnect ──
const DOMAINS=[
  ['auth',26,'#F0EFEB'],['billing',24,'#DCDAD2'],['content',28,'#C9C7BD'],
  ['search',20,'#B3B0A4'],['notify',22,'#9C9A91'],['media',20,'#8F8E88'],
  ['analytics',18,'#7C7A72'],['edge',14,'#6A6963'],
];
const nodes=[],links=[];
DOMAINS.forEach(([dom,count,shade],di)=>{
  // the hub
  nodes.push({name:dom,value:60+Math.round(rnd(di+1,3)*50),hub:true,shade,dom:di});
  // satellites: long-tail sizes, a couple of mid-weights per domain
  for(let i=0;i<count;i++){
    const v=i<3?14+rnd(di+2,i+4)*18:2+rnd(di+5,i+9)*9;
    const name=dom+'-'+String(i+1).padStart(2,'0');
    nodes.push({name,value:Math.round(v),hub:false,shade,dom:di});
    // satellite -> its hub
    links.push({source:name,target:dom,
      lineStyle:{width:.5+v*.05,color:'#3F3E38',opacity:.5}});
    // occasional satellite-to-satellite shortcut inside the domain
    if(i>1&&rnd(di+3,i+11)<.22){
      links.push({source:name,target:dom+'-'+String(1+Math.floor(rnd(di+4,i+13)*i)).padStart(2,'0'),
        lineStyle:{width:.5,color:'#33322D',opacity:.4}});
    }
  }
});
// hub-to-hub backbone: every domain talks to 2-3 others
const H=DOMAINS.length;
for(let a=0;a<H;a++)for(let b=a+1;b<H;b++){
  if(rnd(a+7,b+9)<.42){
    links.push({source:DOMAINS[a][0],target:DOMAINS[b][0],
      lineStyle:{width:1.6+rnd(a+2,b+3)*2.2,color:'#55534B',opacity:.65,curveness:.08}});
  }
}
// a few cross-domain satellite shortcuts — the messy real-world wires
for(let k=0;k<26;k++){
  const a=nodes[1+Math.floor(rnd(k+1,17)*(nodes.length-1))];
  const b=nodes[1+Math.floor(rnd(k+3,23)*(nodes.length-1))];
  if(a.name!==b.name&&a.dom!==b.dom&&!a.hub&&!b.hub){
    links.push({source:a.name,target:b.name,
      lineStyle:{width:.5,color:'#2E2D29',opacity:.38,curveness:.15}});
  }
}

const g=echarts.init(document.getElementById('ch'));
const opt={
  animationDuration:300,
  tooltip:{backgroundColor:PAPER,borderWidth:0,
    textStyle:{color:INK,fontFamily:'Inter',fontSize:12},padding:[10,14],
    formatter:p=>p.dataType==='node'?p.name+' — '+p.value+'k calls/day':''},
  series:[{
    type:'graph',layout:'force',
    force:{repulsion:46,edgeLength:[10,42],gravity:.16,friction:.22,layoutAnimation:true},
    roam:true,draggable:true,
    left:6,right:6,top:6,bottom:6,
    data:nodes.map(n=>({
      name:n.name,value:n.value,
      symbolSize:n.hub?16+Math.sqrt(n.value)*1.6:2.5+Math.sqrt(n.value)*1.5,
      itemStyle:{color:n.shade,borderWidth:n.hub?2:0,borderColor:INK},
      label:{show:n.hub,position:'inside',
        color:INK,fontFamily:'Inter',fontSize:9.5,fontWeight:800},
    })),
    links,
    emphasis:{
      focus:'adjacency',
      lineStyle:{color:PAPER,opacity:.9,width:1.4},
      label:{show:true,color:PAPER,position:'right'},
    },
    blur:{itemStyle:{opacity:.1},lineStyle:{opacity:.03}},
  }],
};
const play=()=>{g.clear();g.setOption(opt)};
play();
// click on empty canvas replays; clicks on nodes are for dragging
g.getZr().on('click',e=>{if(!e.target)play()});
addEventListener('resize',()=>g.resize());
</script>
</body>
</html>