资讯详情 从KGML到D3交互网络图:KEGG通路可视化实践
📅 2026/10/26 2:25:30
简介KPVKEGG PATHWAY VIEWER是一套面向生物信息学与系统生物学学习者的KEGG代谢通路可视化工具源码适合希望把通路数据搬到浏览器中交互分析的高校师生与科研人员使用。项目依据AP检测算法对网络中的蛋白质进行分类可直观标出连通度最高的关键节点同时以动态HTML方式呈现KEGG路径支持节点删除、边添加等基本操作便于模拟和观察网络结构变化。压缩包共956个文件约62.01MB包含211个js脚本、167个html页面、61个css与42个less样式文件以及177个png、29个gif等图表素材另有131个md说明文档和若干php、sql、sqlite等后端与数据文件构成前端展示、样式布局、数据存储与文档说明相对完整的结构。该资源已有1086人学习下载读者可据此了解通路可视化的实现思路参考蛋白质节点分类与网络交互逻辑并在本地复现页面效果或二次开发。1. 差异基因列表跑完富集评审会上被问一句「这几个基因在细胞周期通路里到底怎么连的」表格答不了这个问题我一般直接打开 kegg-network-viewer把通路编号或者手里的 KGML 文件拖进去浏览器里就长出一张能缩放、能拖动、点一下节点就能高亮邻居的网络图产物是一个纯静态 HTML 页面不用装客户端、不用连数据库、也不用配后端。它适合拿来做通路汇报、给富集结果做二次确认也适合当练手项目——从 XML 解析到布局、交互、导出四块拆得很干净想加什么字段都能找到落点。下面按「结构长什么样、数据怎么进来、图画到哪、哪里会翻车、怎么改成自己的」的顺序拆一遍。2. 架构与目录一个 KGML 文件怎么变成浏览器里的一张图2.1 三层结构抓取、规范化、渲染这个工具的骨架很朴素三件事分得很开。第一层是抓取把公开通路数据里的 KGML一种描述通路内基因、化合物、酶以及它们相互关系的 XML拉到本地存成文件这一步是可选的你手上已经有.kgml就跳过。第二层是规范化在浏览器里用DOMParser把 XML 拆成两个扁平数组nodes和edges把「XML 树」变成「图数据结构」。第三层是渲染用 D3 把这两个数组画成 SVG挂上缩放、拖拽、高亮。为什么不干脆在后端出图因为通路图的交互密度太高——鼠标移到某个基因上要看它的邻居点一下子通路要能下钻改配色要立刻生效。后端每次出 PNG等于每点一下都回源一次而且发给合作方还得连服务。静态 HTML 的代价是首次解析稍微慢一点几百个 entry 的通路大概几十毫秒到一百多毫秒换来的是双击就能打开。方案出图速度交互能力分发成本适合场景服务端渲染 PNG快几乎没有要维护服务论文插图、批量出图桌面客户端慢要装运行环境强高长期做通路分析浏览器 SVG本项目首次解析略慢强拷一个目录就走汇报、分享、二次开发这也是我建议先读parse-kgml.js再读render.js的原因数据层一旦对齐渲染层出问题基本都能一眼定位。2.2 目录结构每个文件夹各管一摊解压后按职责捋一遍大致是下面这样具体文件名可能略有出入顺着import关系对得上就行kegg-network-viewer/ ├── index.html # 唯一入口挂载点 顶部工具栏选文件、选布局、导出 ├── src/ │ ├── main.js # 启动流程读参数 → parse → normalize → render │ ├── parse-kgml.js # KGML(XML) → { nodes, edges } │ ├── normalize.js # 补 degree、合并重复 entry、算包围盒 │ ├── render.js # D3 布局、缩放拖拽、tick 更新 │ ├── interactions.js # 邻居高亮、搜索框、图例 │ └── export.js # SVG / PNG 导出 ├── styles/ │ └── viewer.css # 节点配色、字体、工具栏样式 ├── data/ │ ├── raw/ # 原始 .kgml 文件 │ └── build/ # 预转换好的 .json省掉每次解析 ├── scripts/ │ └── fetch-kgml.mjs # 批量下载 转 JSON 的 Node 脚本 └── samples/ # 两三张现成通路用来验证环境值得单独说的是data/build/。很多人第一次跑会忽略它结果每次都从 XML 重新解析。预转换这一步在批量看几十条通路时收益很明显——JSON 直接fetch回来就能画省掉DOMParser的开销也方便你在外面用脚本先做过滤比如只保留有差异基因的 entry。2.3 跑起来一条命令起服务三种方式喂数据不需要npm install也不需要打包工具它就是一个静态站点。但有一点必须注意不能双击index.html。在file://协议下浏览器会把fetch(./data/raw/hsa04110.kgml)当成跨域请求拦掉页面直接空白。# 1) 进目录 cd kegg-network-viewer # 2) 起一个静态服务任选其一 python3 -m http.server 8080 # 或者npx serve . # 3) 浏览器打开 http://localhost:8080起好服务之后有三种喂数据的方式按方便程度排第一直接把samples/里的 kgml 文件拖到页面中间的上传区走FileReader读文本再解析完全不经过网络。第二在地址栏带参数比如http://localhost:8080/?pathwayhsa04110main.js会拼出./data/build/hsa04110.json去拉。第三改main.js里的默认路径常量让它一进来就加载你关心的那条通路。提示跑之前先确认浏览器的开发者工具 Console 是干净的。这项目用的是原生 ES Modulescript typemodule如果你的服务把.js的 MIME 类型返回成text/plain模块加载会被拒绝报错信息是Failed to load module script看着像代码问题其实是服务配置问题。3. 数据层KGML 里的 entry、relation、reaction 怎么读成 nodes 和 edges3.1 先认清四种 entry不然字段全对不上KGML 里的entry是节点的主要来源type属性决定它是什么gene是基因name形如hsa:7157compound是化合物cpd:C00002enzyme是酶map是子通路引用path:hsa04110点进去应该是另一张图。这里有两个特别容易踩的点。一是同一个entry可以挂多个graphics子节点表示这个基因在图上画了好几个框常见于跨膜蛋白或者被拆开摆放的复合物二是name属性可能是空格分隔的多个基因表示这一组基因共用一个框。如果你只读第一个graphics、只取name的第一段图画出来是对的但悬停看不到完整信息做下游分析时也会丢基因。!-- 一个 entry 挂两个 graphics、name 里带两个基因的典型形态 -- entry id12 namehsa:7157 hsa:7158 typegene graphics nameTP53, p53 fgcolor#000000 bgcolor#BFFFBF typerectangle x120 y340 width46 height17/ graphics nameTP53, p53 fgcolor#000000 bgcolor#BFFFBF typerectangle x120 y300 width46 height17/ /entrygraphics上的x、y是矩形左上角坐标width、height是框的尺寸。很多通路图的坐标能到 1500 以上viewBox直接写死0 0 800 600就会把右下角裁掉。3.2 解析函数把 XML 压成两个扁平数组核心就是一个函数输入 XML 文本输出{ nodes, edges }。下面这段是我在这个项目里用得最顺的写法// parse-kgml.js —— KGML 文本 → { nodes, edges } const TYPE_COLOR { gene: #BFFFBF, enzyme: #B3B3FF, compound: #FFD9B3, map: #FFFFFF, ortholog: #CCCCCC, }; export function parseKgml(xmlText) { const doc new DOMParser().parseFromString(xmlText, application/xml); // XML 不合法时 DOMParser 不抛异常而是塞一个 parsererror 节点 const err doc.querySelector(parsererror); if (err) throw new Error(KGML 不是合法 XML err.textContent.slice(0, 120)); const nodes []; for (const entry of doc.querySelectorAll(pathway entry)) { const id entry.getAttribute(id); const type entry.getAttribute(type); const name entry.getAttribute(name) || ; const boxes [...entry.querySelectorAll(graphics)].map(g ({ x: g.getAttribute(x), y: g.getAttribute(y), w: g.getAttribute(width), h: g.getAttribute(height), })); const first boxes[0] || { x: 0, y: 0, w: 46, h: 17 }; nodes.push({ id, // entry id 是唯一主键边也用它对齐 type, genes: name.split( ).filter(Boolean), // 一个 entry 多基因时全部保留 label: (entry.querySelector(graphics)?.getAttribute(name) || name) .split(,)[0].trim(), // 只放第一个别名避免标签溢出 // 坐标统一存「中心点」渲染时才好直接用 translate 摆位 x: first.x first.w / 2, y: first.y first.h / 2, w: first.w, h: first.h, boxes, // 多框 entry 靠它把每个框都画出来 color: TYPE_COLOR[type] ?? #DDDDDD, }); } return { nodes, edges: parseEdges(doc, nodes) }; }几个参数的取舍值得说明白。id用entry的id而不是基因名——同一个基因在一条通路里可能出现多次用基因名做主键会互相覆盖。x、y存中心点而非左上角是因为 D3 里给g打transform: translate(x, y)时子元素的矩形必须用-w/2、-h/2反向偏移中心点对齐比左上角对齐少写一堆补偿。boxes保留完整数组是为了后面能把多框 entry 的每个框都画出来否则跨膜蛋白看起来会缺一半。TYPE_COLOR这张表就是配色入口想改成自己的色系只动这一个对象即可别去viewer.css里逐类选择器改。3.3 relation 和 reaction两种边别混着画边的来源有两处语义完全不同。relation描述调控关系激活、抑制、磷酸化reaction描述酶催化的底物到产物。前者一条记录就是一条边后者要从substrate和product做笛卡尔积——一个反应可能有多个底物、多个产物。// parse-kgml.js —— 边解析 去重 function parseEdges(doc, nodes) { const valid new Set(nodes.map(n n.id)); // 先建 id 白名单挡掉悬空边 const seen new Set(); const edges []; const push (source, target, type, subtype) { if (!source || !target || source target) return; if (!valid.has(source) || !valid.has(target)) return; const key ${type}|${source}|${target}|${subtype}; if (seen.has(key)) return; // 同一对节点同一类型只留一条 seen.add(key); edges.push({ id: e${edges.length}, source, target, type, subtype }); }; // relation基因 / 化合物之间的调控 for (const rel of doc.querySelectorAll(pathway relation)) { const sub rel.querySelector(subtype); push(rel.getAttribute(entry1), rel.getAttribute(entry2), rel.getAttribute(type), sub?.getAttribute(name) ?? unknown); } // reaction酶催化substrate × product 展开成多条边 for (const rxn of doc.querySelectorAll(pathway reaction)) { const subs [...rxn.querySelectorAll(substrate)].map(s s.getAttribute(id)); const prods [...rxn.querySelectorAll(product)].map(p p.getAttribute(id)); for (const s of subs) for (const p of prods) { push(s, p, reaction, rxn.getAttribute(type)); } } return edges; }去重键里带上subtype是关键。同两个基因之间既可能是activation又可能是phosphorylation只按source|target去重会把其中一条吞掉画出来就少一个箭头。valid白名单用来挡悬空边——KGML 里偶尔会引用当前文件没有定义的 entry比如它属于另一条子通路不挡掉的话 D3 的forceLink会直接报missing: xxx然后整张图不动。push里我特意排掉了自环source target。反应里确实存在底物就是产物的情况但画在图上是一个绕过节点的圈视觉噪音很大我的做法是先过滤需要时再单独列一张表。4. 渲染层在 HTML 里把网络画出来并让它可以被操作4.1 布局二选一预置坐标还是力导向这是整个项目里第一个要你做决定的开关。preset模式用 KGML 自带的坐标节点基本落在原图的位置上细胞膜、细胞核、线粒体的相对关系还在做通路汇报时生物学含义不会丢。force模式纯用力导向适合你把自己拼的多条通路合并成一张图——这时候原始坐标来自不同画布叠在一起必然打架。两种模式共用同一套渲染代码差别只在于有没有给模拟加forceX/forceY约束。这里有个反直觉的细节约束的目标值不能直接用d.x。forceX在每帧都会修改d.x如果目标也读d.x等于节点拽着自己的鞋带往上提跑一会儿就被力导向推得面目全非。所以要在启动模拟之前把原始坐标拷一份只读的ox、oy。// render.js —— 骨架部分初始化、数据绑定、模拟 export function renderNetwork(container, graph, opts {}) { const { mode preset, width 1200, height 900 } opts; // 原坐标备份forceX/forceY 只能指向 ox/oy不能指向会被模拟改写的 x/y graph.nodes.forEach(d { d.ox d.x; d.oy d.y; }); const svg d3.select(container).append(svg) .attr(width, 100%) .attr(height, 100%) .attr(viewBox, 0 0 ${width} ${height}) // 用通路包围盒算出来的尺寸 .attr(preserveAspectRatio, xMidYMid meet); const root svg.append(g); // 缩放只作用于这一层 const linkLayer root.append(g).attr(class, links); const nodeLayer root.append(g).attr(class, nodes); const link linkLayer.selectAll(line) .data(graph.edges, d d.id) .join(line) .attr(stroke, #8a8a8a) .attr(stroke-width, 1.2) .attr(marker-end, url(#arrow)); const node nodeLayer.selectAll(g.node) .data(graph.nodes, d d.id) // 用 entry id 做 key重绘才不会错位 .join(g) .attr(class, node); node.append(rect) .attr(x, d -d.w / 2) // 坐标是中心点矩形反向偏移半个宽高 .attr(y, d -d.h / 2) .attr(width, d d.w) .attr(height, d d.h) .attr(rx, 3) .attr(fill, d d.color) .attr(stroke, #333) .attr(stroke-width, 0.8); node.append(title).text(d d.genes.join(\n)); // 悬停看全部基因标签只放第一个 const sim d3.forceSimulation(graph.nodes) .force(link, d3.forceLink(graph.edges).id(d d.id).distance(70).strength(0.4)) .force(charge, d3.forceManyBody().strength(-150)) .force(collide, d3.forceCollide().radius(d Math.max(d.w, d.h) / 2 3)) .on(tick, () { link.attr(x1, d d.source.x).attr(y1, d d.source.y) .attr(x2, d d.target.x).attr(y2, d d.target.y); node.attr(transform, d translate(${d.x},${d.y})); }); if (mode preset) { sim.force(x, d3.forceX(d d.ox).strength(0.9)) .force(y, d3.forceY(d d.oy).strength(0.9)) .alpha(0.6); // 初始扰动别太大避免开局乱飞 } return { svg, root, node, link, sim }; }参数上distance(70)决定连线的最短长度通路里节点密的时候调到 4050 会更好看strength(-150)是斥力负数越大越散超过 -400 小通路会散成一盘沙collide的半径用节点尺寸算不要写死常量基因框和化合物框的宽度差很多。alpha(0.6)让预置模式不用从高温开始降温省掉开局那两三秒的抖动。viewBox的两个参数不要写死正确做法是遍历所有boxes求minX/minY/maxX/maxY再各留 40 像素边距。4.2 缩放、拖拽与邻居高亮交互部分的代码量不大但顺序有讲究先挂zoom再挂drag否则拖节点的时候画布跟着一起平移。// render.js —— 交互缩放 拖拽 点击高亮 export function bindInteractions({ svg, root, node, link, sim }, graph) { // 1) 画布缩放 const zoom d3.zoom() .scaleExtent([0.15, 8]) // 下限别低于 0.1再小就只剩噪点 .on(zoom, ev root.attr(transform, ev.transform)); svg.call(zoom); // 2) 节点拖拽拖的时候临时固定 fx/fy松手后放开 node.call(d3.drag() .on(start, (ev, d) { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx d.x; d.fy d.y; }) .on(drag, (ev, d) { d.fx ev.x; d.fy ev.y; }) .on(end, (ev, d) { if (!ev.active) sim.alphaTarget(0); d.fx null; d.fy null; })); // 3) 点击高亮只算一跳邻居够用且快 function highlight(id) { const near new Set([id]); graph.edges.forEach(e { // 模拟跑起来后 source/target 会从字符串变成节点对象两种都要兼容 const s e.source.id ?? e.source; const t e.target.id ?? e.target; if (s id) near.add(t); if (t id) near.add(s); }); node.attr(opacity, d near.has(d.id) ? 1 : 0.12); link.attr(opacity, e { const s e.source.id ?? e.source, t e.target.id ?? e.target; return (s id || t id) ? 1 : 0.05; }); } node.on(click, (ev, d) { ev.stopPropagation(); highlight(d.id); }); svg.on(click, () { node.attr(opacity, 1); link.attr(opacity, 1); }); // 点空白复位 return { highlight }; }e.source.id ?? e.source这个写法看着别扭但省不掉。D3 的forceLink在第一次 tick 之后会把edges里的source/target从字符串 id 替换成节点对象的引用所以同一个字段在模拟前后是两种类型。判断邻居时如果只按字符串比高亮永远点不亮。scaleExtent的下限设 0.15 是给大型通路留的余量上限 8 已经够看清单个基因框里的别名了。真要进一步放大改的是viewBox而不是scaleExtent。4.3 导出SVG 与 PNG 的两套打法导出这块是我觉得这项目最实用的地方——汇报材料里的图一放大就糊是常事SVG 矢量图能救回来。// export.js —— 导出 SVG注意样式必须内联 export function exportSvg(svgEl, filename pathway.svg, w 1200, h 900) { const clone svgEl.cloneNode(true); clone.setAttribute(width, w); // 把 100% 换成像素否则部分查看器里尺寸为 0 clone.setAttribute(height, h); clone.setAttribute(xmlns, http://www.w3.org/2000/svg); // 外部 CSS 不会被一起序列化关键样式必须先写进 style 属性 clone.querySelectorAll(.node rect).forEach(r { r.setAttribute(style, stroke:#333;stroke-width:0.8); }); const blob new Blob([new XMLSerializer().serializeToString(clone)], { type: image/svgxml;charsetutf-8 }); const a document.createElement(a); a.href URL.createObjectURL(blob); a.download filename; a.click(); URL.revokeObjectURL(a.href); // 不释放会一直占着内存 }导出 PNG 走的是另一条路把上面的 SVG 序列化成 data URL塞进Image对象等onload之后画到canvas上再canvas.toBlob()。两个参数必须给对——canvas.width要按scale倍放大想 2 倍图就乘 2drawImage的宽高也要同步放大否则出来是一张放大的马赛克。还有一个坑Image加载 SVG 时如果里面引用了外部字体字会变成默认字体中文甚至可能整段缺失所以导出前把字体名写进style属性最保险。5. 避坑与排查五个把我卡住的现场5.1 打开是白屏Console 一行红字现象双击index.html页面框架出来了图画布空的Console 报Access to fetch ... has been blocked by CORS policy。原因file://协议下没有源任何fetch本地文件的操作都会被浏览器按跨域处理。这不是代码写错是协议限制。解决起静态服务python3 -m http.server 8080就行。实在不方便起服务就改用文件选择框喂数据走FileReader.readAsText读文本这条路不经过网络请求file://下也能跑。我把这个当成默认入口就是因为踩过一次现场演示时忘了起服务的坑。5.2 框都画出来了就是一根线都没有现象节点铺满画布边的图层是空的Console 里Error: missing: 37。原因edges里某条边的source或target在当前nodes里找不到对应 id。最常见的是reaction的substrate引用了一个当前 KGML 文件没定义的 entry其次是解析时把entry的 id 转成了数字而边上还是字符串判断永远为假。解决解析阶段就建 id 白名单过滤像 3.3 里那样。另外全流程统一用字符串 idgetAttribute拿到的本来就是字符串别手贱parseInt。5.3 图被压扁或者右下角被裁掉现象整张图挤在左上角一小块或者右下角的节点看不见。原因viewBox写死了。不同通路的坐标范围差别很大有的x只到 600有的能到 2400固定0 0 1200 900必然有一部分出界。解决跑一遍所有boxes求包围盒viewBox用minX-40 minY-40 (maxX-minX80) (maxY-minY80)。另外preserveAspectRatio保持xMidYMid meet容器比例和 viewBox 比例不一致时不会拉伸变形。5.4 导出的图打开是空白或者中文全变方块现象pathway.svg用浏览器打开一片白导出的 PNG 里节点文字变成豆腐块。原因一是样式写在外部 CSS 里XMLSerializer只序列化 DOM 结构不带样式表二是width/height还是100%部分查看器解析不出来三是Image加载 SVG 时拿不到页面里声明的字体。解决导出前把关键样式内联到元素的style属性上width/height换成具体像素xmlns手动补上字体名直接写进stylefont-family: ...。这三步做完基本就不会翻车了。5.5 几百个节点的通路拖起来像幻灯片现象小通路很流畅一换到大通路缩放卡顿拖节点掉帧。原因每个节点都单独挂了mousemove监听几百个元素就是几百个回调再加上每次zoom事件都重新selectAll算一遍主线程直接被占满。解决事件委托到svg一层用ev.target.closest(g.node)拿节点zoom回调里只改root的transform别碰子元素模拟的alphaMin调到 0.02 让它早点停下来preset模式下甚至可以跑 300 帧后直接sim.stop()。6. 二次开发把这张图改成你自己项目里的一个视图这个项目最舒服的地方是数据层和渲染层切得干净改造基本不用动骨架。常见的几个方向我列在下面按改动成本排改造目标动哪个文件大概工作量节点颜色映射到 logFC / p 值render.js的fill回调半小时节点大小映射到 degreenormalize.jsrender.js一小时加一个基因搜索框命中即高亮interactions.js两小时点击map类型节点下钻到子通路main.js加一层路由半天导出成 GraphML / JSON 给下游用export.js两小时颜色映射是最容易见效的一个。默认配色是按type走的把它换成按表达量走先给每个节点挂一个value字段从富集结果表里按基因名 join 进来再换一个连续色标。// render.js —— 把节点填充色从「按类型」改成「按 logFC」 const color d3.scaleDiverging() .domain([-2, 0, 2]) // 下限、中性点、上限超出范围会夹住 .interpolator(d3.interpolateRdBu); // 红-白-蓝蓝低红高 // 在解析之后、渲染之前把表达量 join 到节点上 const expr new Map([[7157, 2.31], [7158, -1.02]]); // 来自你的差异分析结果 graph.nodes.forEach(d { const hit d.genes.map(g g.split(:)[1]).find(g expr.has(g)); d.value hit ? expr.get(hit) : null; }); // 替换掉原来的 .attr(fill, d d.color) .attr(fill, d d.value null ? #E8E8E8 : color(d.value))domain的三个值建议按你数据的实际分位数定写死 ±2 只是习惯做法color对null必须单独兜底不然scaleDiverging(null)返回的是中性色图上会分不清「没测到」和「没变化」——这个区别在汇报时经常被追问别偷懒。想验证改得对不对我的习惯是拿一条已知结论的通路做回归测试比如细胞周期通路里那几个核心调控基因应该在图上互相连着改完配色之后点一下它高亮出来的邻居数量和改之前对得上就说明数据层没被改坏。血泪经验是每次动parse-kgml.js都先跑一遍samples/里的三条通路看一眼节点数和边数有没有突变再做别的。数据层的错不会立刻报异常它只会安静地让图少几条边等你拿着图去汇报的时候才发现不对。从那以后我每次改解析逻辑都强制走一遍这三条样本的节点数、边数、孤立节点数对比再往下写渲染。希望帮到你。本文还有配套的精品资源点击获取