【本章目标】

- 分得清关系图的两份数据:节点表
data(别名nodes)与边表links(别名edges),知道边靠id找节点、id缺省时会退回name再退回序号,也知道对不上时 ECharts 一声不响地把那条边丢掉。 - 会用三种
layout并在具体场景里选对:none把你写的x/y当数据坐标,circular把节点摆到圆上,force用力导向自己找位置;同时确认为什么本章没有第四种forceAtlas2。 - 能报出力导向调参手感:
repulsion、gravity、edgeLength、friction、layoutAnimation各自调大调小,图形会长成什么样、代价是多少毫秒;能把冷却公式friction × 0.992和实测耗时对上账。 - 掌握漫游家族:
roam的四种写法各自的真实行为、roamTrigger的触发区、nodeScaleRatio的直径公式,以及scaleLimit如何在把zoom回写 option 之前先把它夹进区间。 - 会用 6.x 新增的顶层组件
thumbnail:它只认关系图系列,把同一份数据再画一遍微型版(本章给出元素数账本),白色窗口就是当前视窗,拖动窗口会把center写回 option。 - 能用
categories做分类取色与图例联动,能自己算节点度数并写回symbolSize,能用edgeSymbol、curveness、autoCurveness画清有向图与平行边。 - 完成两张实战图:19 个节点、21 条边的门店供应链网络(力导向 + 缩略图 + 邻接高亮 + 按度数放大),和一张用分层坐标画的本章工程依赖图。
关系图是本书第一张没有坐标轴的图。前面二十五章里,一个数落在哪儿,是由 xAxis、yAxis、角度或者经纬度决定的;关系图里没有这些东西,位置要么你自己给,要么让算法自己找。这个差别会一路影响本章的每个配置:layout 决定位置从哪来,x/y 与 fixed 决定你介入多深,漫游与缩略图决定图画大了以后怎么看。
【原理先行】
两张表,不是一张表
series-graph 的数据是两份:节点表和边表。
series: [{
type: 'graph',
data: [{ id: 'wh', name: '上海仓' }, { id: 'S01', name: '南京西路店' }],
links: [{ source: 'wh', target: 'S01' }]
}]
data 与 nodes 是同一件事,links 与 edges 也是。源码取值的顺序写死在 GraphSeries.js:87-88:先看 edges 再看 links,先看 data 再看 nodes——同时写两份时,排在前面的那个生效。本章实测过:data 给三个点、nodes 给两个点且内容不同,画出来是 data 的那三个。边表同理:edges 与 links 同时写,edges 赢。
source 和 target 填的是节点的标识,而这个标识是怎么定出来的:createGraphFromNodeEdge.js:57-59 依次取 id、name、dataIndex,第一个不是 null/undefined 的就用。于是有三种现实情况:节点只写 name,边就填 name(能对上);节点写了 id,边必须填 id——填 name 会静默失效。本章实测过这条:三个带 id 的节点,边用 name 去指,节点画了 3 个、边画了 0 条,控制台没有任何提示,SVG 从 1457 字节缩到 904 字节。丢边的判断在 createGraphFromNodeEdge.js:68-73:graph.addEdge() 对不上就返回假,这条边连 validEdges 都进不去,不报错、不警告。
第三条最容易踩:data 不能写成散点那种纯数组。data: [[40,60],[240,60]] 在 series-scatter 里是坐标,在 series-graph 里会被当成「一个只有 value 维度的数据项」(实测 data.count=2,维度只有 ["value"],getItemLayout(0) 是 [null,null]),结果画布上一个元素都没有。关系图的节点必须是对象,坐标字段名就叫 x、y。
三种 layout,解决三个问题
layout 的类型在 6.1.0 的 shared.d.ts:11520 写得很清楚:'none' | 'force' | 'circular',缺省是 null(GraphSeries.js:207)。三者的分工完全不同。
none 是「位置我全包」。你写的 x/y 就是数据坐标,ECharts 只负责把数据矩形塞进一个视窗框(这个框怎么算,面板二给实测);不写 x/y,点就没有位置,画布空白(实测:layout:'none' 且不写 x/y,节点元素 0、边元素 0,不报错)。它适合位置本身是信息的图:分层架构图、依赖图、你自己排好的流程网络。本章面板十一用的就是它。
circular 是「我不在乎谁连着谁,先把人摆到圆上」。它不看你写的 x/y(circularLayout.js 自己重算所有点),圆心取坐标系包围盒(也就是面板二那个视窗框)的中心,半径取包围盒短边的一半(circularLayoutHelper.js 里写的是 cx = rect.x + rect.width/2、r = Math.min(rect.width, rect.height)/2)。节点在圆上的间隔不是等分,而是按 symbolSize 加权:circularLayout.js:51 调 circularLayout(seriesModel, 'symbolSize'),circularLayoutHelper.js:127-152 用 asin(symbolSize/2/r) 给每个节点分配弧长——点越大,占的角度越多。本章面板一实测:7 个节点里有一个直径 22、其余 14,量得相邻极角 52.5°、50.7°、51.1°、51.4°、51.1°、50.7°、52.5°,而严格等分应当是 360°/7 = 51.4°;那两档偏大的角正好夹着大节点。circular.rotateLabel:true 会把标签转成切线方向,本章后面有它的实测行为。
force 是「位置让算法自己找」。弹簧力、向心力、斥力三种力互相拉扯,摩擦系数一路衰减到阈值才停。它的输入是你写的 x/y(当起点)或者随机初值,输出是收敛后的位置;输出不会写回 option。数据里写了 x/y 的,getOption() 读回来还是原值(面板一按「再跑一次」前后,七个圆心逐字相同);没写的,getOption() 里始终是 null(面板四按下①后五个数据项读回 x=null y=null fixed,右图照样把五个点摆成环——位置只活在布局器内部)。所以你在页面上看到的力导向图,位置和 option 是两套东西——这一点和 roam 不一样,roam 会把 center/zoom 写回去(View.js:596-600)。
没有第四种。 6.1.0 的 layout 里没有 forceAtlas2:本章在 node_modules/echarts/lib 与 types/dist 全量搜索这个串,零命中;shared.d.ts:11520 的联合类型里也只有那三个。网上的 ECharts 4/5 教程常提到 force.atlas2,那是另一套实现,不要往 6.1.0 里抄。更要紧的是写错以后的现象:layout:'circle'(少个 ular)不会有任何「不认识的取值」警告,未知值不进任何布局阶段(simpleLayout.js:77),随后 adjustEdge.js:116 去读那个从没被初始化过的字段,抛 Cannot read properties of undefined (reading '__original');但如果这份数据一条边都没有,抛错的那段代码根本不会执行,于是变成「画布一片空白、什么错都没有」。同一个错误有两种截然不同的表现,这是本章第一条常见坑的由来。
力导向里到底发生了什么
layout:'force' 的缺省参数写在 GraphSeries.js:213-223:repulsion:[0,50]、gravity:0.1、friction:0.6、edgeLength:30、layoutAnimation:true。每一帧(layoutAnimation:false 时是一次性循环)做的是同一件事:
- 随机初值。节点没有
x/y时,forceHelper.js:77-81用Math.random()把它撒在画布中心附近。这就是为什么同一份数据刷新两次,力导向图的形状不一样。 - 弹簧力(
forceHelper.js:132-139):每条边把它两个端点往edgeLength的目标长度上拉或推,超了就往里收,不够就往外撑。 - 向心力(
:142-151):每个节点被往整个图的质心拉,强度是gravity。 - 斥力(
:154-169):两两之间算一次斥力,强度是repulsion;两个点恰好重合(距离为 0)时再加一个Math.random()把它们抖开。这一步是O(n²),是力导向慢的全部原因。 - 积分与冷却(
:170-180):位置的增量乘上当前friction,然后friction *= 0.992;friction跌破0.01就判定收敛(forceHelper.js的step把finished交给回调)。所以friction既是步长,也是计时器。
第 5 步给了一个可以直接用的步数公式:从 friction 出发,每步乘 0.992,直到低于 0.01——
步数 = ceil( log(0.01 / friction) / log(0.992) )
friction:0.6 → 510 步,0.3 → 424 步,0.05 → 201 步,0.02 → 87 步,0.9 → 561 步。本章按这个公式对过账:2000 个节点跑 510 步实测 27028 ms(每步 53.0 ms),把 friction 改成 0.05 只跑 201 步,公式预测约 10652 ms,实测 10667 ms——误差 0.14%。也就是说,friction 是力导向的「预算旋钮」,它直接决定你要付多少毫秒。
repulsion 和 edgeLength 还能写成 [小, 大] 两个数的数组。这时它按数据项的 value 线性插值(forceLayout.js:82-105),而且两者的方向不一样:repulsion 是 value 越大斥力越大,edgeLength 是 value 越大边越短。本章实测过这条反直觉的规则:三条边的 value 分别是 0、5、10,edgeLength:[30,120] 收敛后的边长是 102.3、59.6、27.5(value 最大的最短),把数组反过来写 [120,30] 就变成 27.4、59.5、102.4。同一次实验里,标量 edgeLength:30 得到 33.1、33.2、33.1,标量 120 得到 96.4、89.1、96.6——标量的目标长度和实际收敛长度不相等,因为斥力与向心力也在场,edgeLength 只是弹簧的原长。
还有两个只在特定条件下起效的键:force.initLayout('circular'|'none',shared.d.ts:11561 附近)决定迭代之前先怎么摆一次,本章面板四量过它连 fixed 的位置都会覆写;节点上的 ignoreForceLayout:true 让这个点不参与迭代(读的位置在 forceLayout.js:112,跳过的位置在 forceHelper.js:126),适合「这几个点我要钉住、其余点随便它怎么动」。
边的两端:符号、弯曲与平行边
edgeSymbol 是两端的符号,缺省 ['none','none'](GraphSeries.js:232)——也就是说,默认关系图的边没有箭头。归一化规则在 edgeVisual.js:47-52:写成字符串 'arrow' 会被当成 ['arrow','arrow'](两头都有);但写成只有一个元素的数组 ['arrow'],第二端是 undefined,那一端不画。想要单向箭头,老老实实写 ['none','arrow']。
箭头不是「盖在线上」的符号,它会把线缩短。adjustEdge.js:128-168 只把带符号的那一端往回收。本章实测(节点直径 20、edgeSymbolSize 用默认 10):['none','none'] 时线两端各让位 0;['none','arrow'] 时终端让位 6.3、起端 0;['arrow','arrow'] 时 6.3/6.3。把节点改成扁的 [30,10] 或高的 [10,30],让位量还是 6.3——因为 graphHelper.getSymbolSize()(:53-58)取的是宽高平均值,边不知道节点其实是椭圆。这三行读数解释了本章面板八那个「偏移/弦长不再正好等于 curveness」的现象:弦本身被缩短了。
lineStyle.curveness 是二次贝塞尔控制点的偏移比例,基数是数据坐标下的圆心距:simpleLayoutHelper.js:61 把控制点推出 curveness × 圆心距。实测:A(40,120) 到 B(200,120) 圆心距 160,curveness:0.3、箭头关掉时读到「弦长=160 偏移=48」,48/160=0.3 分毫不差;curveness:0.8 稳态应是 128。曲线在屏幕上真正鼓出来的高度大约是这个偏移的一半(二次贝塞尔在中点处的偏离是控制点偏移的一半),所以想让线鼓出 30 像素,curveness 要比直觉的值大一倍。正负号决定凸向弦的哪一边。
同一对节点之间画多条边时,全部用同一个 curveness 会完全重叠。autoCurveness:true 让 ECharts 自己分配档位:multipleGraphEdgeHelper.js:56-83 生成一张 20 项的表 [0, -0.2, 0.2, -0.4, 0.4, -0.6, 0.6, -0.8, 0.8, -1, 1, ...],按「同一对节点的第几条边」依次取。实测:三条平行边(圆心距 280)收敛成一条直边(弦长 280、无控制点)和两条 偏移=56 的弧(56/280=0.2),正好是表里的 0、-0.2、0.2 三档。autoCurveness 也可以给一个数字(用几个档位)或一个数组(自己指定档位表)。注意一个互斥关系:shared.d.ts:11571 附近写明,一旦你在边上写了 lineStyle.curveness,autoCurveness 就不起作用——显式优先。
大图的两条救生索:漫游与缩略图
图画到几百个节点以后,第一件事是让人能自己看:roam。它的类型是 boolean | 'pan' | 'move' | 'zoom' | 'scale'(shared.d.ts:7697),关系图缺省 false(GraphSeries.js:239)。RoamController.js:117 与 :122 是两个独立的判断分支:一个管拖动平移、一个管滚轮缩放。所以 true 两者都开,'move'/'pan' 只开平移,'zoom'/'scale' 只缩放;写一个不认识的字符串(比如 'nope')两个分支都不进——等于静悄悄关掉,不报错。本章在真浏览器里把这五种取值逐一试过,读数见面板五。
触发区是第二个坑。roam:true 不代表整张画布都能响应滚轮:GraphView.js:108 把事件交给坐标系的 containPoint,而关系图坐标系的 containPoint(View.js:224-229)判的是「数据点撑出来的包围盒经过当前缩放变换之后的矩形」。数据缩在左下角时,右上大片留白里滚轮没有任何反应。roamTrigger:'global' 把触发区换成整张画布——这是 6.0 为树图、桑基、矩形树图加的键(那几个图缺省就是 'global'),关系图缺省是 null(shared.d.ts:7715),所以它的默认手感和其他漫游图不一样。
nodeScaleRatio 决定「放大时节点跟不跟着变大」,缺省 0.6(GraphSeries.js:244)。生效公式在 View.js:609-613:
节点屏幕直径 = symbolSize × ((zoom − 1) × (nodeScaleRatio || 1) + 1)
注意那个 || 1:写 nodeScaleRatio:0(你本来想「节点完全别跟着缩放」)会被当成 1,节点缩放满格。本章实测 zoom:2 时 0 那档得到 40 px,跟 1 那档一模一样;想要「不缩放」得写 0.001,实测直径 20.02 px。
第二条救生索是 thumbnail 缩略图(6.x 新增)。它常被误当成系列里的一个键,其实它是顶层组件(thumbnail 与 series 平级,类型是 ThumbnailOption,shared.d.ts:10559-10566)。ThumbnailModel.js:85-104 解析目标时只接受 graph 系列:不写 seriesIndex 时它自己去找第一个 graph 系列,找不到就安静地不出现(本章实测:只有柱状图 + thumbnail:{} 时,缩略图矩形数 0、控制台无异常、图照画);显式把 seriesIndex 指向非 graph 系列,开发构建会在源码的一个空指针上抛 TypeError(ThumbnailModel.js:90-92 先把 series 置成 null 再读 series.subType),生产版则把那段提示编译掉、同样安静。缺省样式在 :114-132:show:true、right:1、bottom:1、width/height 都是 '25%'、itemStyle.borderWidth:2、窗口 windowStyle.opacity:0.3。
它画的是什么?GraphView.js:293-324 把主图的节点和边克隆一份,缩到微型、挂在 z2:151。本章算过这笔账:64 个节点、112 条边的网格图,不写 thumbnail 时 displayList 是 176 个元素(112 个 ec-line + 64 个 path);写了以后是 354 个——176 主图 + 176 克隆 + 2 个矩形(白底外框 + 半透明窗口)。所以面板六读到「path 128 / 线 224」不是重复绘制出 bug,而是缩略图的固有开销;克隆出来的微型节点直径只有 0.4 px(黄框按直径分档,正好分成主图 8 px 与克隆 0.4 px 两档)。窗口尺寸满足 窗口 = 迷你内框 ÷ zoom,因此 zoom:1 时窗口正好铺满缩略图,放大一档它就按比例缩小;拖动窗口,主图平移、center 被写回 option。
分类、图例与「谁决定颜色」
categories 是分类表,节点用 category 指向它——可以是序号(0、1)也可以是名字('门店'),categoryVisual.js:77-84 两种都认。图例项就是 categories 里的 name,所以 legend.data 和图例能不能对上,取决于你有没有给分类起名。取色优先级本章用三张对照图量过(面板七):数据项的 itemStyle.color > 系列 itemStyle.color > categories 调色板。中间那张只多写了系列色,三个分类的 9 个节点立刻全变成 #c0504d——「分类颜色不生效」的现场就是这个(黄框里数到 12 个填充,是因为 3 个图例图标也是 path)。分类序号越界也不报错:categories 只有 2 项、数据写 category:3,categoriesData.getItemVisual(3,'style') 取不到值,那个点退回调色板第一色 #5070dd,颜色照样有、含义全丢。
节点大小是另一件事:graph 不会自动把度数映射到 symbolSize。symbolSize 的缺省是 10(GraphSeries.js:231)。想让「连得多的点画得大」,要自己数边再写回数据项——本章 min.html 与面板十里都是那七行 degree 统计代码。这件事值得单独学,因为「按度数放大」是关系图最有信息量的一层编码。
【最小可跑】
ch26/min.html 是一张能双击打开的六节点关系图:两个中心仓、四家门店。数据只有两份表——NODES 六个节点、LINKS 五条边;上海仓连三家店,广州仓连一家店,两个仓之间还有一条虚线。页面不读任何外部数据,file:// 直接打开就能画。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>第 26 章最小可跑:一张关系图,仓配网络</title>
<style>
body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
h1 { font-size: 20px; margin: 0 0 6px; }
#chart { width: 760px; height: 420px; border: 1px solid #d9d2c7; }
#status { margin-top: 8px; padding: 8px 12px; background: #fff8ea; border-left: 4px solid #b6902f;
font: 13px/1.7 Consolas, monospace; }
</style>
</head>
<body>
<h1>一张关系图:两个仓喂四家店</h1>
<p>力导向布局,节点大小按连接数,拖动节点可以挪位置,滚轮缩放、空白处拖动平移。</p>
<div id="chart"></div>
<div id="status">正在建图……</div>
<script src="../../lib/echarts.min.js"></script>
<script>
var CATS = [{ name: '中心仓' }, { name: '门店' }];
var NODES = [
{ id: 'wh-sh', name: '上海仓', category: 0 },
{ id: 'wh-gz', name: '广州仓', category: 0 },
{ id: 'S01', name: '南京西路旗舰店', category: 1 },
{ id: 'S05', name: '湖滨银泰店', category: 1 },
{ id: 'S09', name: '新街口店', category: 1 },
{ id: 'S04', name: '天河城店', category: 1 }
];
var LINKS = [
{ source: 'wh-sh', target: 'S01' },
{ source: 'wh-sh', target: 'S05' },
{ source: 'wh-sh', target: 'S09' },
{ source: 'wh-gz', target: 'S04' },
{ source: 'wh-sh', target: 'wh-gz', lineStyle: { type: 'dashed' } }
];
// 度数自己算:graph 不会替你把「连了几条边」映射到 symbolSize
var degree = {};
NODES.forEach(function (n) { degree[n.id] = 0; });
LINKS.forEach(function (l) { degree[l.source]++; degree[l.target]++; });
NODES.forEach(function (n) { n.symbolSize = 14 + degree[n.id] * 5; });
var chart = echarts.init(document.getElementById('chart'), null, { renderer: 'svg' });
chart.setOption({
animation: false,
legend: { data: ['中心仓', '门店'], top: 8 },
tooltip: {},
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 8,
categories: CATS,
data: NODES,
links: LINKS,
label: { show: true, position: 'right', fontSize: 12 },
force: { repulsion: 260, edgeLength: 90, gravity: 0.05, layoutAnimation: false },
emphasis: { focus: 'adjacency' },
lineStyle: { color: '#86878c', width: 1, opacity: 0.6 }
}]
});
var el = document.getElementById('status');
var paths = document.querySelectorAll('#chart path').length;
el.textContent = NODES.length + ' 个节点、' + LINKS.length + ' 条边;'
+ '度数最大的是 ' + NODES.reduce(function (a, b) { return degree[a.id] >= degree[b.id] ? a : b; }).name
+ '(' + Math.max.apply(null, NODES.map(function (n) { return degree[n.id]; })) + ' 条);'
+ 'SVG 里 ' + paths + ' 个 path。';
</script>
</body>
</html>
先看两份表怎么对上。节点都写了 id 与 name,边里填的是 id('wh-sh'、'S01'),不是店名。这一条必须从第一张图就写对:只要节点写了 id,边就必须用 id 指它;改用 name 不会报错,那几条边会安静地消失。
节点大小不是 ECharts 自动算的。min.html 里有一段七行的度数统计:
var degree = {};
NODES.forEach(function (n) { degree[n.id] = 0; });
LINKS.forEach(function (l) { degree[l.source]++; degree[l.target]++; });
NODES.forEach(function (n) { n.symbolSize = 14 + degree[n.id] * 5; });
先把每个节点的度数置 0,再扫一遍边、两端各加一,最后写回 symbolSize。实测三个尺寸:上海仓度数 4(三家店 + 一条仓间虚线),直径 14 + 4×5 = 34;广州仓度数 2(一家店 + 虚线),24;四家门店度数各 1,19。黄框没有替我们算这一步,是这段代码自己算的——graph 不会把「连了几条边」映射到 symbolSize,这是关系图最容易被误会的一点。
force 只给了四个参数:repulsion:260、edgeLength:90、gravity:0.05、layoutAnimation:false。六个点没有写 x/y,所以起点仍是 Math.random() 撒的,刷新一次形状会变;layoutAnimation:false 只是不演动画、一次迭代到收敛,它不消除随机初值。小图里这不要紧,等到了面板三,节点一多、参数一极,形状差异就会变成可读的账。
页面底部的状态条是这张图的验收单,原文是:
6 个节点、5 条边;度数最大的是 上海仓(4 条);SVG 里 21 个 path。
21 个 path 不是「21 个图形元素」那么含糊:6 个节点符号 + 5 条边 + 5 个箭头 = 16,剩下 5 个是图例的两枚图标、一块图例底板和两个文字命中矩形。把图例关掉再数,path 会少 5 个。这个账在面板六还会再算一次,因为缩略图会把同一份图元克隆一遍。
最后两行是交互:roam:true 让滚轮和拖动都能动视图,draggable:true 让节点能被拖走。min.html 没有 fetch,所以双击打开时这两件事也能用;index.html 要读 data/stores.json,必须走本地服务器,这一点在【工程文件】里再说。
【逐步加料】
实验台 ch26/index.html 加 ch26/app.js 一共 11 个面板、26 张图。所有图都用 echarts.init(el, null, {renderer:'svg'}),并关掉动画;黄框里的圆心、直径来自元素的 getComputedTransform(),边的控制点来自 ec-line 的 shape,透明度来自元素状态,不是拿正则去扫 SVG。下面按面板顺序,把每个配置为什么这么写、实测读到了什么、能推出什么讲清楚。
面板一:三种 layout,同一份数据三种长法
一个中心仓、六家店,一共七个节点、七条边,三张图只有 layout 不同。none 认数据里写的 x/y;circular 把它们当没写过,按数据顺序摆到圆周上;force 把它们当起点,迭代到收敛。三张图都读到 path=14、线=7:14 是 7 个节点符号 + 7 个箭头,7 是边。
none 的七个圆心是 (164.3,130)d22、(60.3,26)d14、(251,26)d14、(268.3,147.3)d14、(207.7,234)d14、(77.7,216.7)d14、(51.7,112.7)d14,最小间距 87.1。它们不是数据坐标本身,而是数据坐标经过视窗框映射后的屏幕像素——面板二专门讲这个框。
circular 的圆心换了七个位置,最小间距 89.2。真正要看的是相邻极角:52.5°、50.7°、51.1°、51.4°、51.1°、50.7°、52.5°,严格等分应是 360/7 = 51.4°。两个 52.5° 正好是夹着那个 symbolSize:22 仓库的两段弧,其余五段是两个 14 的店之间的弧——环形布局按 symbolSize 加权分弧长,点越大占的角度越多(circularLayout.js:51 把权重键传成 'symbolSize',circularLayoutHelper.js:127-152 用 asin(symbolSize/2/r) 折算)。所以「圆上等距」只是看起来像,实际是按大小分。
force 这一张写了 x/y,又关掉 layoutAnimation,于是起点固定、迭代同步,落点可以复现。按「① 再跑一次 force」前后各读一次圆心,两串坐标逐字相同:["160.1,131.8","111.4,59.1","183.4,47.4","247.1,114.7","217,199.3","128.3,214.1","72.4,143.5"]。这不是说 force 没有随机数,而是说这一张的每个点都有明确起点,迭代里没有别的地方掷骰子。
按「② 开关 layoutAnimation」打开动画,1 秒后读到的是中间帧 (158,133.3)d22 …,还在往收敛点走;关掉动画后,一次同步迭代就停在最终位置。动画适合演示「图在找平衡」,不适合用来验收坐标——验收要等它停下来。
面板二:viewRect 不是「等比塞满画布」
五节点数据放在 0…300 的正方形里:四角 A/B/C/D 加中心 O,三条边。图容器 460×320。什么都不写时,黄框读到 viewRect x=102 y=32 w=256 h=256、dataRect x=0 y=0 w=300 h=300、横纵都是 0.85 px/单位。这个 256 不是画布的宽,也不是高,而是「按数据宽高比取容器 80%」的结果:正方形数据在 460×320 的画布里,受高度约束,320 × 0.8 = 256,再水平居中到 x=102、垂直居中到 y=32。关系图的 left/top 缺省是 'center'(GraphSeries.js:224-225),所以不写版式键时它天然居中。
四个按钮把版式键拆开:
| 按钮 | 写入的版式键 | viewRect | 1 个数据单位 |
|---|---|---|---|
| 复位 | 什么都不写 | x=102 y=32 w=256 h=256 | 横 0.85/纵 0.85 px |
| ① | left:40, top:20 | x=40 y=20 w=256 h=256 | 横 0.85/纵 0.85 px |
| ② | 再加 width:180, height:180 | x=40 y=20 w=180 h=180 | 横 0.6/纵 0.6 px |
| ③ | right:30, bottom:30 | x=130 y=95 w=200 h=130 | 横 0.67/纵 0.43 px |
第①档最容易误会:只写 left/top 只移动框,不改框的大小;宽高都没写时仍走「80% 那一维」的规则。第②档把宽高都写死,框就是 180×180,每单位 0.6 px。第③档只写 right/bottom,而 left/top 还吃着缺省的 'center'(也就是 50%),于是宽度 460 − 30 − 230 = 200、高度 320 − 30 − 160 = 130,框变成 200×130;横纵比例不同,1 个数据单位横着是 0.67 px、纵着是 0.43 px,数据被拉伸了。
这就是为什么不能说「view 坐标系把 dataRect 等比塞进画布」。它先按 getLayoutRect 算一个框(createView.js:49-55、layout.js:199-256),再把数据矩形逐轴映射进去;dataRect 到 viewRect 的变换是横纵分开的(View.js:317-325),框不方,单位就不等长。想让圆保持圆,得自己把框做成和数据同比例;面板七、面板十里的饼形节点在屏幕上看不看得出来变形,也取决于这一步。
面板三:力导向调参手感
十二个点、十五条边:四组三角形(12 条边)加三条桥(0-3、6-9、2-5),六张 220×190 的小图,只改一个参数,全部 layoutAnimation:false。读数如下——这是一次运行的值,六张图都没写 x/y,随机初值不同,绝对值每次刷新会变;要看的是同一行里的相对差异。
| 图 | 改动 | 像素包围盒 | 最小间距 | 读法 |
|---|---|---|---|---|
| 默认 | 无 | 111.3×116.4 | 16.8 | 12 个点在 220×190 里刚好铺开 |
| 斥力 1 | repulsion:1 | 36.5×32.9 | 2.9 | 点挤成一团,拓扑看不出 |
| 斥力 3000 | repulsion:3000 | 626.4×942.5 | 112.5 | 撑出画布四倍,必须配漫游/缩略图 |
| 向心 0.02 | gravity:0.02 | 110.2×204 | 20.8 | 向心力小了,图被拉成长条、更松 |
| 边长 300 | edgeLength:300 | 337.8×328.5 | 28.2 | 边整体变长,小画布装不下 |
| 摩擦 0.9 | friction:0.9 | 113.7×117 | 30.2 | 多迭代 51 步,点距更匀,代价约 10% |
这六行就是「调参手感」的可核对版本。repulsion 决定点与点之间的排斥,调到 1 等于取消排斥,点和边缩成一团;调到 3000 会撑出画布,此时没有 roam 和缩略图就没法看。gravity 决定向质心的拉力,调小图会松散、拉长;edgeLength 是弹簧原长,调大边整体变长;friction 决定冷却速度,调大迭代步数变多、收敛更充分。layoutAnimation 不是手感参数,而是「演不演」:关掉一次算完,打开逐帧演。
怎么选起点?本章的建议是:十到五十个点先用 repulsion:150–300、edgeLength:60–100、gravity:0.05–0.1、friction:0.6、layoutAnimation:false,先看拓扑对不对,再微调;上百个点把 edgeLength 改成 [小, 大] 的数组、按边的 value 拉开长短,并配 roam;上千个点先算预算再决定要不要用 force——本章 2000 个节点、friction:0.6 要跑 510 步,实测 27028 ms;把 friction 降到 0.05 只跑 201 步,实测 10667 ms,和公式预测的 10652 ms 只差 0.14%。面板三的 500 节点那一档是 738 ms(500 点、500 边、layoutAnimation:false、容器 220×190),这已经是「点一次按钮等约一秒」的量级。
按「③ 开动画跑 1.2 秒」能在黄框里看到三次包围盒:0 ms 944.6×953.4、413 ms 774.8×858.8、1201 ms 793.2×880.8。第一帧最散,四百毫秒已经收得差不多,之后在一个小区间里抖——这就是冷却过程;layoutAnimation:false 就是把这段过程压成一次同步循环。
面板四:x/y、fixed、initLayout 与拖拽
五个人,阿哲是 fixed:true,写了 x:40,y:60,符号是 20×20 的方块;小林、Nana、老周、Momo 都只写 x/y。左图 initLayout:'none',阿哲留在写死的位置,屏幕圆心 (68.2,51.8)d20;右图 initLayout:'circular',五个人先被摆成环,阿哲的圆心变成 (307.1,220.5)d20——连 fixed 的位置也被初摆覆写。initLayout 只在迭代前摆一次,之后 fixed 才生效。
按「① 只给 fixed,不写 x/y」:五个数据项都变成 fixed:true,但没有坐标;黄框第一行读回 x=null y=null fixed,左图的节点圆心为空,path 数为 0。fixed 的语义只是「别被力推开」,它不替你补坐标;没有 x/y,落点是 NaN,缩放里一旦出现 NaN,整幅图就空了。
按「② 拖完一个节点再读 option」时,先在左图把任意一个节点拖到别处,再点按钮。黄框第一行仍然是 ["阿哲 x=40 y=60 fixed","小林 x=260 y=40",…],和拖之前一字不差。拖拽的真相在 GraphView.js:127-164:force 布局拖动时调 forceLayout.setFixed(idx),松手 setUnfixed(idx);circular 拖动时把节点的内部 layout 标成 fixed:true 并重跑环形布局;none 只是把元素位置写回 itemLayout、再重算边。三条路径都只改布局器/元素,不回写 getOption().data。所以「拖完节点会变成 fixed:true」是半句真话:内部布局器确实临时钉住了它,但 option 里没有这回事;刷新页面或重新 setOption,节点回到你写的 x/y(没写就重新随机)。
面板五:roam 家族的真实手感
二十个点摆成 5×5 网格,layout:'none',roam:true,nodeScaleRatio:0.6。这一面板的读法必须用真浏览器:滚轮、拖动、center 回写都不是静态读数能替代的。本章用 CDP 在无头 Chrome 里逐档试过,结论如下。
先看触发区。缺省 roamTrigger:null,触发区是「数据点撑出来的包围盒经过当前缩放变换后的矩形」(GraphView.js:108 把判断交给坐标系的 containPoint,View.js:224-229 判的是这个矩形)。在包围盒外的留白滚 3 格,zoom 从 1 到 1,一点没动;把指针移到包围盒中心再滚 3 格,zoom 到 1.331。按「⑥ 切 roamTrigger」改成 'global' 后,同样在盒外滚 3 格,zoom 也到 1.331,center 同时被写回 [94.11,74.47]。
再看 roam 的取值。每一档都先复位,然后滚 2 格、拖 (60,−40):
roam | 滚轮 | 拖动 | 读数 |
|---|---|---|---|
false | 不动 | 不动 | zoom 1,center null |
true | 动 | 动 | zoom 1.21,center [101.72,130.52] |
'move' | 不动 | 动 | zoom 1,center [93.68,135.88] |
'scale' | 动 | 不动 | zoom 1.21,center [140,105](缩放本身写的) |
'nope' | 不动 | 不动 | zoom 1,center null |
'nope' 这一行是本章最想让人记住的:写错字符串不报错、不警告,两个分支都不进,等于把漫游悄悄关掉。'move'/'pan' 与 'scale'/'zoom' 是两组同义词,分别只开平移、只开缩放(RoamController.js:117,122)。
nodeScaleRatio 的公式是 直径 = symbolSize × ((zoom − 1) × (nodeScaleRatio || 1) + 1)(View.js:609-613)。在 zoom:2 下逐个试:0 读到 40 px,跟 1 一模一样——因为源码里写的是 || 1,0 被当成 1;0.001 读到 20.02 px,这才是「几乎不缩放」;0.3 是 26 px,0.6 是 32 px,1 是 40 px。想让节点在放大时保持原大小,写 0.001,别写 0。
scaleLimit 是最后一道闸。写 {min:0.5,max:2} 后,滚进 14 格读到 option.zoom=2,再滚出 24 格读到 0.5;不写限幅时同样的操作能到 3.797 和 0.386。关键在「读 option」:关系图会把夹过的 zoom 写回 option(View.js:596-600),所以 getOption().series[0].zoom 就是你看到的那个数,不是「实际变换与请求值各一套」。这一点和地图章节的 scaleLimit 读法要分开记。
面板六:thumbnail,把整张图再画一遍
左边一张 8×8 网格、64 个节点、112 条边,layout:'none',不写 thumbnail;右边同一份数据,加顶层组件 thumbnail:{show:true,right:2,bottom:2,width:'24%',height:'24%'}。黄框第一行左边 path 总数=64、线=112,右边 path 128、线 224——所有图元正好翻倍。翻倍不是 bug:缩略图把主图的节点和边克隆一份、缩到微型、挂在 z2:151(GraphView.js:293-324);displayList 的完整账是 176(112 条 ec-line + 64 个节点 path)变成 354(主图 176 + 克隆 176 + 白底和窗口 2 个矩形)。黄框里 path 数的是 SVG 的 path,线 数的是 displayList 里的 ec-line,两个口径都对,只是别混着加。
克隆出来的点有多小?按直径分档读到主图 8 px 一档 64 个、克隆 0.4 px 一档 64 个。0.4 px 已经是亚像素,肉眼看不见,但元素确实在——这就是「缩略图元素数是主图两倍」的直观证据。
白色矩形是当前视窗。zoom:1 时读到 91.7×68.8,乘以 zoom 还原出迷你内框也是 91.7×68.8;按「① 放大一档」到 zoom:1.6,窗口缩到 57.3×43,而 57.3×1.6 ≈ 91.7、43×1.6 ≈ 68.8。关系是 窗口 = 迷你内框 ÷ zoom:放大,窗口变小;缩小,窗口变大。主图的内框是 x=80 y=30 w=240 h=240,缩略图只是把这 240×240 再缩进右下角。
把缩略图里的白框拖到别处,主图跟着平移,center 被写回 option——本章拖完读到 [-12.678571428571061,48.39285714285715]。这是 thumbnail 与 roam 的第二次联动:滚轮改 zoom,拖窗口改 center,两个都写回 option,和力导向「输出不写回」正好相反。
thumbnail 是顶层组件,不是 series 里的键。它只接受 graph 系列(ThumbnailModel.js:85-104)。不写 seriesIndex 时它自己去找第一个 graph 系列,找不到就什么都不画;本章用「只有柱状图 + thumbnail:{}」试过:图照画、缩略图矩形 0 个、控制台无异常。若你显式写 thumbnail:{seriesIndex:0} 指向柱状图,6.1.0 的源码还有个小 bug——ThumbnailModel.js:90-92 先把 series 置成 null,再拼 'series.' + series.subType,开发构建会直接抛 TypeError: Cannot read properties of null (reading 'subType');生产版 lib/echarts.min.js 把这段提示编译掉了,所以只是安静地不出现。缺省值是 show:true、right:1、bottom:1、width/height:'25%'、itemStyle.borderWidth:2、窗口 windowStyle.opacity:0.3(:114-132)。
面板七:categories、图例与「谁决定颜色」
九节点、六条边:两个中心仓、四家门店、三个产地。三张图的数据一模一样,只依次加一层颜色配置,这样能把「谁盖谁」量成一条清楚的优先级。
| 图 | 只改了什么 | 节点元素的填充色统计 |
|---|---|---|
| 左 | categories 三项,节点写 category | {"#5070dd":3,"#b6d634":5,"#505372":4} |
| 中 | 再加系列 itemStyle.color:'#c0504d' | {"#c0504d":12} |
| 右 | 再给三个产地加数据项 itemStyle.color:'#4caf50' | {"#c0504d":9,"#4caf50":3} |
先把「12」这个数解释掉:图上明明只有 9 个节点。填充色统计用的是 nodeEls(),它把图例图标也算进去了——三个分类各有一枚图标。左图 3 蓝 = 2 个仓 + 1 枚「中心仓」图标,5 绿 = 4 家店 + 1 枚「门店」图标,4 灰 = 3 个产地 + 1 枚「产地」图标。中图 12 红 = 9 个节点 + 3 枚图标,因为系列色把分类调色板整片盖掉。右图 9 红 = 6 个节点 + 3 枚图标,3 绿 = 3 个产地节点;图例图标不会跟着数据项变色,所以「产地」那枚还是红的。
优先级是:数据项 > 系列 > 分类调色板。 这三张图就是它的现场。最常见的「categories 不生效」不是分类写错,而是某处又写了系列 itemStyle.color。另外两个不报错的边角:category 也可以写名字(categoryVisual.js:77-84 用 'ec-' + 名字 去查表);写越界序号(categories 两项、节点写 3)时 categoriesData.getItemVisual(3,'style') 取不到值,extend(style, undefined) 什么也不做,节点退回分类调色板第一色 #5070dd——颜色还在,分类含义没了。
图例项直接来自 categories 的 name,不写 legend.data 也能生成三枚。按「① 图例里关掉『门店』」:4 家门店的节点一起消失,剩下 2 仓 + 3 产地 + 3 枚图标 = 8 个填充,读数变成 {"#5070dd":3,"#505372":4,"#cfd2d7":1};那个 #cfd2d7 是「未选中图例图标」的灰,不是哪个分类的颜色。legend.selected 同时读回 {"门店":false,"中心仓":true,"产地":true}。按「② 全部恢复」回到 3/5/4。
面板八:箭头、curveness 与平行边
四个节点 A/B/C/D、三条边,layout:'none',symbolSize:16,edgeSymbolSize:8。左图把「箭头开关」和「curveness」拆成两个按钮,右图专门画三条平行边。
先看箭头。默认 edgeSymbol:['none','arrow'],按「③」等动画停下来读数:
| 箭头 | 弦长(数据坐标) | 控制点偏移 | 偏移/弦长 |
|---|---|---|---|
| 开 | 154.13 / 134.13 / 94.12 | 44.52 / 38.53 / 26.54 | 0.29 / 0.29 / 0.28 |
| 关 | 160 / 140 / 100 | 48 / 42 / 30 | 0.3 / 0.3 / 0.3 |
A→B 的圆心距是 160,160 × 0.3 = 48。关掉箭头时分毫不差;打开箭头后弦变成 154.13,偏移变成 44.52,比值掉到 0.29。差值 5.87 就是箭头那一端让出的长度,后两条边也各让 5.87。这里不是简单的「线缩短、控制点按新弦长重算」——adjustEdge.js:128-168 是沿二次贝塞尔曲线重新切一段,把切出来的子曲线端点与控制点一起换掉,所以 44.52 并不等于 0.3 × 154.13 = 46.24。想验证裸公式,先关箭头。
curveness 的符号决定凸向哪边,绝对值决定鼓多高;屏幕上的鼓出高度约是控制点偏移的一半(二次贝塞尔中点偏离控制点的一半)。按「①」循环 0.3 → −0.3 → 0.8 → 0:−0.3 的绝对值仍是 48,只是方向反了;0.8 按公式是 128;0 直接读成「直边」。刚切过去会读到过渡帧(本章在 −0.3 档读到 46.49、0.8 档读到 124.78),按「③」再读一次才是稳态。验收弯曲度要等动画停。
右图三条边共用同一对节点,autoCurveness:true,圆心距 280。读数是一条直边(弦长 280、无控制点)加两条弧(弦长 280、偏移 56、比值 0.2)。56/280 = 0.2,对应 multipleGraphEdgeHelper.js:56-83 那张档位表的 0、−0.2、0.2 前三项。三条边都写死同一个 curveness 会完全重叠;autoCurveness 按「同一对节点的第几条边」依次取档。注意互斥关系:边上只要写了 lineStyle.curveness,autoCurveness 就不起作用(shared.d.ts:11568-11571)。
最后一条是边标签。D→B 这条边在 links 里写了 label:{show:true,formatter:'84 kg'},而系列级 edgeLabel.show:false;图上仍然出现「84 kg」。原因是 GraphSeries.js:124-130 把边上的 label 路径改写成 edgeLabel,数据项上的显式 show:true 覆盖系列缺省。要单条边注释就写在 links[i].label,要所有边就写系列 edgeLabel。
面板九:focus、blurScope 与邻接高亮
a—b—c—d 一条链,四个节点、三条边,layout:'none',animation:false,节点 symbolSize:20,边 lineStyle.opacity:0.5。三张图唯一区别是 emphasis.focus:none、self、adjacency。按「①」对三个图同时 dispatchAction({type:'highlight',dataIndex:1}),读数如下([blur] 表示元素进了淡出态):
| focus | 节点 a/b/c/d | 边 a→b / b→c / c→d |
|---|---|---|
none | 1 / 1 / 1 / 1 | 0.5 / 0.5 / 0.5 |
self | 0.1[blur] / 1 / 0.1[blur] / 0.1[blur] | 0.05[blur] / 0.05[blur] / 0.05[blur] |
adjacency | 1 / 1 / 1 / 0.1[blur] | 0.5 / 0.5 / 0.05[blur] |
none 谁都不淡;self 只留被悬停的 b 自己,连邻居和三条边一起淡;adjacency 才把 b 的邻居 a、c 和两条直接相连的边留下,只淡距离两跳的 d 与 c→d。blur 的缺省透明度是「元素原本的透明度 × 0.1」:节点 1 → 0.1,边写了 0.5 → 0.05。这就是为什么边看起来比节点淡得多——不是两套系数,是乘法。
邻接索引只在 focus==='adjacency' 时才挂到元素上:GraphView.js:165-168 给节点挂 getAdjacentDataIndices(),:172-181 给边挂两端节点与自身。blurScope 缺省 'coordinateSystem'(states.js:327),本例只有一张图,写不写都一样;同一画布里叠多个系列时它决定淡出波及谁。
一个容易误会的取值:focus:'trajectory'。它在桑基图里表示「沿流向的上下游」,但关系图的类型联合只有 none/self/series/adjacency(shared.d.ts:11468),实现也只写在 SankeyView.js:214,252。在 graph 上硬写 trajectory 不报错,行为退化成 self——别把桑基的手感搬过来。
面板十:实战 A,门店供应链网络
这张图有 19 个节点、21 条边:3 个产地(三角形)、4 个中心仓(方块)、12 家门店(圆点)。数据不是另编的:门店名、城市、面积来自 data/stores.json(前几章一直在用的那份),仓—店归属写在 HOME,产地→仓的份额写在 WH_SOURCE。21 条边的构成是 8 条产地→仓、12 条仓→店、1 条虚线 w-sh → st-S06(跨仓调拨);演示口径的月运量 = 门店 area ÷ 8,仓的运量按归属累加,四仓分别是 60/44/25/41 吨。
节点大小自己算:度数表里 w-sh 是 8(3 条产地 + 4 家店 + 1 条虚线),w-gz 与 w-bj 各 5,w-cd 是 3;产地 o-yn、o-br 各 3,o-et 是 2。仓与产地用 14 + 度数×3,门店用更小的基数 9 + 度数×3,免得 12 个门店压过仓。落到的尺寸是:仓 38/29/29/23,产地 23/23/20,门店 12(S06 因为有调拨虚线是 15)。四个仓的月运量在黄框里能直接对上 ["w-sh:60","w-gz:44","w-cd:25","w-bj:41"]。
边宽也按运量:产地→仓 1 + round(吨/30),仓→店 1 + round(吨/20),所以粗线就是大流量。edgeLength 给的是数组 [60,140],按边的 value 线性插值——注意原理篇那条反直觉规则:value 越大边越短,重货流会被拉近。
三个布局档位把同一份数据换三种看法:
| 按钮 | layout | 19 点包围盒 | 最小间距 | 读法 |
|---|---|---|---|---|
| 初始 | force | 490.2×505.4 | 66.7 | 拓扑自然,仓自动落在中间 |
| ② | circular | 409.4×412.3 | 56.8 | 所有人上圆,按大小分弧长,标签沿切线转 |
| ③ | none + 手写 x/y | 650×416 | 37.8 | 三列固定分层,适合放进文档 |
Force 那列是初读值。你按过别的按钮再回来,包围盒会略变:setOption 之后力导向可能继续迭代,而初值又是随机的,所以面板里所有力导向读数都只承诺「同一屏内可比」,不承诺跨刷新复现。none 那一档用的是 layered():产地 x=60、仓 x=300、门店 x=560,纵向 20 + i×320/(n−1) 铺开;它把「谁喂谁」画得最直白,代价是边会交叉。
缩略图在右下角,width/height 都是 '15%'。元素账本:path 62 = 19 个主节点 + 21 个箭头 + 19 个克隆节点 + 3 枚图例图标,线 42 = 21 条主边 + 21 条克隆边;文字 22 = 19 个节点标签 + 3 个图例文字,打开运量标签后 43 = 22 + 21 条边的「xx 吨」。缩略图那批微型点读到 22 个:19 个克隆节点(直径 0.2 px)加 3 枚图例图标(直径 2 px);主图的尺寸档 12/15/20/23/29/38 一个不少。
按「① 显示运量标签」会打开 edgeLabel.show,formatter 取边的 value;再按一次关掉。这个按钮自己的提示会在 420 ms 后被统一读数覆盖,直接看黄框第二行的「文字=」一栏即可:开是 43,关是 22。
面板十一:实战 B,本章工程的依赖图
七个节点、六条边,layout:'none':ch26/index.html 与 ch26/app.js 在第一列(x=40),lib/echarts.min.js 与 echarts@6.1.0 在第二列(x=260、480),zrender@6.1.0、tslib@2.3.0、另一个 tslib@2.3.0 在第三列(x=660、840、660)。三个分类「本章 / 打包产物 / 运行时依赖」只用来上色;位置全部手写,所以每次渲染位置完全一样,可以当文档用。
形状和大小也有分工:index.html、app.js 是矩形,symbolSize:34;lib/echarts.min.js 是圆角矩形,symbolSize:44,名字里带换行和真实体积「1 121 883 字节」;echarts、zrender、两个 tslib 保持缺省的圆形 10。红色粗边是 umd → ec,表示「这份落盘文件打包的是 echarts 6.1.0」;三条带版本号的边分别是 ec → zr 的 6.1.0、ec → ts1 的 2.3.0、zr → ts2 的 2.3.0。
这里有个读数的意外:path 13 = 7 个节点 + 6 个箭头,但「文字」是 14,不是 7 + 3 = 10。拆开看是 8 段节点文字 + 6 条边标签:lib/echarts.min.js 的名字里有 \n,SVG 里被拆成两个 <text>(不是两个 tspan);边标签除了三条手写版本号,另外三条没有 formatter,edgeLabel.show:true 就用缺省的「源 > 目标」补上,实测是「html > umd」「html > app」「umd > ec」。这正好把依赖方向写全了;如果你只想要版本号,把系列 edgeLabel.show 关掉,再在三条版本边上分别写 label:{show:true,formatter:'6.1.0'}(另外两条写各自的 2.3.0)。
为什么同一个 tslib@2.3.0 画成两个节点?因为 echarts@6.1.0 和 zrender@6.1.0 都依赖它。画成两个节点,每条边就是一对一,分层干净;合并成一个节点会多出两条交叉边,图立刻乱。代价是看起来像装了两份——在文档里要另写一句话说明「这里按依赖者拆开画,不代表重复安装」。这是关系图建模的常见取舍:节点到底代表「实体」还是「实体的一次出现」,要在画之前定死。
版本号不是手抄的:lib/echarts.min.js 与 npm 包的版本对账由 tools/verify-types.mjs 的 assertVersionMatch 管,本章实测两边都是 6.1.0;lib/echarts.min.js 的字节数 1 121 883 也是磁盘实测。按「① 高亮 tslib 的邻接」能看到 tslib@2.3.0(ts1)与 echarts@6.1.0 两个节点亮起,其余节点淡出——这正是 emphasis.focus:'adjacency' 在依赖图里的用法:问「改这个包会波及谁」。
【参数速查】
两张表与布局
| 键 | 作用 | 本章读数/建议 |
|---|---|---|
series.data | 节点表(别名 nodes) | 同时写时 data 赢;节点必须是对象 |
series.links | 边表(别名 edges) | 同时写时 edges 赢;source/target 填节点标识 |
series.data[i].id | 节点标识 | 写了 id 后边必须用 id 指,写 name 静默丢边 |
series.data[i].name | 标签与无 id 时的标识 | 可含 \n;SVG 里拆成多段文字 |
series.data[i].x | layout:'none' 的数据 x | 不写就没有位置;force 里当起点 |
series.data[i].y | 数据 y | 与 x 成对出现 |
series.data[i].fixed | 力导向里钉住节点 | 只表示「别被推开」,不补坐标 |
series.data[i].symbolSize | 节点直径 | 缺省 10;度数映射要自己算 |
series.data[i].symbol | 节点形状 | circle/rect/roundRect/triangle 等 |
series.data[i].category | 分类序号或名字 | 越界静默退回调色板第一色 |
series.data[i].value | 参与力参数数组插值 | edgeLength 数组里 value 越大边越短 |
series.layout | 'none' | 'force' | 'circular' | 缺省 null;没有 forceAtlas2 |
力导向
| 键 | 作用 | 本章读数/建议 |
|---|---|---|
series.force.repulsion | 两两斥力 | 12 点默认包围盒 111×116;调 1 缩成 37×33;调 3000 撑到 626×943 |
series.force.gravity | 向质心的拉力 | 0.02 时图被拉长到 110×204 |
series.force.edgeLength | 弹簧原长 | 30 时收敛边长约 33;数组按 value 插值且越大越短 |
series.force.friction | 冷却系数 | 每步 ×0.992;0.6→510 步,0.05→201 步 |
series.force.layoutAnimation | 演不演迭代 | false 同步收敛;true 逐帧,验收等静止 |
series.force.initLayout | 'circular' | 'none' | 迭代前先摆一次,连 fixed 的位置也覆写 |
series.data[i].ignoreForceLayout | 该点不参与力迭代 | 适合钉住少数点、其余照常跑 |
版式、漫游与缩略图
| 键 | 作用 | 本章读数/建议 |
|---|---|---|
series.left | 视窗框左边 | 缺省 'center';只写它不改框大小 |
series.top | 视窗框上边 | 缺省 'center' |
series.right | 视窗框右边 | 与 left 同用时定宽 |
series.bottom | 视窗框下边 | 与 top 同用时定高 |
series.width | 视窗框宽 | 写死就不走「容器 80%」规则 |
series.height | 视窗框高 | 框不方时横纵单位长度分开 |
series.roam | false | true | 'move' | 'pan' | 'scale' | 'zoom' | 写错字符串静默关闭 |
series.roamTrigger | null | 'selfRect' | 'global' | 缺省只在数据包围盒内响应滚轮 |
series.nodeScaleRatio | 节点跟不跟缩放 | 公式 symbolSize×((zoom−1)×(ratio||1)+1);0 被当 1 |
series.scaleLimit.min | 最小 zoom | 夹完写回 option |
series.scaleLimit.max | 最大 zoom | {min:0.5,max:2} 实测夹到 0.5/2 |
series.center | 视图中心 | 滚轮与拖缩略图会写回 |
series.zoom | 缩放 | 同上;scaleLimit 先夹再写 |
series.draggable | 节点可拖 | 拖拽只改布局器/元素,不回写 option |
thumbnail.show | 显示缩略图 | 缺省 true |
thumbnail.right | 距右 | 缺省 1;本章面板六用 2、面板十用 4 |
thumbnail.bottom | 距下 | 缺省 1 |
thumbnail.width | 缩略图宽 | 缺省 '25%';面板六 24%、面板十 15% |
thumbnail.height | 缩略图高 | 与宽同比例最好 |
thumbnail.itemStyle.borderWidth | 外框线宽 | 缺省 2 |
thumbnail.windowStyle.opacity | 视窗白框透明度 | 缺省 0.3 |
thumbnail.seriesIndex | 指定要缩略的系列 | 不写就找第一个 graph;指错系列生产版安静 |
边与箭头
| 键 | 作用 | 本章读数/建议 |
|---|---|---|
series.edgeSymbol | 两端符号 | 缺省 ['none','none'];字符串会同时作用于两端 |
series.edgeSymbolSize | 箭头大小 | 缺省 10;箭头会把线削短 |
series.lineStyle.curveness | 弯曲比例 | 偏移基数是没有箭头时的圆心距 |
series.autoCurveness | 平行边自动分档 | true 用 [0,−0.2,0.2,…];显式 curveness 优先 |
series.edgeLabel.show | 边标签开关 | 缺省关;打开后无 formatter 的边显示「源 > 目标」 |
series.edgeLabel.formatter | 边标签内容 | 面板十用 value + ' 吨' |
series.links[i].label | 单条边标签 | 会被改写成 edgeLabel,数据项 show 覆盖系列 |
series.lineStyle.width | 线宽 | 面板十按运量算 |
series.lineStyle.type | 线型 | solid/dashed 等 |
series.lineStyle.opacity | 线透明度 | 面板九用 0.5;blur 时再乘 0.1 |
分类、颜色、高亮与标签
| 键 | 作用 | 本章读数/建议 |
|---|---|---|
series.categories | 分类表 | 图例项就是各项 name |
series.categories[i].name | 分类名 | 节点 category 可填这个名字 |
series.itemStyle.color | 系列色 | 会盖掉分类调色板 |
series.data[i].itemStyle.color | 数据项色 | 优先级最高;不影响图例图标 |
legend.data | 图例项 | 不写也能从 categories 生成 |
legend.selected | 选中态 | 关掉门店读回 {"门店":false,...} |
series.emphasis.focus | 高亮范围 | none/self/series/adjacency;graph 没有 trajectory |
series.emphasis.blurScope | 淡出波及范围 | 缺省 'coordinateSystem' |
series.label.show | 节点标签开关 | 面板十/十一都打开 |
series.label.position | 标签位置 | right/top/inside 等 |
series.circular.rotateLabel | 环形布局标签沿切线 | 面板十的 circular 档打开 |
力导向起点建议
| 节点规模 | 建议起点 | 备注 |
|---|---|---|
| 10–50 点 | repulsion:150–300、edgeLength:60–100、gravity:0.05–0.1、friction:0.6、layoutAnimation:false | 先看拓扑,再微调 |
| 100 点以上 | edgeLength:[小,大] 按 value 拉开、配 roam | 同时打开缩略图 |
| 1000 点以上 | 先按 friction 算步数预算 | 2000 点 510 步约 27 秒;0.05 约 10.7 秒 |
【常见坑】
- 边用
name去指带id的节点。节点画出来了、边一条没有,控制台不报错;本章实测三个节点、零条边,SVG 从 1457 字节缩到 904 字节。只要节点写了id,links就必须用id。 data写成散点那种纯数组。[[40,60],[240,60]]在 graph 里会变成两个只有value维度的数据项,getItemLayout是[null,null],画布上一个元素都没有。节点必须是对象,坐标字段叫x、y。layout写错。'circle'、'forceAtlas2'都不会被识别;有边时adjustEdge.js:116读未初始化的__original直接抛TypeError,没边时则一片空白、什么错都没有。6.1.0 没有forceAtlas2。layout:'none'不写x/y。没有位置就没有元素,节点和边都是 0,也不报错。none意味着「位置我全包」,不是「你随便摆」。- 以为力导向结果会写回 option。
getOption()里始终是你写的x/y,没写就是null;拖拽也只改布局器/元素。刷新或重新setOption会回到起点或重新随机。 fixed:true但不写坐标。fixed只表示「别被力推开」,不补位置;没有x/y时落点NaN,整幅图空掉。面板四按下「①」的 path=0 就是这个现场。roam写了不认识的字符串。'nope'两个分支都不进,等于静默关掉;'move'/'pan'只平移,'scale'/'zoom'只缩放。另外roamTrigger缺省只在数据点的包围盒里响应滚轮,盒外留白要写'global'。nodeScaleRatio:0。源码是(nodeScaleRatio || 1),0 被当成 1,节点照样满格缩放;想几乎不缩放写0.001。- 把
thumbnail写进series。它是顶层组件,和series平级;写错位置不会生效。不写seriesIndex时只找第一个 graph 系列,找不到就安静地不画;显式seriesIndex指非 graph 系列,开发构建还会踩到ThumbnailModel.js:90-92的空指针TypeError,生产版则安静。 - 以为缩略图只是一个小框。它会把主图图元克隆一份,
path、线、文字一起翻倍;64 节点 112 边的图从 176 个 displayList 元素变成 354 个。数元素时别把克隆当成重复绘制的 bug。 edgeSymbol缺省没有箭头。写字符串'arrow'是两端都画;只写['arrow']时第二端是undefined,只有一端有箭头。要单向就写['none','arrow']。- 箭头开着还按裸公式验收
curveness。箭头会把线削短、并让控制点跟着子曲线变,偏移/弦长不再是精确的curveness(本章实测 0.29);想验证公式先关箭头,或等动画停下来。 - 同一对节点写死同一个
curveness。多条边会完全重叠;用autoCurveness自动分档,或给每条边手写不同的curveness。反过来,已经写了lineStyle.curveness时autoCurveness不起作用。 - 「分类颜色不生效」。先查有没有系列
itemStyle.color,它优先于 categories;再查category是否越界,越界会静默退回第一色#5070dd。图例图标的颜色也算进填充统计,数颜色时先减掉 3 枚图标。 - 拿动画中间帧当结论。本章在
curveness:0.8刚切过去读到 124.78、在−0.3读到 46.49,稳定后才是 128 与 48;力导向图每次setOption还可能重跑布局。验收坐标、弯曲、透明度都要等画面静止再读。
【工程文件】
ch26/ 下三个文件,分工固定:
ch26/
├── min.html ← 最小可跑(78 行):6 节点 5 边,度数写回 symbolSize,file:// 双击可开
├── index.html ← 实验台骨架(172 行):11 个 h2、26 个容器、35 个按钮、11 个黄框
└── app.js ← 面板脚本(935 行):11 个面板的 option、读数与按钮绑定
11 个面板的图数分配是 3+2+6+2+1+2+3+2+3+1+1 = 26:
| 面板 | 一 | 二 | 三 | 四 | 五 | 六 | 七 | 八 | 九 | 十 | 十一 | 合计 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 图数 | 3 | 2 | 6 | 2 | 1 | 2 | 3 | 2 | 3 | 1 | 1 | 26 |
min.html 是自足的:只引 ../../lib/echarts.min.js,数据写死在页面里,没有 fetch,所以 file:// 双击就能画。index.html 引同一份 ECharts 加 app.js;app.js 启动时先 initAll() 建好十个面板,再 fetch('../../data/stores.json'),拿到数据后才 initP10()。这也是为什么实验台必须走本地服务器:file:// 下浏览器会拦掉这次 fetch,面板十会空着,状态条给出读取失败的原因。
启动方式:
node tools/serve.mjs --port 18000
然后打开 http://127.0.0.1:18000/code/ch26/index.html;只想看最小可跑就打开 http://127.0.0.1:18000/code/ch26/min.html。改完正文后用 node tools/extract.mjs ch26 从 md 的代码围栏重建 code/ch26/,再跑 npm run verify 与 npm run selftest。
黄框的读数有三段管线,读正文时值得知道:所有图都是 echarts.init(el, null, {renderer:'svg'}),读数从 getZr().storage.getDisplayList() 里取元素,圆心与直径来自 getComputedTransform(),边的控制点来自 ec-line 的 shape,透明度来自元素的 style.opacity;这不是 ECharts 的公开 API,版本一换就可能变,生产代码不要照抄。此外 afterPaint() 把首轮读数推迟 1300 ms,躲开 appear 动画的 0 直径;bind() 在执行按钮后再等 420 ms 统一重读所有面板。所以按钮自己的提示可能被统一读数覆盖,正文里的数都按「按下后等一秒」的稳态抄。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>第 26 章 关系图实验台</title>
<style>
body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; max-width: 1040px; }
h1 { font-size: 20px; margin: 0 0 6px; }
h2 { font-size: 15px; margin: 30px 0 6px; border-left: 4px solid #8a5a2b; padding-left: 8px; }
#status { background: #eef5fb; border-left: 4px solid #3d7ab8; padding: 8px 12px; margin: 10px 0 14px;
white-space: pre-wrap; }
.readout { background: #fff8ea; border-left: 4px solid #b6902f; padding: 8px 12px; margin: 8px 0 0;
font: 13px/1.7 Consolas, monospace; min-height: 3.4em; white-space: pre-wrap; }
button { padding: 6px 12px; margin: 0 8px 6px 0; cursor: pointer; }
.chart { border: 1px solid #d9d2c7; background: #fff; display: inline-block; vertical-align: top; }
.lab { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
.hint { font-size: 12px; color: #6f675c; margin: 4px 0 0; }
code { background: #f1ece3; padding: 0 3px; }
.wide { overflow-x: auto; }
</style>
</head>
<body>
<h1>关系图实验台:节点、边、力导向与缩略图</h1>
<div id="status">读取门店数据中……</div>
<h2>面板一 · 三种 layout:同一份数据,三种长法</h2>
<div class="lab">
<div><div id="p1a" class="chart" style="width:320px;height:260px;"></div><p class="hint">layout:'none' + x/y</p></div>
<div><div id="p1b" class="chart" style="width:320px;height:260px;"></div><p class="hint">layout:'circular'</p></div>
<div><div id="p1c" class="chart" style="width:320px;height:260px;"></div><p class="hint">layout:'force'</p></div>
</div>
<p>
<button id="b1a">① 再跑一次 force</button>
<button id="b1b">② 开关 layoutAnimation</button>
<button id="b1c">③ 重新读数</button>
</p>
<p class="hint">六家店、七条边,三份数据的差别只有 <code>layout</code>。<code>none</code> 只认你写的 <code>x/y</code>;<code>circular</code> 把它当没写过,从数据第一个节点开始摆到圆周上,间隙按 <code>symbolSize</code> 加权(面板一实测:直径 22 的仓库左右各 52.5°,其余 50.7°~51.4°,严格等分应是 51.4°);<code>force</code> 拿你写的 <code>x/y</code> 当起点,关掉 <code>layoutAnimation</code> 就一次迭代到收敛——按①再跑一次,落点逐字相同;面板三那种没写 <code>x/y</code> 的数据才是随机初值,每跑一次换一个形状。黄框量的是节点像素位置、最小间距和 SVG 元素数。</p>
<p id="p1Info" class="readout"></p>
<h2>面板二 · 版式与视窗:数据矩形怎么塞进画布</h2>
<div class="lab">
<div id="p2a" class="chart" style="width:460px;height:320px;"></div>
<div id="p2b" class="chart" style="width:460px;height:320px;"></div>
</div>
<p>
<button id="b2a">① 只写 left/top</button>
<button id="b2b">② 写宽与高(像素)</button>
<button id="b2c">③ 写 right/bottom</button>
<button id="b2d">④ 复位(什么都不写)</button>
</p>
<p class="hint">两图都是 <code>layout:'none'</code>、同一份 0..300 的方形数据。左图演示 <code>left/top/width/height</code> 盒布局怎么改视窗,右图当参照物不动。黄框打印 <code>dataRect</code>、<code>viewRect</code> 和「一个数据单位等于多少像素」——横竖各一个数。</p>
<p id="p2Info" class="readout"></p>
<h2>面板三 · 力导向手感:五个参数各调一档会发生什么</h2>
<div class="wide"><div class="lab">
<div><div id="p3a" class="chart" style="width:220px;height:190px;"></div><p class="hint">默认</p></div>
<div><div id="p3b" class="chart" style="width:220px;height:190px;"></div><p class="hint">repulsion:1</p></div>
<div><div id="p3c" class="chart" style="width:220px;height:190px;"></div><p class="hint">repulsion:3000</p></div>
<div><div id="p3d" class="chart" style="width:220px;height:190px;"></div><p class="hint">gravity:0.02</p></div>
<div><div id="p3e" class="chart" style="width:220px;height:190px;"></div><p class="hint">edgeLength:300</p></div>
<div><div id="p3f" class="chart" style="width:220px;height:190px;"></div><p class="hint">friction:0.9</p></div>
</div></div>
<p>
<button id="b3a">① 换成 500 个节点并计时</button>
<button id="b3b">② 换回 12 个节点</button>
<button id="b3c">③ 开动画跑 1.2 秒看收敛</button>
</p>
<p class="hint">十二个点、十五条边(四组三角形 + 三条桥)的小网络,六张图只改一个参数,且都关掉了 <code>layoutAnimation</code>(同步迭代到收敛)。黄框打印每张图收敛后的像素包围盒、最小间距,以及 500 节点那一次的 <code>setOption</code> 耗时——这就是「调参手感」的可核对版本。</p>
<p id="p3Info" class="readout"></p>
<h2>面板四 · <code>x/y</code>、<code>fixed</code>、<code>initLayout</code> 与拖拽</h2>
<div class="lab">
<div><div id="p4a" class="chart" style="width:420px;height:300px;"></div><p class="hint">initLayout:'none'</p></div>
<div><div id="p4b" class="chart" style="width:420px;height:300px;"></div><p class="hint">initLayout:'circular'</p></div>
</div>
<p>
<button id="b4a">① 只给 fixed,不写 x/y</button>
<button id="b4b">② 拖完一个节点再读 option</button>
<button id="b4c">③ 复位</button>
</p>
<p class="hint">五个人,其一号 <code>fixed:true</code> 且写了 <code>x/y</code>。左图钉住节点留在原地,右图里 <code>initLayout:'circular'</code> 先把所有人按环形摆好——钉住的位置也被覆写。黄框读的是每个节点的数据坐标,以及拖拽之后 <code>getOption()</code> 里 <code>x/y/fixed</code> 有没有变化。</p>
<p id="p4Info" class="readout"></p>
<h2>面板五 · 漫游家族:roam、roamTrigger、nodeScaleRatio、scaleLimit</h2>
<div><div id="p5" class="chart" style="width:500px;height:340px;"></div></div>
<p>
<button id="b5a">① 循环切 roam</button>
<button id="b5b">② 循环切 nodeScaleRatio</button>
<button id="b5c">③ 开关 scaleLimit</button>
<button id="b5d">④ 复位视图</button>
<button id="b5e">⑤ 读数</button>
<button id="b5f">⑥ 切 roamTrigger</button>
</p>
<p class="hint">面板打开时 <code>roam</code> 已是 <code>true</code>、<code>nodeScaleRatio</code> 是缺省 <code>0.6</code>,直接在图里滚轮、按住空白拖动试试。<code>roam</code> 的 <code>'move'</code>/<code>'pan'</code> 只平移、<code>'scale'</code>/<code>'zoom'</code> 只缩放、<code>true</code> 两者都要;写一个不认识的字符串(比如 <code>'nope'</code>)两个分支都不进,等于悄悄关掉且不报错。缺省 <code>roamTrigger</code> 的触发区是「数据点撑出来的那块包围盒」按当前缩放变换之后的矩形,包围盒外的留白里滚轮没反应——按⑥切成 <code>'global'</code>,整张画布都能滚。节点直径按 <code>直径 = symbolSize × ((zoom−1) × (nodeScaleRatio || 1) + 1)</code> 长:注意那个 <code>|| 1</code>,写 <code>0</code> 会被当成 <code>1</code>,节点跟着缩放满格。</p>
<p id="p5Info" class="readout"></p>
<h2>面板六 · thumbnail 缩略图:整张图与当前视窗</h2>
<div class="lab">
<div><div id="p6a" class="chart" style="width:400px;height:300px;"></div><p class="hint">不写 thumbnail</p></div>
<div><div id="p6b" class="chart" style="width:400px;height:300px;"></div><p class="hint">thumbnail.show:true</p></div>
</div>
<p>
<button id="b6a">① 右图放大一档</button>
<button id="b6b">② 右图复位</button>
<button id="b6c">③ 读数</button>
</p>
<p class="hint"><code>thumbnail</code> 是顶层组件,不是系列里的键。<code>layout:'none'</code> + 六十四节点的网格图最适合看它的价值:缩略图画的是全部节点,白色窗口是「你现在看到的这块」。窗口尺寸 = 迷你内框 ÷ <code>zoom</code>,所以 <code>zoom:1</code> 时窗口正好铺满整个缩略图,放大一档它就按比例缩小。把窗口拖到别处,主图跟着平移,<code>center</code> 会被写回 option——黄框第四行读得到。</p>
<p id="p6Info" class="readout"></p>
<h2>面板七 · categories、legend 与取色优先级</h2>
<div class="lab">
<div><div id="p7a" class="chart" style="width:300px;height:240px;"></div><p class="hint">只写 categories</p></div>
<div><div id="p7b" class="chart" style="width:300px;height:240px;"></div><p class="hint">再加 series.itemStyle.color</p></div>
<div><div id="p7c" class="chart" style="width:300px;height:240px;"></div><p class="hint">再加 data[i].itemStyle.color</p></div>
</div>
<p>
<button id="b7a">① 图例里关掉「门店」</button>
<button id="b7b">② 全部恢复</button>
</p>
<p class="hint">三层取色:数据项自己写的 > 系列 <code>itemStyle.color</code> > <code>categories</code> 的调色板。中间那张被系列色盖住了分类色,三堆点变成同一种颜色——这是最常见的「分类颜色不生效」。图例来自 <code>categories</code> 的名字,关一类,节点和连着它的边一起消失。</p>
<p id="p7Info" class="readout"></p>
<h2>面板八 · 边:箭头、边标签、curveness 与多边</h2>
<div class="lab">
<div><div id="p8a" class="chart" style="width:440px;height:260px;"></div><p class="hint">有向 + 边标签 + 弯曲</p></div>
<div><div id="p8b" class="chart" style="width:440px;height:260px;"></div><p class="hint">autoCurveness 自动分开平行边</p></div>
</div>
<p>
<button id="b8a">① 循环 curveness</button>
<button id="b8b">② 开关 edgeSymbol 箭头</button>
<button id="b8c">③ 读数</button>
</p>
<p class="hint"><code>edgeSymbol</code> 是两端符号,<code>['none','arrow']</code> 才是单向箭头,箭头会自动把线缩短一点,免得扎进节点里。<code>lineStyle.curveness</code> 把二次贝塞尔的控制点沿弦的垂线推出 <code>curveness × 数据圆心距</code>,曲线真正鼓出的是它的一半;正负号决定凸向哪边。要留意 <code>ec-line</code> 的 <code>shape</code> 记的是数据坐标而不是像素,箭头还会沿线把终点缩短并重算控制点:同一份数据,关箭头读到 <code>偏移/弦长 = 48/160 = 0.3</code>,开箭头读到 <code>44.5/154 ≈ 0.29</code>,这不是 bug。刚按完按钮还可能读到动画过渡里的那一帧,等一秒再按③读数就稳了。同两点画三条边时,<code>autoCurveness:true</code> 会给它们分配 0 与 ±0.2 三档弯曲度。</p>
<p id="p8Info" class="readout"></p>
<h2>面板九 · emphasis.focus 与淡出(blur)</h2>
<div class="lab">
<div><div id="p9a" class="chart" style="width:300px;height:220px;"></div><p class="hint">focus:'none'</p></div>
<div><div id="p9b" class="chart" style="width:300px;height:220px;"></div><p class="hint">focus:'self'</p></div>
<div><div id="p9c" class="chart" style="width:300px;height:220px;"></div><p class="hint">focus:'adjacency'</p></div>
</div>
<p>
<button id="b9a">① 程序化悬停第 2 个节点</button>
<button id="b9b">② 取消悬停</button>
</p>
<p class="hint">graph 的 <code>emphasis.focus</code> 只有 <code>'none'|'self'|'series'|'adjacency'</code> 四种有效值(<code>shared.d.ts:11468</code>),桑基图那个 <code>'trajectory'</code> 在这里不存在:写进去不报错,效果退成 <code>'self'</code> 那样——只亮自己,邻居照样被淡掉。<code>self</code> 只亮自己——连直接相连的边也一起淡掉(实测悬停 b:三条边全变 0.05);要把邻居节点和连着它们的边一起留下,得用 <code>adjacency</code>(<code>GraphView.js:165-168</code> 与 <code>:176-181</code> 只在 <code>focus==='adjacency'</code> 时给元素挂邻接索引,别的取值一律不挂)。黄框在每次悬停后打印每个元素此刻的 <code>style.opacity</code> 和它进没进 <code>[blur]</code> 状态,这一面板特意关掉 <code>animation</code>,为的是让淡出读数稳定。淡出的缺省透明度是「元素原本的透明度 × 0.1」,写了 <code>lineStyle.opacity:0.5</code> 的边因此被压到 0.05。按钮①用 <code>dispatchAction</code> 程序化悬停,和鼠标真的悬上去读到的是同一套值;但在 <code>ssr:true</code> 的服务器渲染里,<code>dispatchAction</code> 并不会把状态真的套到元素上——想在 Node 端核对淡出,得自己把 <code>emphasis</code> 的样式算一遍。</p>
<p id="p9Info" class="readout"></p>
<h2>面板十 · 实战 A:门店供应链网络</h2>
<div class="wide"><div id="p10" class="chart" style="width:900px;height:520px;"></div></div>
<p>
<button id="b10a">① 显示运量标签</button>
<button id="b10b">② 换成 circular</button>
<button id="b10c">③ 换成 none + 手工分层</button>
<button id="b10d">④ 读数</button>
</p>
<p class="hint">三个产地、四个中心仓、十二家门店(门店名单与城市来自 <code>data/stores.json</code>,运量是本章编的演示数字)。节点大小按度数算,产地用三角形、仓用方形、门店用圆。力导向 + 缩略图 + 邻接高亮,点一个门店看它连到哪个仓。</p>
<p id="p10Info" class="readout"></p>
<h2>面板十一 · 实战 B:本章工程的依赖图</h2>
<div><div id="p11" class="chart" style="width:720px;height:380px;"></div></div>
<p>
<button id="b11a">① 高亮 tslib 的邻接</button>
<button id="b11b">② 取消高亮</button>
</p>
<p class="hint">用 <code>layout:'none'</code> 手工分层坐标画「谁依赖谁」:版本号写在边标签上,红色粗边标出 <code>lib/echarts.min.js</code> 指向 <code>echarts@6.1.0</code> 的那一跳。分层坐标是手算的,好处是每次渲染位置完全一样,能当文档用。</p>
<p id="p11Info" class="readout"></p>
<script src="../../lib/echarts.min.js"></script>
<script src="app.js"></script>
</body>
</html>
// 第 26 章实验台:11 个面板,26 张图。
// 关系图的节点与边都写在代码里(data/ 里没有「供应链.json」这种文件,本章也不新增);
// 只有门店名单和城市取自 data/stores.json,运量是本章编的演示数字。
// 黄框里的数字全部现场读取:节点圆心与直径来自元素的 getComputedTransform(),
// 边的控制点来自 ec-line 的 shape(数据坐标),透明度来自 SVG 上的属性。
// 门禁沙箱里没有真 DOM 也没有 zrender 元素,读不到就打印占位——那里只负责验证图能渲染。
var statusEl = document.getElementById('status');
function $(id) { return document.getElementById(id); }
function any(o) { return /** @type {any} */ (o); }
function getJSON(url) {
return fetch(url).then(function (r) {
if (!r.ok) throw new Error(r.status + ' ' + url);
return r.json();
});
}
function safe(fn, fallback) { try { return fn(); } catch (e) { return fallback; } }
function later(fn, ms) { return setTimeout(fn, ms || 80); }
function setStatus(text) { if (statusEl) statusEl.textContent = text; }
function setInfo(id, rows) { var el = $(id); if (el) el.textContent = [].concat(rows).join('\n'); }
// 每次点按钮之后都补一遍全量读数:setOption 只是把更新排进队列,
// 元素列表要等 zrender 下一帧 flush 才是新的。立刻读会读到上一帧的图,
// 于是「换回 12 节点」会报 512 个、复位后的左图会报空。
function bind(id, fn) {
var el = $(id);
if (!el) return;
el.onclick = function () {
fn();
later(readEveryPanel, 420);
};
}
function J(v) { try { return JSON.stringify(v); } catch (e) { return String(v); } }
function fr(v) {
if (typeof v !== 'number') return v == null ? 'null' : String(v);
if (!isFinite(v)) return String(v);
return String(Math.round(v * 100) / 100);
}
function now() { return safe(function () { return performance.now(); }, Date.now()); }
// 同一个容器重复 init 会被 ECharts 警告并复用旧实例;面板四的「复位」会二次调用,
// 所以这里先问 DOM 上有没有实例,有就拿来用,没有才建。
function mk(id) {
var el = $(id);
if (!el) return null;
return echarts.getInstanceByDom(el) || echarts.init(el, null, { renderer: 'svg' });
}
/* ===== 读数:一张 graph 的 displayList 里有什么 ===== */
// 节点是 type:'path' 且 shape.symbolType 有值、name 为空;边是 type:'ec-line';
// 箭头是另一个 path(name:'toSymbol'),别把它数成节点。
function dispList(chart) { return safe(function () { return any(chart).getZr().storage.getDisplayList(); }, []); }
function nodeEls(chart) {
return dispList(chart).filter(function (e) {
return !!e && e.type === 'path' && !!(e.shape && e.shape.symbolType) &&
e.shape.symbolType !== 'arrow' && !e.name;
});
}
function lineEls(chart) { return dispList(chart).filter(function (e) { return !!e && e.type === 'ec-line'; }); }
function textEls(chart) { return dispList(chart).filter(function (e) { return !!e && (e.type === 'text' || e.type === 'tspan'); }); }
function pathCount(chart) { return dispList(chart).filter(function (e) { return !!e && e.type === 'path'; }).length; }
function arrowCount(chart) {
return dispList(chart).filter(function (e) {
return !!e && e.type === 'path' && e.shape && e.shape.symbolType === 'arrow';
}).length;
}
// 元素的计算矩阵是 [a,0,0,d,tx,ty]:平移量就是圆心,直径 = 基础形状宽 2 × a
function nodePix(chart) {
var out = [];
nodeEls(chart).forEach(function (e) {
var m = safe(function () { return e.getComputedTransform(); }, null);
if (!m) return;
out.push({ x: Math.round(m[4] * 10) / 10, y: Math.round(m[5] * 10) / 10, d: Math.round(m[0] * 20) / 10, s: e.shape.symbolType });
});
return out;
}
function pixList(list, n) {
var take = list.slice(0, n || list.length).map(function (p) { return '(' + p.x + ',' + p.y + ')d' + p.d; });
return '[' + take.join(' ') + ']' + (list.length > take.length ? ' …' : '');
}
function pixBBox(list) {
if (!list.length) return null;
var x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
list.forEach(function (p) { x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); });
return { x0: x0, x1: x1, y0: y0, y1: y1, w: Math.round((x1 - x0) * 10) / 10, h: Math.round((y1 - y0) * 10) / 10 };
}
function minGap(list) {
var best = Infinity;
for (var i = 0; i < list.length; i++) {
for (var j = i + 1; j < list.length; j++) {
var d = Math.sqrt(Math.pow(list[i].x - list[j].x, 2) + Math.pow(list[i].y - list[j].y, 2));
if (d < best) best = d;
}
}
return best === Infinity ? null : Math.round(best * 10) / 10;
}
function csOf(chart) { return safe(function () { return any(chart).getModel().getSeriesByIndex(0).coordinateSystem; }, null); }
function rectOf(chart, which) {
var cs = csOf(chart);
if (!cs) return null;
return safe(function () { return which === 'data' ? cs.getBoundingRect() : cs.getViewRect(); }, null);
}
function rectStr(r) { return r ? 'x=' + fr(r.x) + ' y=' + fr(r.y) + ' w=' + fr(r.width) + ' h=' + fr(r.height) : '—'; }
// 横向与纵向各自缩放,所以「1 个数据单位等于多少像素」是两个数,不是一个
function pxPerUnit(chart) {
var d = rectOf(chart, 'data'), v = rectOf(chart, 'view');
if (!d || !v) return null;
return {
x: d.width > 0 ? Math.round(v.width / d.width * 1000) / 1000 : null,
y: d.height > 0 ? Math.round(v.height / d.height * 1000) / 1000 : null
};
}
function pxUnitStr(p) { return p ? '横 ' + fr(p.x) + ' px/纵 ' + fr(p.y) + ' px' : '—'; }
function sOpt(chart) { return safe(function () { var o = chart.getOption(); return (o.series || [])[0] || {}; }, {}); }
function svgPaths(id) {
var el = $(id);
return safe(function () { return [].slice.call(el.querySelectorAll('path')); }, []);
}
// 淡掉时 ECharts 直接把 blur 的透明度动画到元素的 style.opacity 上
// (util/states.js:219-237:blurStyle 没写 opacity 就取「基础 opacity × 0.1」),
// 所以黄框读 zrender 元素,不必去 DOM 上摸 fill-opacity。
function elOpacity(e) {
var o = safe(function () { return any(e).style.opacity; }, null);
return typeof o === 'number' && isFinite(o) ? Math.round(o * 1000) / 1000 : 1;
}
function statesOf(e) {
var s = safe(function () { return any(e).currentStates || []; }, []);
return s.length ? '[' + s.join('+') + ']' : '';
}
// 按数据坐标认领边:shape.x1/y1 就是 source 节点写的那对 x/y
function nodeAt(data, x, y) {
for (var i = 0; i < data.length; i++) {
if (Math.abs(any(data[i]).x - x) < 1 && Math.abs(any(data[i]).y - y) < 1) return data[i].name;
}
return '?';
}
// 按视图坐标认领节点:元素矩阵的平移量 = coordinateSystem.dataToPoint(x, y)
function nodeAtView(cs, list, x, y) {
var best = null, bd = Infinity;
list.forEach(function (n) {
var p = safe(function () { return cs.dataToPoint([any(n).x, any(n).y]); }, null);
if (!p) return;
var d = Math.abs(p[0] - x) + Math.abs(p[1] - y);
if (d < bd) { bd = d; best = n.name; }
});
return bd < 2 ? best : '?';
}
function focusRows(chart) {
var cs = csOf(chart);
var n = nodeEls(chart).map(function (e) {
var m = safe(function () { return e.getComputedTransform(); }, null);
var who = cs && m ? nodeAtView(cs, N9, m[4], m[5]) : '?';
return who + '=' + fr(elOpacity(e)) + statesOf(e);
});
var l = lineEls(chart).map(function (e) {
var s = any(e).shape || {};
return nodeAt(N9, s.x1, s.y1) + '→' + nodeAt(N9, s.x2, s.y2) + '=' + fr(elOpacity(e)) + statesOf(e);
});
return '点 ' + n.join(' ') + ' 边 ' + l.join(' ');
}
// 首帧有 appear 动画:节点从 0 倍长起来、边按 percent 描出来。
// 动画跑完就读,圆心是对的但直径读回 0,所以第一遍读数一律推迟到动画之后。
function afterPaint(fn) { later(fn, 1300); }
function degreeMap(links) {
var d = {};
links.forEach(function (l) {
d[l.source] = (d[l.source] || 0) + 1;
d[l.target] = (d[l.target] || 0) + 1;
});
return d;
}
var c1a, c1b, c1c, c2a, c2b, c3a, c3b, c3c, c3d, c3e, c3f, c4a, c4b, c5, c6a, c6b;
var c7a, c7b, c7c, c8a, c8b, c9a, c9b, c9c, c10, c11;
/* ===== 面板一 · 三种 layout ===== */
var CAT2 = [{ name: '中心仓' }, { name: '门店' }];
var N1 = [
{ id: 'wh', name: '上海仓', category: 0, x: 150, y: 150, symbol: 'rect', symbolSize: 22 },
{ id: 'S01', name: '南京西路店', category: 1, x: 30, y: 30 },
{ id: 'S05', name: '湖滨银泰店', category: 1, x: 250, y: 30 },
{ id: 'S09', name: '新街口店', category: 1, x: 270, y: 170 },
{ id: 'S04', name: '天河城店', category: 1, x: 200, y: 270 },
{ id: 'S06', name: '春熙路店', category: 1, x: 50, y: 250 },
{ id: 'S12', name: '南屏街店', category: 1, x: 20, y: 130 }
];
var L1 = [
{ source: 'wh', target: 'S01' }, { source: 'wh', target: 'S05' }, { source: 'wh', target: 'S09' },
{ source: 'wh', target: 'S04' }, { source: 'wh', target: 'S06' }, { source: 'wh', target: 'S12' },
{ source: 'S01', target: 'S05', lineStyle: { type: 'dashed', opacity: 0.4 } }
];
function g1(extra) {
return { series: [Object.assign({
type: 'graph', symbolSize: 14, roam: true, draggable: true,
edgeSymbol: ['none', 'arrow'], edgeSymbolSize: 6, categories: CAT2, data: N1, links: L1,
label: { show: true, position: 'right', fontSize: 11 },
lineStyle: { color: '#86878c', width: 1, opacity: 0.6 },
emphasis: { focus: 'adjacency' }
}, extra || {})] };
}
function initP1() {
c1a = mk('p1a'); c1b = mk('p1b'); c1c = mk('p1c');
if (c1a) c1a.setOption(g1({ layout: 'none' }));
if (c1b) c1b.setOption(g1({ layout: 'circular' }));
if (c1c) c1c.setOption(g1({ layout: 'force', force: { repulsion: 240, edgeLength: 80, gravity: 0.05, layoutAnimation: false } }));
readP1();
}
function posKey(chart) {
return J(nodePix(chart).map(function (p) { return p.x + ',' + p.y; }));
}
var forceCmp = '';
function readP1() {
var rows = [];
[['none', c1a], ['circular', c1b], ['force', c1c]].forEach(function (pair) {
var p = nodePix(pair[1]);
rows.push(fr2(pair[0]) + ' path=' + pathCount(pair[1]) + ' 线=' + lineEls(pair[1]).length +
' 圆心' + pixList(p) + ' 最小间距=' + fr(minGap(p)));
});
rows.push('none 认你写的 x/y;circular 当 x/y 没写过,按 symbolSize 加权分圆周(本图 52.5/50.7/51.1/51.4/51.1/50.7/52.5 度,等分应 51.4);' +
'force 拿 x/y 当起点,layoutAnimation:false 时一次迭代到收敛');
rows.push('面板三那六张图没写 x/y,初值是随机撒的,才会每跑一次换一个形状');
if (forceCmp) rows.push(forceCmp);
setInfo('p1Info', rows);
}
function fr2(s) { return (s + ' ').slice(0, 16); }
bind('b1a', function () {
if (!c1c) return;
var before = posKey(c1c);
c1c.setOption(g1({ layout: 'force', force: { repulsion: 240, edgeLength: 80, gravity: 0.05, layoutAnimation: false } }), true);
var after = posKey(c1c);
forceCmp = '再跑一次 force(数据里的 x/y 一字未动)跑之前 ' + before + ' 跑之后 ' + after + ' → ' +
(before === after ? '落点逐字相同:起点就是 x/y,同步迭代到收敛,中间没有随机数' : '落点不同:这一版的起点或迭代里带了随机');
readP1();
afterPaint(readP1);
});
var animOn = false;
bind('b1b', function () {
animOn = !animOn;
if (!c1c) return;
c1c.setOption(g1({ layout: 'force', force: { repulsion: 240, edgeLength: 80, gravity: 0.05, layoutAnimation: animOn } }), true);
setInfo('p1Info', ['layoutAnimation:' + animOn + '(' + (animOn ? '逐帧演给你看,位置会一直动' : '同步迭代到收敛,一次画完') + ')', '此刻 ' + pixList(nodePix(c1c))]);
if (animOn) later(function () { setInfo('p1Info', ['动画 1 秒后 ' + pixList(nodePix(c1c))]); }, 1000);
else afterPaint(readP1);
});
bind('b1c', readP1);
/* ===== 面板二 · 版式与视窗 ===== */
var N2 = [
{ id: 'a', name: 'A', x: 0, y: 0 }, { id: 'b', name: 'B', x: 300, y: 0 },
{ id: 'c', name: 'C', x: 300, y: 300 }, { id: 'd', name: 'D', x: 0, y: 300 },
{ id: 'o', name: 'O', x: 150, y: 150 }
];
var L2 = [{ source: 'a', target: 'c' }, { source: 'b', target: 'd' }, { source: 'o', target: 'a' }];
function g2(box) {
return { series: [Object.assign({
type: 'graph', layout: 'none', symbolSize: 18, roam: true,
data: N2, links: L2, label: { show: true, position: 'inside', fontSize: 11, color: '#fff' },
lineStyle: { color: '#86878c', opacity: 0.6 }
}, box || {})] };
}
function initP2() {
c2a = mk('p2a'); c2b = mk('p2b');
if (c2a) c2a.setOption(g2());
if (c2b) c2b.setOption(g2());
readP2();
}
function readP2() {
setInfo('p2Info', [
'左 viewRect ' + rectStr(rectOf(c2a, 'view')),
' dataRect ' + rectStr(rectOf(c2a, 'data')) + ' 1 个数据单位 = ' + pxUnitStr(pxPerUnit(c2a)),
'右 viewRect ' + rectStr(rectOf(c2b, 'view')) + '(参照,什么都不写)',
' dataRect ' + rectStr(rectOf(c2b, 'data')) + ' 1 个数据单位 = ' + pxUnitStr(pxPerUnit(c2b)),
'view 坐标系给 dataRect 定一个视窗框:宽高都没写时按数据宽高比取容器的 80%,正方形数据得到 256×256;' +
'right/bottom 与缺省的 left/top:"center" 一起把框算成 200×130,框不方,横纵 1 单位就不等长'
]);
}
// 版式按钮之间必须整份替换:合并会把上一档的 left/top/width/height 留着,
// 于是「只写 right/bottom」读出来还是上一档的视窗,看不出这组键的关系。
bind('b2a', function () { if (c2a) c2a.setOption(g2({ left: 40, top: 20 }), true); readP2(); });
bind('b2b', function () { if (c2a) c2a.setOption(g2({ left: 40, top: 20, width: 180, height: 180 }), true); readP2(); });
bind('b2c', function () { if (c2a) c2a.setOption(g2({ right: 30, bottom: 30 }), true); readP2(); });
bind('b2d', function () { if (c2a) c2a.setOption(g2(), true); readP2(); });
/* ===== 面板三 · 力导向手感 ===== */
var N3 = [];
var L3 = [];
(function build() {
for (var i = 0; i < 12; i++) N3.push({ id: 'n' + i, name: String(i) });
var edges = [[0, 1], [1, 2], [2, 0], [3, 4], [4, 5], [5, 3], [6, 7], [7, 8], [8, 6],
[9, 10], [10, 11], [11, 9], [0, 3], [6, 9], [2, 5]];
edges.forEach(function (e) { L3.push({ source: 'n' + e[0], target: 'n' + e[1] }); });
})();
function g3(force) {
return any({ series: [{
type: 'graph', layout: 'force', roam: true, symbolSize: 8,
data: N3, links: L3, force: Object.assign({ layoutAnimation: false }, force),
lineStyle: { color: '#86878c', width: 1, opacity: 0.5 },
label: { show: false }
}] });
}
var P3_LABEL = ['默认', 'repulsion:1', 'repulsion:3000', 'gravity:0.02', 'edgeLength:300', 'friction:0.9'];
function initP3() {
c3a = mk('p3a'); c3b = mk('p3b'); c3c = mk('p3c'); c3d = mk('p3d'); c3e = mk('p3e'); c3f = mk('p3f');
if (c3a) c3a.setOption(g3({}));
if (c3b) c3b.setOption(g3({ repulsion: 1 }));
if (c3c) c3c.setOption(g3({ repulsion: 3000 }));
if (c3d) c3d.setOption(g3({ gravity: 0.02 }));
if (c3e) c3e.setOption(g3({ edgeLength: 300 }));
if (c3f) c3f.setOption(g3({ friction: 0.9 }));
readP3();
}
function forceOf(chart) {
var f = sOpt(chart).force || {};
return 'rep=' + fr(any(f).repulsion) + ' grav=' + fr(f.gravity) + ' len=' + fr(f.edgeLength) + ' fric=' + fr(f.friction);
}
var bigMs = '(还没跑)';
function readP3() {
var rows = [];
[[c3a, 0], [c3b, 1], [c3c, 2], [c3d, 3], [c3e, 4], [c3f, 5]].forEach(function (pair) {
var p = nodePix(pair[0]);
var b = pixBBox(p);
rows.push(fr2(P3_LABEL[pair[1]]) + (b ? ' 像素包围盒 ' + b.w + '×' + b.h + ' 最小间距 ' + fr(minGap(p)) : ' 没画出节点') +
' | ' + forceOf(pair[0]) + ' | 节点 ' + p.length);
});
rows.push('500 节点那一次 force 初始化耗时:' + bigMs);
setInfo('p3Info', rows);
}
// 面板三的「开动画看收敛」要在 0/400/1200 ms 各抢一次黄框,那段时间里别被通用读数盖掉
var p3BusyUntil = 0;
function readP3UnlessLocked() { if (now() >= p3BusyUntil) readP3(); }
bind('b3a', function () {
var n = [], l = [];
for (var i = 0; i < 500; i++) n.push({ id: 'x' + i, name: String(i) });
for (var k = 0; k < 500; k++) l.push({ source: 'x' + k, target: 'x' + ((k * 7 + 3) % 500) });
if (!c3a) return;
var big = {
series: [{
type: 'graph', layout: 'force', roam: true, symbolSize: 4, data: n, links: l,
force: { layoutAnimation: false }, lineStyle: { width: 0.5, opacity: 0.3 }
}]
};
var t0 = now();
c3a.setOption(big, true);
var ms = Math.round(now() - t0);
bigMs = ms + ' ms(500 节点 / 500 边,layoutAnimation:false,容器 220×190)';
setInfo('p3Info', ['500 节点的收敛像素包围盒 ' + J(pixBBox(nodePix(c3a))) + ',元素 ' + dispList(c3a).length + ' 个',
'这一次 setOption 的耗时:' + bigMs, '点②换回 12 节点的小图']);
});
// 换回小图必须整份替换:合并只会把 500 个节点接着往下数,读出来是「节点 512」
bind('b3b', function () { if (c3a) c3a.setOption(g3({}), true); readP3(); });
bind('b3c', function () {
if (!c3c) return;
p3BusyUntil = now() + 2200;
c3c.setOption(g3({ repulsion: 3000, layoutAnimation: true }), true);
var t0 = now();
var snap = [];
snap.push('0 ms 包围盒 ' + J(pixBBox(nodePix(c3c))));
later(function () { snap.push(Math.round(now() - t0) + ' ms 包围盒 ' + J(pixBBox(nodePix(c3c)))); setInfo('p3Info', snap); }, 400);
later(function () { snap.push(Math.round(now() - t0) + ' ms 包围盒 ' + J(pixBBox(nodePix(c3c))) + ' ← 收敛后不再动'); setInfo('p3Info', snap); }, 1200);
setInfo('p3Info', snap.concat(['(layoutAnimation:true:位置逐帧演算,黄框在 400 ms 与 1200 ms 各补一次)']));
});
/* ===== 面板四 · x/y、fixed、initLayout 与拖拽 ===== */
var N4 = [
{ id: 'p1', name: '阿哲', x: 40, y: 60, fixed: true, symbol: 'rect', symbolSize: 20 },
{ id: 'p2', name: '小林', x: 260, y: 40 },
{ id: 'p3', name: 'Nana', x: 300, y: 220 },
{ id: 'p4', name: '老周', x: 80, y: 260 },
{ id: 'p5', name: 'Momo', x: 170, y: 150 }
];
var L4 = [{ source: 'p1', target: 'p5' }, { source: 'p2', target: 'p5' }, { source: 'p5', target: 'p3' }, { source: 'p5', target: 'p4' }];
var N4_INIT = JSON.parse(JSON.stringify(N4));
function g4(data, initLayout) {
return any({ series: [{
type: 'graph', layout: 'force', roam: true, symbolSize: 14,
data: data, links: L4, label: { show: true, position: 'right', fontSize: 11 },
force: { repulsion: 120, edgeLength: 70, initLayout: initLayout, layoutAnimation: false },
lineStyle: { color: '#86878c', opacity: 0.6 }
}] });
}
function initP4() {
c4a = mk('p4a'); c4b = mk('p4b');
// 整份替换:上一版可能已被「fixed 而不写 x/y」的数据污染,合并着设会留着那些空坐标
if (c4a) c4a.setOption(g4(JSON.parse(JSON.stringify(N4_INIT)), 'none'), true);
if (c4b) c4b.setOption(g4(JSON.parse(JSON.stringify(N4_INIT)), 'circular'), true);
readP4();
}
function dataRows(chart) {
var d = sOpt(chart).data || [];
return d.map(function (n) { return n.name + ' x=' + fr(any(n).x) + ' y=' + fr(any(n).y) + (n.fixed ? ' fixed' : ''); });
}
function readP4() {
var empty = (sOpt(c4a).data || []).every(function (n) { return any(n).x == null && any(n).y == null; });
setInfo('p4Info', ['option 里的数据 ' + J(dataRows(c4a))].concat([
'左(initLayout:none) 圆心 ' + pixList(nodePix(c4a)) + ' path=' + pathCount(c4a),
'右(initLayout:circular) 圆心 ' + pixList(nodePix(c4b)) + ' path=' + pathCount(c4b),
empty ? '左图五个点都没有坐标:fixed 只表示「别被力推开」,不补位置,path 数为 0'
: '左图阿哲被钉在写死的 x/y 上;右图 initLayout 先把五个点摆成环,连钉住的位置也被它覆写'
]));
}
bind('b4a', function () {
var bad = JSON.parse(JSON.stringify(N4_INIT));
bad.forEach(function (n) { n.fixed = true; delete n.x; delete n.y; });
if (c4a) c4a.setOption(g4(bad, 'none'), true);
var d = safe(function () { return sOpt(c4a).data; }, null) || [];
var p = nodePix(c4a);
setInfo('p4Info', [
'fixed:true 但没有 x/y:数据坐标 ' + J(d.map(function (n) { return [any(n).x, any(n).y]; })),
'dataRect ' + rectStr(rectOf(c4a, 'data')) + ' → 1 单位 = ' + pxUnitStr(pxPerUnit(c4a)),
'画出来的 path 数=' + pathCount(c4a) + ',节点圆心=' + pixList(p) + '(读到这里就知道图还在不在)',
'「钉住」只是「别被力推开」,位置得你自己给:没有 x/y,落点就是 NaN,缩放里一旦出现 NaN 整幅图就空了'
]);
});
bind('b4b', function () {
var nowRows = dataRows(c4a);
var initRows = dataRows({ getOption: function () { return { series: [{ data: N4_INIT }] }; } });
var same = J(nowRows) === J(initRows);
setInfo('p4Info', [
'拖之前 option 里 ' + J(initRows),
'拖之后 option 里 ' + J(nowRows),
(same ? '两者逐字相同:拖拽只改视图元素,不回写 setOption 的数据' : '两者不同:说明中间还 setOption 过'),
'此刻画面上的圆心 ' + pixList(nodePix(c4a)),
'带 fixed 的节点数 = ' + (sOpt(c4a).data || []).filter(function (n) { return n.fixed; }).length + '(拖完并不会自动变成 fixed)'
]);
});
bind('b4c', function () { initP4(); });
/* ===== 面板五 · 漫游家族 ===== */
var N5 = [];
var L5 = [];
(function build5() {
for (var i = 0; i < 20; i++) N5.push({ id: 'm' + i, name: String(i), x: (i % 5) * 70, y: Math.floor(i / 5) * 70 });
for (var r = 0; r < 4; r++) for (var q = 0; q < 5; q++) {
if (q < 4) L5.push({ source: 'm' + (r * 5 + q), target: 'm' + (r * 5 + q + 1) });
L5.push({ source: 'm' + (r * 5 + q), target: 'm' + ((r + 1) * 5 + q) });
}
})();
var ROAM_MODES = [false, true, 'move', 'scale', 'nope'];
var roamIdx = 1;
var nsrIdx = 0;
var NSR_VALUES = [0.6, 0, 1];
var RTRIGGER = [null, 'global'];
var rtIdx = 0;
var limitOn = false;
function g5(extra) {
return { series: [Object.assign({
type: 'graph', layout: 'none', symbolSize: 20, data: N5, links: L5,
roam: any(ROAM_MODES[roamIdx]), nodeScaleRatio: NSR_VALUES[nsrIdx], roamTrigger: any(RTRIGGER[rtIdx]),
// ①⑥ 用的是 notMerge,整棵系列被重建:不在这里带上 scaleLimit,黄框就会显示它被悄悄关掉
scaleLimit: limitOn ? { min: 0.5, max: 2 } : any(null),
itemStyle: { borderWidth: 0 }, label: { show: true, position: 'inside', fontSize: 10, color: '#fff' },
lineStyle: { color: '#b6ac9e', opacity: 0.5 }
}, extra || {})] };
}
function initP5() {
c5 = mk('p5');
if (c5) {
c5.setOption(g5());
safe(function () { any(c5).getZr().on('mousewheel', function () { later(readP5, 200); }); });
safe(function () { c5.on('mouseup', function () { later(readP5, 200); }); });
}
readP5();
}
function readP5() {
var s = sOpt(c5);
var zoom = typeof s.zoom === 'number' ? s.zoom : 1;
var nsr = typeof s.nodeScaleRatio === 'number' ? s.nodeScaleRatio : 0.6;
// View.js:610 写的是 `nodeScaleRatio || 1`,所以 0 会被当成 1
var eff = nsr || 1;
var p = nodePix(c5);
var got = p.length ? p[0].d : null;
setInfo('p5Info', [
'roam=' + J(s.roam) + ' roamTrigger=' + (any(s).roamTrigger == null ? 'null(只认数据包围盒)' : J(any(s).roamTrigger)) +
' nodeScaleRatio=' + fr(s.nodeScaleRatio) + '(生效值 ' + fr(eff) + ') scaleLimit=' + J(s.scaleLimit),
'option.zoom=' + fr(zoom) + ' option.center=' + J(s.center),
'实测第一个节点直径=' + fr(got) + ' px 公式 symbolSize×((zoom−1)×(nodeScaleRatio||1)+1)=' +
fr(20 * ((zoom - 1) * eff + 1)) + ' px',
'roam 取值(RoamController.js:117,122):true 拖动+滚轮都管;move/pan 只认拖动;scale/zoom 只认滚轮;写错的字符串两个分支都不进,等于静悄悄关掉',
'滚轮只在数据点撑出来的那块包围盒里有效(GraphView.js:108 把触发区交给 coordinateSystem.containPoint);' +
'改成 roamTrigger:"global" 整幅画布都吃,包围盒外的留白也能滚'
]);
}
bind('b5a', function () {
roamIdx = (roamIdx + 1) % ROAM_MODES.length;
if (c5) c5.setOption(g5(), true);
readP5();
});
bind('b5b', function () {
nsrIdx = (nsrIdx + 1) % NSR_VALUES.length;
if (c5) c5.setOption({ series: [{ nodeScaleRatio: any(NSR_VALUES[nsrIdx]) }] });
readP5();
});
bind('b5c', function () {
limitOn = !limitOn;
if (c5) c5.setOption({ series: [{ scaleLimit: limitOn ? { min: 0.5, max: 2 } : any(null) }] });
readP5();
});
bind('b5d', function () {
if (c5) c5.setOption({ series: [{ center: any(null), zoom: 1 }] });
readP5();
});
bind('b5e', readP5);
bind('b5f', function () {
rtIdx = (rtIdx + 1) % RTRIGGER.length;
if (c5) c5.setOption(g5(), true);
readP5();
});
/* ===== 面板六 · thumbnail ===== */
var N6 = [];
var L6 = [];
(function build6() {
for (var y = 0; y < 8; y++) for (var x = 0; x < 8; x++) {
N6.push({ id: 'g' + y + '_' + x, name: y * 8 + x + '', x: x * 40, y: y * 40 });
}
for (var r = 0; r < 8; r++) for (var q = 0; q < 8; q++) {
if (q < 7) L6.push({ source: 'g' + r + '_' + q, target: 'g' + r + '_' + (q + 1) });
if (r < 7) L6.push({ source: 'g' + r + '_' + q, target: 'g' + (r + 1) + '_' + q });
}
})();
function g6() {
return any({
type: 'graph', layout: 'none', roam: true, symbolSize: 8, data: N6, links: L6,
itemStyle: { borderWidth: 0 }, label: { show: false },
lineStyle: { color: '#9aa3b0', opacity: 0.45 }
});
}
function initP6() {
c6a = mk('p6a'); c6b = mk('p6b');
if (c6a) c6a.setOption({ series: [g6()] });
if (c6b) c6b.setOption({ thumbnail: { show: true, right: 2, bottom: 2, width: '24%', height: '24%' }, series: [g6()] });
readP6();
}
function windowRect(id) {
var hit = svgPaths(id).filter(function (p) { return p.getAttribute('fill-opacity') === '0.3'; });
if (!hit.length) return null;
var b = safe(function () { return hit[0].getBoundingClientRect(); }, null);
if (!b) return null;
return { w: Math.round(b.width * 10) / 10, h: Math.round(b.height * 10) / 10 };
}
function readP6() {
var s = sOpt(c6b);
var zoom = typeof s.zoom === 'number' ? s.zoom : 1;
var inner = rectOf(c6b, 'view');
var win = windowRect('p6b');
var dBySize = {};
nodePix(c6b).forEach(function (p) { dBySize[p.d] = (dBySize[p.d] || 0) + 1; });
setInfo('p6Info', [
'左(无 thumbnail)path 总数=' + pathCount(c6a) + ' 线=' + lineEls(c6a).length,
'右(thumbnail.show:true)path 总数=' + pathCount(c6b) + ' 线=' + lineEls(c6b).length +
' 按直径分档=' + J(dBySize),
'缩略图把同一份数据再画一遍微型版,所以微型点(直径 0.4 那档)正好是主图节点数;白色矩形就是「你现在看到的这块」',
'视窗矩形=' + J(win) + ' 当前 zoom=' + fr(zoom) + ' option.center=' + J(s.center) +
' 推出迷你内框(窗口×zoom)=' +
(win ? Math.round(win.w * zoom * 10) / 10 + '×' + Math.round(win.h * zoom * 10) / 10 : '—') +
' 主图内框=' + rectStr(inner),
'关系是 窗口 = 迷你内框 ÷ zoom:放大窗口变小;拖窗口主图跟着平移,center 会被写回 option'
]);
}
bind('b6a', function () { if (c6b) c6b.setOption({ series: [{ zoom: any(1.6) }] }); readP6(); });
bind('b6b', function () { if (c6b) c6b.setOption({ series: [{ center: any(null), zoom: 1 }] }); readP6(); });
bind('b6c', readP6);
/* ===== 面板七 · categories 与取色 ===== */
var CAT3 = [{ name: '中心仓' }, { name: '门店' }, { name: '产地' }];
var N7 = [
{ id: 'w1', name: '上海仓', category: 0 }, { id: 'w2', name: '广州仓', category: 0 },
{ id: 's1', name: '南京西路店', category: 1 }, { id: 's2', name: '天河城店', category: 1 },
{ id: 's3', name: '春熙路店', category: 1 }, { id: 's4', name: '湖滨银泰店', category: 1 },
{ id: 'y1', name: '临沧', category: 2 }, { id: 'y2', name: '耶加雪菲', category: 2 },
{ id: 'y3', name: '喜拉多', category: 2 }
];
var L7 = [{ source: 'y1', target: 'w2' }, { source: 'y2', target: 'w1' }, { source: 'w1', target: 's1' },
{ source: 'w1', target: 's4' }, { source: 'w2', target: 's2' }, { source: 'w2', target: 's3' }];
function fillsOf(chart) {
var map = {};
nodeEls(chart).forEach(function (e) {
var f = (e.style && e.style.fill) || '?';
map[f] = (map[f] || 0) + 1;
});
return map;
}
function g7(extra) {
return Object.assign({
type: 'graph', layout: 'force', roam: true, symbolSize: 16,
categories: CAT3, data: N7, links: L7,
label: { show: false }, lineStyle: { color: '#86878c', opacity: 0.5 },
force: { layoutAnimation: false, repulsion: 120, edgeLength: 60 }
}, extra || {});
}
function initP7() {
c7a = mk('p7a'); c7b = mk('p7b'); c7c = mk('p7c');
if (c7a) c7a.setOption({ legend: { top: 4, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } }, series: [g7()] });
if (c7b) c7b.setOption({ legend: { top: 4, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } }, series: [g7({ itemStyle: { color: '#c0504d' } })] });
if (c7c) c7c.setOption({
legend: { top: 4, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } },
series: [g7({ itemStyle: { color: '#c0504d' }, data: N7.map(function (n) { return n.category === 2 ? Object.assign({}, n, { itemStyle: { color: '#4caf50' } }) : n; }) })]
});
readP7();
}
function readP7() {
var sel = safe(function () { return c7a.getOption().legend[0].selected; }, null);
setInfo('p7Info', [
'只写 categories ' + J(fillsOf(c7a)),
'+ 系列 itemStyle.color ' + J(fillsOf(c7b)),
'+ 数据项 itemStyle ' + J(fillsOf(c7c)),
'优先级:数据项 > 系列 > 调色板。中间那张三类点被系列色盖成同一种颜色——这就是「分类颜色不生效」',
'legend 选中态 ' + J(sel) + '(图例项来自 categories 的名字)'
]);
}
bind('b7a', function () { safe(function () { c7a.dispatchAction({ type: 'legendUnSelect', name: '门店' }); }); readP7(); });
bind('b7b', function () { safe(function () { c7a.dispatchAction({ type: 'legendAllSelect' }); }); readP7(); });
/* ===== 面板八 · 边 ===== */
var CURVE = [0, 0.3, -0.3, 0.8];
var curveIdx = 1;
var arrowOn = true;
var N8 = [{ id: 'a', name: 'A', x: 40, y: 120 }, { id: 'b', name: 'B', x: 200, y: 120 },
{ id: 'c', name: 'C', x: 340, y: 120 }, { id: 'd', name: 'D', x: 200, y: 20 }];
var L8 = [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }, { source: 'd', target: 'b', label: { show: true, formatter: '84 kg' } }];
function g8(extra) {
return { series: [Object.assign({
type: 'graph', layout: 'none', symbolSize: 16, data: N8, links: L8,
edgeSymbol: arrowOn ? ['none', 'arrow'] : ['none', 'none'], edgeSymbolSize: 8,
label: { show: true, position: 'right', fontSize: 11 },
edgeLabel: { show: false },
lineStyle: { curveness: CURVE[curveIdx], color: '#86878c', opacity: 0.7, width: 1.5 },
itemStyle: { borderWidth: 0 }
}, extra || {})] };
}
function initP8() {
c8a = mk('p8a'); c8b = mk('p8b');
if (c8a) c8a.setOption(g8());
if (c8b) c8b.setOption({
series: [{
type: 'graph', layout: 'none', symbolSize: 14, autoCurveness: true,
data: [{ id: 'l', name: '上海仓', x: 60, y: 120, symbol: 'rect' }, { id: 's', name: '门店', x: 340, y: 120 }],
links: [{ source: 'l', target: 's', lineStyle: { color: '#5070dd' } },
{ source: 'l', target: 's', lineStyle: { color: '#b6d634' } },
{ source: 'l', target: 's', lineStyle: { color: '#fac858' } }],
label: { show: true, position: 'top', fontSize: 11 },
lineStyle: { width: 1.5, opacity: 0.8 }
}]
});
readP8();
}
// ec-line 的 shape 是数据坐标,不是像素:x1/y1 就是 source 节点写的那对 x/y。
// 关掉箭头时实测 偏移 = curveness × 数据圆心距,分毫不差(0.3×160=48、0.8×160=128)。
function curveRows(chart) {
return lineEls(chart).map(function (e) {
var s = any(e).shape || {};
var len = Math.sqrt(Math.pow(s.x2 - s.x1, 2) + Math.pow(s.y2 - s.y1, 2));
if (!isFinite(s.cpx1) || !isFinite(s.cpy1)) return '直边 弦长=' + fr(len);
var mx = (s.x1 + s.x2) / 2, my = (s.y1 + s.y2) / 2;
var off = Math.sqrt(Math.pow(s.cpx1 - mx, 2) + Math.pow(s.cpy1 - my, 2));
return '弦长=' + fr(len) + ' 偏移=' + fr(off) + ' 偏移/弦长=' + fr(off / len);
});
}
// 第一条边的弦长(数据坐标):开箭头时它比圆心距短一截
function firstChord(chart) {
var e = lineEls(chart)[0];
var s = e ? any(e).shape : null;
if (!s || !isFinite(s.x1)) return null;
return Math.sqrt(Math.pow(s.x2 - s.x1, 2) + Math.pow(s.y2 - s.y1, 2));
}
function readP8() {
setInfo('p8Info', [
'左 curveness=' + fr(CURVE[curveIdx]) + ' 箭头=' + (arrowOn ? '开' : '关') + ' → ' + J(curveRows(c8a)),
'线元素=' + lineEls(c8a).length + ' 箭头元素=' + arrowCount(c8a) + '(edgeSymbol 现在是 ' + J(any(sOpt(c8a)).edgeSymbol) + ')',
'右 autoCurveness:true 三条平行边 → ' + J(curveRows(c8b)),
'弦长与偏移都是数据坐标,不是像素:A(40,120) 到 B(200,120) 圆心距 160,160 × 0.3 = 48 就是控制点偏移',
'此刻弦长 ' + fr(firstChord(c8a)) + ':' + (arrowOn
? '开着箭头,线两头被缩短、控制点按缩短后的端点重算,所以偏移/弦长 不再正好等于 curveness'
: '箭头关掉,偏移/弦长 就正好等于 curveness'),
'刚按完按钮读到的可能是动画过渡中那一帧,过一秒按③再读数就稳'
]);
}
bind('b8a', function () {
curveIdx = (curveIdx + 1) % CURVE.length;
if (c8a) c8a.setOption({ series: [{ lineStyle: { curveness: any(CURVE[curveIdx]) } }] });
readP8();
});
bind('b8b', function () {
arrowOn = !arrowOn;
if (c8a) c8a.setOption({ series: [{ edgeSymbol: any(arrowOn ? ['none', 'arrow'] : ['none', 'none']) }] });
readP8();
});
bind('b8c', readP8);
/* ===== 面板九 · focus 与悬停 ===== */
var N9 = [{ id: 'a', name: 'a', x: 0, y: 0 }, { id: 'b', name: 'b', x: 200, y: 0 },
{ id: 'c', name: 'c', x: 400, y: 0 }, { id: 'd', name: 'd', x: 200, y: 160 }];
var L9 = [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }, { source: 'c', target: 'd' }];
function g9(focus) {
return any({ series: [{
type: 'graph', layout: 'none', animation: false, symbolSize: 20, data: N9, links: L9,
itemStyle: { borderWidth: 0 }, label: { show: true, position: 'inside', fontSize: 10, color: '#fff' },
emphasis: { focus: any(focus), blurScope: 'coordinateSystem' },
lineStyle: { color: '#86878c', opacity: 0.5 }
}] });
}
function initP9() {
c9a = mk('p9a'); c9b = mk('p9b'); c9c = mk('p9c');
if (c9a) c9a.setOption(g9('none'));
if (c9b) c9b.setOption(g9('self'));
if (c9c) c9c.setOption(g9('adjacency'));
[['p9a', c9a], ['p9b', c9b], ['p9c', c9c]].forEach(function (pair) {
if (!pair[1]) return;
any(pair[1]).on('mouseover', function () { later(function () { readP9('鼠标悬停在 ' + pair[0]); }, 150); });
any(pair[1]).on('globalout', function () { later(function () { readP9('鼠标已移出'); }, 150); });
});
readP9('还没悬停');
}
var lastHover = '(还没悬停)';
function readP9(tag) {
if (tag) lastHover = tag;
setInfo('p9Info', [
lastHover + ' (= 后面是元素的 style.opacity,[blur] 表示它进了淡出状态)',
'none ' + focusRows(c9a),
'self ' + focusRows(c9b),
'adjacency ' + focusRows(c9c),
'a—b—c—d 一条链,悬停 b:self 只留 b 自己,三条边一起淡;adjacency 才把邻居 a、c 和边 a→b、b→c 留下,只淡 d 与 c→d;none 谁都不淡',
'blur 的缺省透明度是「这个元素原本的透明度 × 0.1」:节点 1 → 0.1,边写了 lineStyle.opacity:0.5 → 0.05'
]);
}
bind('b9a', function () {
safe(function () {
[c9a, c9b, c9c].forEach(function (c) { if (c) c.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: 1 }); });
});
later(function () { readP9('dispatchAction highlight dataIndex:1'); }, 80);
});
bind('b9b', function () {
safe(function () {
[c9a, c9b, c9c].forEach(function (c) { if (c) c.dispatchAction({ type: 'downplay', seriesIndex: 0 }); });
});
later(function () { readP9('dispatchAction downplay'); }, 80);
});
/* ===== 面板十 · 实战 A:供应链 ===== */
var CAT10 = [{ name: '产地' }, { name: '中心仓' }, { name: '门店' }];
var ORIGINS = [{ id: 'o-yn', name: '云南临沧', category: 0, symbol: 'triangle' },
{ id: 'o-et', name: '埃塞耶加雪菲', category: 0, symbol: 'triangle' },
{ id: 'o-br', name: '巴西喜拉多', category: 0, symbol: 'triangle' }];
var WAREHOUSES = [{ id: 'w-sh', name: '上海仓', category: 1, symbol: 'rect' },
{ id: 'w-gz', name: '广州仓', category: 1, symbol: 'rect' },
{ id: 'w-cd', name: '成都仓', category: 1, symbol: 'rect' },
{ id: 'w-bj', name: '北京仓', category: 1, symbol: 'rect' }];
var HOME = { S01: 'w-sh', S05: 'w-sh', S09: 'w-sh', S07: 'w-sh', S03: 'w-gz', S04: 'w-gz', S11: 'w-gz', S02: 'w-bj', S08: 'w-bj', S10: 'w-bj', S06: 'w-cd', S12: 'w-cd' };
var WH_SOURCE = {
'w-sh': [['o-et', 0.5], ['o-br', 0.35], ['o-yn', 0.15]],
'w-gz': [['o-br', 0.6], ['o-yn', 0.4]],
'w-cd': [['o-yn', 1]],
'w-bj': [['o-br', 0.7], ['o-et', 0.3]]
};
var STORES = [];
// 演示口径:门店月运量 = 门店面积 ÷ 8(area 是 data/stores.json 里的真实字段),
// 产地→仓的份额写在 WH_SOURCE 里。这两个都是本章编的,不是数据字典里的字段。
function storeTons(s) { return Math.max(4, Math.round(any(s).area / 8)); }
function whTons(id) {
var t = 0;
STORES.forEach(function (s) { if (HOME[any(s).storeId] === id) t += storeTons(s); });
return t;
}
function supplyEdges() {
var e = any([]);
Object.keys(WH_SOURCE).forEach(function (wid) {
WH_SOURCE[wid].forEach(function (pair) {
var t = Math.round(whTons(wid) * pair[1]);
e.push({ source: pair[0], target: wid, value: t, lineStyle: { width: 1 + Math.round(t / 30) } });
});
});
STORES.forEach(function (s) {
var t = storeTons(s);
e.push({ source: HOME[any(s).storeId], target: 'st-' + any(s).storeId, value: t, lineStyle: { width: 1 + Math.round(t / 20) } });
});
e.push({ source: 'w-sh', target: 'st-S06', value: 6, lineStyle: { type: 'dashed', color: '#c0504d', width: 1.5 } });
return e;
}
function storeNodes() {
var deg = degreeMap(supplyEdges());
var nodes = any(ORIGINS.concat(WAREHOUSES));
STORES.forEach(function (s) {
nodes.push({ id: 'st-' + any(s).storeId, name: any(s).name + '(' + any(s).city + ')', category: 2 });
});
return nodes.map(function (n) {
var d = deg[n.id] || 1;
return Object.assign({}, n, { symbolSize: n.category === 2 ? 9 + d * 3 : 14 + d * 3 });
});
}
function layered() {
var rows = { 0: [], 1: [], 2: [] };
storeNodes().forEach(function (n) { rows[n.category].push(n); });
var out = [];
[0, 1, 2].forEach(function (cat) {
var x = cat === 0 ? 60 : cat === 1 ? 300 : 560;
rows[cat].forEach(function (n, i) {
var step = 320 / Math.max(1, rows[cat].length - 1);
out.push(Object.assign({}, n, { x: x, y: 20 + i * step }));
});
});
return out;
}
function initP10() {
c10 = mk('p10');
if (!c10) return;
c10.setOption({
thumbnail: { show: true, right: 4, bottom: 4, width: '15%', height: '15%' },
legend: { top: 6, data: ['产地', '中心仓', '门店'] },
tooltip: { trigger: 'item' },
series: [{
type: 'graph', layout: 'force', roam: true, draggable: true,
categories: CAT10, data: storeNodes(), links: supplyEdges(),
edgeSymbol: ['none', 'arrow'], edgeSymbolSize: 7,
label: { show: true, position: 'right', fontSize: 11 },
edgeLabel: { show: false, fontSize: 10, formatter: function (pr) { return pr.value + ' 吨'; } },
lineStyle: { color: '#86878c', opacity: 0.55, curveness: 0.12 },
force: { repulsion: 320, edgeLength: [60, 140], gravity: 0.06, layoutAnimation: false },
emphasis: { focus: 'adjacency' }
}]
});
readP10();
afterPaint(readP10);
}
function catCount() {
var n = [0, 0, 0];
storeNodes().forEach(function (x) { n[x.category]++; });
return n;
}
function topDegree() {
var deg = degreeMap(supplyEdges());
return Object.keys(deg).sort(function (a, b) { return deg[b] - deg[a]; }).slice(0, 3)
.map(function (id) { return id + ':' + deg[id]; });
}
function readP10() {
var all = nodePix(c10);
var bySize = {};
all.forEach(function (x) { bySize[x.d] = (bySize[x.d] || 0) + 1; });
var main = all.filter(function (x) { return x.d >= 6; });
var mini = all.length - main.length;
setInfo('p10Info', [
'layout=' + sOpt(c10).layout + ' 节点 ' + storeNodes().length + '(产地 ' + catCount()[0] + ' / 仓 ' + catCount()[1] + ' / 门店 ' + catCount()[2] + ') 边 ' + supplyEdges().length,
'度数前三 ' + J(topDegree()) + ' path 数=' + pathCount(c10) + ' 线=' + lineEls(c10).length + ' 文字=' + textEls(c10).length,
'主图 ' + main.length + ' 个点的像素包围盒 ' + J(pixBBox(main)) + ' 最小间距=' + fr(minGap(main)),
'缩略图那批微型点 ' + mini + ' 个,按直径分档 ' + J(bySize) + '(图例图标也是 path,所以这里比节点数多几个)',
'四仓月运量 ' + J(['w-sh', 'w-gz', 'w-cd', 'w-bj'].map(function (id) { return id + ':' + whTons(id); })) + ' 吨(门店面积÷8 累加)'
]);
}
var tonLabelOn = false;
bind('b10a', function () {
tonLabelOn = !tonLabelOn;
if (!c10) return;
c10.setOption({ series: [{ edgeLabel: { show: tonLabelOn } }] });
setInfo('p10Info', ['运量标签' + (tonLabelOn ? '已开(edgeLabel.show:true,formatter 取边的 value)' : '已关'),
'此刻文字元素数=' + textEls(c10).length + ',边=' + supplyEdges().length + ',节点=' + storeNodes().length]);
});
bind('b10b', function () {
if (c10) c10.setOption({ series: [{ layout: 'circular', data: storeNodes(), circular: { rotateLabel: true } }] });
readP10();
});
bind('b10c', function () {
if (c10) c10.setOption({ series: [{ layout: 'none', data: layered(), links: supplyEdges() }] });
readP10();
});
bind('b10d', readP10);
/* ===== 面板十一 · 实战 B:依赖图 ===== */
var N11 = [
{ id: 'html', name: 'ch26/index.html', x: 40, y: 40, symbol: 'rect', symbolSize: 34, category: 0 },
{ id: 'app', name: 'ch26/app.js', x: 40, y: 200, symbol: 'rect', symbolSize: 34, category: 0 },
{ id: 'umd', name: 'lib/echarts.min.js\n1 121 883 字节', x: 260, y: 120, symbol: 'roundRect', symbolSize: 44, category: 1 },
{ id: 'ec', name: 'echarts@6.1.0', x: 480, y: 60, category: 1 },
{ id: 'zr', name: 'zrender@6.1.0', x: 660, y: 60, category: 2 },
{ id: 'ts1', name: 'tslib@2.3.0', x: 840, y: 120, category: 2 },
{ id: 'ts2', name: 'tslib@2.3.0', x: 660, y: 220, category: 2 }
];
var L11 = [
{ source: 'html', target: 'umd', lineStyle: { width: 2 } },
{ source: 'html', target: 'app', lineStyle: { width: 2 } },
{ source: 'umd', target: 'ec', lineStyle: { width: 2, color: '#e0746f' } },
{ source: 'ec', target: 'zr', label: { show: true, formatter: '6.1.0' } },
{ source: 'ec', target: 'ts1', label: { show: true, formatter: '2.3.0' } },
{ source: 'zr', target: 'ts2', label: { show: true, formatter: '2.3.0' } }
];
function initP11() {
c11 = mk('p11');
if (!c11) return;
c11.setOption({
tooltip: { trigger: 'item' },
series: [{
type: 'graph', layout: 'none', roam: true, draggable: true,
data: N11, links: L11,
categories: [{ name: '本章' }, { name: '打包产物' }, { name: '运行时依赖' }],
edgeSymbol: ['none', 'arrow'], edgeSymbolSize: 9,
label: { show: true, fontSize: 11, formatter: '{b}' },
edgeLabel: { show: true, fontSize: 10, color: '#6f675c' },
lineStyle: { color: '#86878c', width: 1.5, opacity: 0.75, curveness: 0 },
emphasis: { focus: 'adjacency' }
}]
});
readP11();
}
function readP11() {
setInfo('p11Info', [
'节点 ' + N11.length + ' 边 ' + L11.length + ' path 数=' + pathCount(c11) + ' 文字=' + textEls(c11).length,
'分层 x:本章 ' + J(N11.filter(function (n) { return n.category === 0; }).map(function (n) { return n.x; })) +
' echarts ' + J(N11.filter(function (n) { return n.category === 1; }).map(function (n) { return n.x; })) +
' tslib ' + J(N11.filter(function (n) { return n.category === 2; }).map(function (n) { return n.x; })),
'lib/echarts.min.js 与 npm 包版本对账由 tools/verify-types.mjs 的 assertVersionMatch 管,本章实测两边都是 6.1.0'
]);
}
bind('b11a', function () {
safe(function () { c11.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: N11.findIndex(function (n) { return n.id === 'ts1'; }) }); });
later(readP11, 60);
});
bind('b11b', function () { safe(function () { c11.dispatchAction({ type: 'downplay', seriesIndex: 0 }); }); readP11(); });
/* ===== 启动 ===== */
function readEveryPanel() {
[readP1, readP2, readP3UnlessLocked, readP4, readP5, readP6, readP7, readP8, readP9, readP10, readP11]
.forEach(function (f) { safe(f); });
}
function initAll() {
initP1(); initP2(); initP3(); initP4(); initP5(); initP6();
initP7(); initP8(); initP9(); initP11();
afterPaint(readEveryPanel);
}
initAll();
getJSON('../../data/stores.json').then(function (rows) {
STORES = rows;
initP10();
setStatus('第 26 章实验台:11 个面板 / 26 张图。门店 ' + rows.length + ' 家取自 ../../data/stores.json,' +
'仓—店归属写在 app.js 的 HOME 里,月运量 = 门店 area ÷ 8(演示口径)。' +
'每张图下面的黄框是现场读数:节点圆心与直径来自元素的 getComputedTransform(),边的控制点来自 ec-line 的 shape。');
}).catch(function (e) {
setStatus('读取 data/stores.json 失败:' + (e && e.message));
});
【练习】
- 三种布局的分工:同一份数据分别用
layout:'none'、'circular'、'force'。哪一张会读你写的x/y?哪一张完全不看?哪一张把x/y当起点、结果又不写回 option?如果三张都不写x/y,哪一张是空白? - 算力预算:用
ceil(log(0.01/friction)/log(0.992))算friction:0.6、0.3、0.05的步数;按本章实测的 53.0 ms/步估算 2000 个节点各要跑多久。哪一档最接近实测的 10667 ms? - 度数写回尺寸:面板十的规则是仓/产地
14+d×3、门店9+d×3。不运行页面,写出 4 个仓、3 个产地、12 家门店各自的symbolSize;S06 为什么是 15? scaleLimit的读法:给面板五加scaleLimit:{min:1,max:1.2},滚进 14 格、再滚出 24 格后,getOption().series[0].zoom分别是多少?为什么不是「请求值 3.797 / 0.386」?- 缩略图的窗口:面板六
zoom:1.6时窗口 57.3×43;如果zoom改成 3.2,窗口应是多少?改成 0.8 呢?(窗口 = 迷你内框 ÷zoom) - 邻接高亮:a—b—c—d 链,悬停 c。
focus:'adjacency'时四个节点、三条边的透明度各是多少?哪条边保留 0.5,哪条边掉到 0.05? - 给依赖图加一条边:在面板十一加
{source:'app',target:'ts1'}。path和「文字」各变成多少?如果不想让这条边显示默认的「app > ts1」,该怎么写? - 综合:给面板十加第五个仓
w-hz,把 S07、S10 改归它,并加一条o-yn → w-hz的产地边(份额自定)。验收:节点 20、边 22;度数 w-sh 7、w-gz 5、w-bj 4、w-cd 3、w-hz 3;path 65、线 44、文字 23(开运量标签 45);缩略图克隆 20 个。再调scaleLimit:{min:0.8,max:1.5},确认zoom回写被夹住。
【小结】
关系图是全书第一张没有坐标轴的图,本章把它拆成了四件事。第一,数据是两张表:data/links 各管一头,边的 source/target 按 id、name、dataIndex 的顺序找节点,对不上就静默丢边。第二,位置有三个来源:none 用你写的 x/y,circular 按 symbolSize 加权摆圆,force 用弹簧力、向心力、斥力加冷却自己找;力导向的结果不写回 option,预算是 friction 决定的迭代步数。第三,大图靠漫游与缩略图:roam 的五种取值、roamTrigger 的触发区、nodeScaleRatio 的直径公式、scaleLimit 的夹取回写,以及 thumbnail 的图元克隆账本。第四,关系要编码:edgeSymbol 画方向,curveness/autoCurveness 理清平行边,categories 与 itemStyle 决定颜色,emphasis.focus:'adjacency' 回答「改这个点会波及谁」。
两张实战图把这几层合起来:供应链图用「形状 + 大小 + 线宽 + 缩略图」讲仓配流量,依赖图用「手写分层坐标 + 边标签」讲谁依赖谁。下一章换一种关系数据:层级。树图、矩形树图、旭日图不再问「谁连着谁」,而是问「谁属于谁」——位置由父子关系决定,和本章的力导向正好是两种极端。
本文系统讲解ECharts 6.x中关系图(graph)的核心机制,涵盖节点表与边表的双数据结构、三种layout(cartesian/circular/force)的原理与适用场景、力导向布局的弹簧/向心/斥力计算模型及冷却预算公式、漫游(roam)与缩略图(thumbnail)的交互实现细节、边箭头/curveness/平行边控制、categories分类配色优先级,以及邻接高亮(focus/blurScope)等关键配置。内容基于实测数据与源码逻辑,聚焦前端可视化工程实践。

545

被折叠的 条评论
为什么被折叠?



