跳转至

L3 · 数据驱动节点图

L3 的分水岭不是"有没有连线",而是节点位置由谁决定:L1/L2/L4 里用户摆哪是哪,L3 里位置是布局算法从数据里算出来的。JSON 可视化(jsoncrack)、DAG 流水线、血缘图谱都属于这档。demo 内置一个 60 行的迷你布局引擎(最长路径分层 + 重心排序),让你直观看到"数据进、图出"。

试这几下:

  • 左侧改 JSON 再点渲染 —— 加几个字段、嵌套几层,图和布局全自动重算
  • 换示例:流水线 DAG —— 多入边的非树结构,分层布局照样服帖
  • 悬停节点高亮它的连线;注意节点不可拖 —— 这是 L3 的立场:位置属于算法

生产选型

demo 里的迷你布局只为教学,生产直接用库:

渲染层 布局 什么时候选
@xyflow/react(React Flow) DOM 节点 + SVG 连线 自己接 dagre/ELK 节点是富 React 组件、要交互(生产首选,MIT,社区最大)
reaflow 纯 SVG ELK 内置开箱即用 只读展示派生图,节点是简单文本
react-zoomable-ui 容器 单独提供 pan/zoom 容器,常与 reaflow 组合

jsoncrack 的经典组合(版本号来自其仓库实测):

{
  "jsonc-parser": "3.3.1",       // 容错 JSON 解析(注释/尾逗号不炸)
  "reaflow": "5.4.1",             // SVG 节点图 + ELK 自动布局
  "react-zoomable-ui": "^0.11.0"  // 缩放容器
}

数据流:JSON → jsonc-parser AST → 自定义 parser → {nodes, edges} → reaflow <Canvas/> → 包进 <Space/>

React Flow 走法(节点要交互/表单时):

import { ReactFlow, Background, MiniMap, ReactFlowProvider } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from '@dagrejs/dagre';

// 1) 自定义节点 = 普通 React 组件,data 里塞什么都行
const nodeTypes = { task: TaskNode };

// 2) 布局:dagre 算 x/y 回填 nodes(大图放 Web Worker)
function layoutWith(nodes, edges) {
  const g = new dagre.graphlib.Graph().setGraph({ rankdir: 'LR' });
  nodes.forEach(n => g.setNode(n.id, { width: 200, height: 80 }));
  edges.forEach(e => g.setEdge(e.source, e.target));
  dagre.layout(g);
  return nodes.map(n => ({ ...n, position: g.node(n.id) }));
}

<ReactFlowProvider>
  <ReactFlow nodes={layouted} edges={edges} nodeTypes={nodeTypes} fitView>
    <Background variant="dots" />
    <MiniMap />
  </ReactFlow>
</ReactFlowProvider>

要点

  • 分层布局的骨架很小:① layer = 距根的最长路径(拓扑序推进)② 层内按父节点重心排序减少交叉 ③ 每层宽度取最宽节点。demo 里 60 行——理解了这个再看 ELK/dagre 的文档就不慌
  • 端口类型化是节点编辑器的好模式:每个节点声明输入/输出端口的 kind(text|image|video|…),连线要求两端 kind 一致——把图的合法性挪进类型系统
  • 运行态与文档态分离:执行进度/输出这类瞬时态不落盘,持久化时剥离,节点终态单独按 nodeId 存映射
  • 图的执行调度器写成纯逻辑模块(拓扑排序、入度归零入队、并发上限、失败下游级联 skip、含环拒跑),不依赖 React——可独立单测,React hook 只注入执行器和回调
  • React Flow 节点也支持用户拖拽自由摆放(很多生产工作流编辑器就这么用,配"新节点螺旋避让落位"即可),不要因为"要自由摆放"而放弃 L3——真正让你放弃 L3 的是"要白板工具链"

性能上限

  • reaflow 是 SVG,~5000 节点封顶;jsoncrack 的官方策略是超过阈值(NODE_LIMIT)直接拒渲画布
  • ELK/dagre 对超大图要 1–3 秒,必须放 Web Worker,否则冻 UI
  • 富组件 + 大量节点选 React Flow(DOM 节点 + 内置视口优化)

源码(折叠)

demo 源码:assets/demo-l3-nodegraph.html(自包含单页,含 60 行布局引擎)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L3 · 数据驱动节点图 — JSON 进,图出,位置算法算</title>
<style>
  * { box-sizing: border-box; margin: 0; }
  html, body { height: 100%; overflow: hidden; }
  body { font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; display: flex; }

  /* 左侧:数据面板 —— L3 的输入是数据,不是鼠标 */
  .panel {
    width: 250px; flex: 0 0 auto; background: #fff; border-right: 1px solid #e2e8f0;
    display: flex; flex-direction: column; padding: 12px; gap: 8px;
  }
  .panel h3 { font-size: 13px; color: #0f172a; }
  .panel textarea {
    flex: 1; font: 11.5px/1.5 ui-monospace, Menlo, Consolas, monospace;
    border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px; resize: none; color: #334155;
  }
  .panel button {
    font: inherit; font-size: 12px; padding: 6px 10px; cursor: pointer;
    border: 1px solid #cbd5e1; border-radius: 7px; background: #f8fafc; color: #334155;
  }
  .panel button.primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
  .panel .err { font-size: 11px; color: #dc2626; min-height: 14px; }
  .panel .meta { font-size: 11px; color: #94a3b8; }

  .viewport {
    position: relative; flex: 1; overflow: hidden;
    background: #f7f8fa; cursor: grab; touch-action: none;
  }
  .viewport.panning { cursor: grabbing; }
  .world { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }

  svg.wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
  svg.wires path { fill: none; stroke: #cbd5e1; stroke-width: 1.5; transition: stroke .12s; }
  svg.wires path.hot { stroke: #3b82f6; stroke-width: 2; }

  .gnode {
    position: absolute; padding: 6px 12px; border-radius: 8px; white-space: nowrap;
    font-size: 12px; border: 1px solid; user-select: none; cursor: default;
    background: #fff; box-shadow: 0 1px 4px rgba(15,23,42,.06);
  }
  .gnode .k { font-weight: 600; }
  .gnode .v { color: #64748b; margin-left: 6px; }
  .gnode.obj   { border-color: #93c5fd; }
  .gnode.arr   { border-color: #fcd34d; }
  .gnode.leaf  { border-color: #d1d5db; }
  .gnode.stage { border-color: #86efac; font-weight: 600; color: #14532d; }
  .gnode.hot { box-shadow: 0 0 0 2px rgba(59,130,246,.3); }

  .tip {
    position: absolute; right: 12px; top: 12px; z-index: 5;
    font-size: 11.5px; color: #94a3b8; background: rgba(255,255,255,.85);
    border: 1px solid #e2e8f0; border-radius: 8px; padding: 6px 10px; line-height: 1.7;
  }
</style>
</head>
<body>
<div class="panel">
  <h3>数据(编辑后点渲染)</h3>
  <textarea id="src" spellcheck="false"></textarea>
  <div class="err" id="err"></div>
  <button class="primary" id="render">渲染 JSON → 图</button>
  <button id="demoDag">换示例:流水线 DAG</button>
  <div class="meta" id="meta"></div>
</div>
<div class="viewport" id="viewport">
  <div class="world" id="world">
    <svg class="wires" id="wires" width="1" height="1"></svg>
  </div>
  <div class="tip">节点不可拖 —— 位置由布局算法计算<br/>悬停节点高亮其连线 · 滚轮缩放 · 拖空白平移</div>
</div>

<script>
/* ============ 输入 1:JSON → 树状图(jsoncrack 模式) ============ */
const SAMPLE_JSON = {
  name: "订单 #1024",
  status: "paid",
  amount: 299,
  customer: { name: "小王", vip: true },
  items: [
    { sku: "A-1", qty: 2 },
    { sku: "B-7", qty: 1 }
  ]
};

function jsonToGraph(value, label, nodes, edges, parent) {
  const id = 'n' + nodes.length;
  const kind = Array.isArray(value) ? 'arr' : (value && typeof value === 'object') ? 'obj' : 'leaf';
  const text = kind === 'leaf' ? { k: label, v: JSON.stringify(value) }
             : kind === 'arr'  ? { k: label, v: `[${value.length}]` }
             :                    { k: label, v: '' };
  nodes.push({ id, kind, ...text });
  if (parent) edges.push({ from: parent, to: id });
  if (kind === 'obj') for (const k of Object.keys(value)) jsonToGraph(value[k], k, nodes, edges, id);
  if (kind === 'arr') value.forEach((v, i) => jsonToGraph(v, String(i), nodes, edges, id));
  return { nodes, edges };
}

/* ============ 输入 2:非树 DAG(多入边,树布局搞不定的形状) ============ */
const SAMPLE_DAG = {
  nodes: [
    { id: 'src',  k: '拉取代码', kind: 'stage' },
    { id: 'lint', k: 'Lint',     kind: 'stage' },
    { id: 'unit', k: '单元测试', kind: 'stage' },
    { id: 'build',k: '构建产物', kind: 'stage' },
    { id: 'e2e',  k: 'E2E 测试', kind: 'stage' },
    { id: 'img',  k: '打镜像',   kind: 'stage' },
    { id: 'dep',  k: '部署预发', kind: 'stage' },
  ],
  edges: [
    { from: 'src', to: 'lint' }, { from: 'src', to: 'unit' }, { from: 'src', to: 'build' },
    { from: 'build', to: 'e2e' }, { from: 'unit', to: 'e2e' },
    { from: 'build', to: 'img' }, { from: 'lint', to: 'img' },
    { from: 'img', to: 'dep' }, { from: 'e2e', to: 'dep' },
  ],
};

/* ============ 布局:最长路径分层 + 重心排序(ELK/dagre 的迷你替身) ============ */
function layout(nodes, edges, sizeOf) {
  const GAP_X = 70, GAP_Y = 18;
  const adj = {}, indeg = {};
  nodes.forEach(n => { adj[n.id] = []; indeg[n.id] = 0; });
  edges.forEach(e => { adj[e.from].push(e.to); indeg[e.to]++; });

  // 1) 分层:layer = 距离根的最长路径(拓扑序推进)
  const layer = {}, q = [];
  nodes.forEach(n => { if (indeg[n.id] === 0) { layer[n.id] = 0; q.push(n.id); } });
  const left = { ...indeg };
  while (q.length) {
    const id = q.shift();
    for (const t of adj[id]) {
      layer[t] = Math.max(layer[t] ?? 0, layer[id] + 1);
      if (--left[t] === 0) q.push(t);
    }
  }
  nodes.forEach(n => { if (layer[n.id] === undefined) layer[n.id] = 0; }); // 含环兜底

  // 2) 按层分组,层内按父节点平均 y(重心)排序,减少交叉
  const cols = [];
  nodes.forEach(n => { (cols[layer[n.id]] ||= []).push(n); });
  const pos = {};
  const parents = {};
  edges.forEach(e => (parents[e.to] ||= []).push(e.from));
  cols.forEach((col, li) => {
    if (li > 0) col.sort((a, b) => bary(a) - bary(b));
    let y = 0;
    for (const n of col) { pos[n.id] = { y }; y += sizeOf(n).h + GAP_Y; }
    const totalH = y - GAP_Y;
    col.forEach(n => pos[n.id].y -= totalH / 2);           // 每列垂直居中
  });
  function bary(n) {
    const ps = parents[n.id] || [];
    if (!ps.length) return 0;
    return ps.reduce((s, p) => s + (pos[p]?.y ?? 0), 0) / ps.length;
  }

  // 3) x:每层宽度取本层最宽节点
  let x = 0;
  cols.forEach(col => {
    const w = Math.max(...col.map(n => sizeOf(n).w));
    col.forEach(n => { pos[n.id].x = x; });
    x += w + GAP_X;
  });
  return pos;
}

/* ============ 渲染 ============ */
const world = document.getElementById('world');
const wiresSvg = document.getElementById('wires');
const viewport = document.getElementById('viewport');
let graph = { nodes: [], edges: [] };
let els = {};

function renderGraph(g) {
  graph = g; els = {};
  world.querySelectorAll('.gnode').forEach(el => el.remove());
  // 先建 DOM 测量真实宽高,再布局
  for (const n of g.nodes) {
    const el = document.createElement('div');
    el.className = 'gnode ' + n.kind;
    el.innerHTML = `<span class="k">${esc(n.k)}</span>` + (n.v ? `<span class="v">${esc(n.v)}</span>` : '');
    el.dataset.id = n.id;
    world.appendChild(el);
    els[n.id] = el;
  }
  const pos = layout(g.nodes, g.edges, n => ({ w: els[n.id].offsetWidth, h: els[n.id].offsetHeight }));
  for (const n of g.nodes) {
    els[n.id].style.left = pos[n.id].x + 'px';
    els[n.id].style.top  = (pos[n.id].y + 300) + 'px';
  }
  drawWires();
  document.getElementById('meta').textContent =
    `${g.nodes.length} 节点 / ${g.edges.length} 边 · 布局 = 最长路径分层 + 重心排序`;
  fitAll();
}
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

function drawWires() {
  wiresSvg.innerHTML = graph.edges.map((e, i) => {
    const a = els[e.from], b = els[e.to];
    const x1 = a.offsetLeft + a.offsetWidth, y1 = a.offsetTop + a.offsetHeight / 2;
    const x2 = b.offsetLeft,                 y2 = b.offsetTop + b.offsetHeight / 2;
    const mx = (x1 + x2) / 2;
    return `<path data-i="${i}" d="M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}"/>`;
  }).join('');
}

/* 悬停高亮相邻边 */
world.addEventListener('mouseover', (e) => {
  const el = e.target.closest('.gnode');
  if (!el) return;
  el.classList.add('hot');
  graph.edges.forEach((ed, i) => {
    if (ed.from === el.dataset.id || ed.to === el.dataset.id)
      wiresSvg.querySelector(`[data-i="${i}"]`)?.classList.add('hot');
  });
});
world.addEventListener('mouseout', (e) => {
  if (!e.target.closest('.gnode')) return;
  world.querySelectorAll('.hot').forEach(el => el.classList.remove('hot'));
  wiresSvg.querySelectorAll('.hot').forEach(el => el.classList.remove('hot'));
});

/* ============ pan / zoom(与 L1 相同的三个数) ============ */
let scale = 1, panX = 0, panY = 0;
function applyTransform() {
  world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
viewport.addEventListener('wheel', (e) => {
  e.preventDefault();
  const rect = viewport.getBoundingClientRect();
  const ax = e.clientX - rect.left, ay = e.clientY - rect.top;
  const next = Math.min(3, Math.max(0.2, scale * Math.exp(e.deltaY * -0.002)));
  const ratio = 1 - next / scale;
  panX += (ax - panX) * ratio; panY += (ay - panY) * ratio; scale = next;
  applyTransform();
}, { passive: false });
let panning = false, lx = 0, ly = 0;
viewport.addEventListener('pointerdown', (e) => {
  panning = true; lx = e.clientX; ly = e.clientY;
  viewport.setPointerCapture(e.pointerId); viewport.classList.add('panning');
});
viewport.addEventListener('pointermove', (e) => {
  if (!panning) return;
  panX += e.clientX - lx; panY += e.clientY - ly; lx = e.clientX; ly = e.clientY;
  applyTransform();
});
const stopPan = () => { panning = false; viewport.classList.remove('panning'); };
viewport.addEventListener('pointerup', stopPan);
viewport.addEventListener('pointercancel', stopPan);

function fitAll() {
  const nodes = Object.values(els);
  if (!nodes.length) return;
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const el of nodes) {
    minX = Math.min(minX, el.offsetLeft); minY = Math.min(minY, el.offsetTop);
    maxX = Math.max(maxX, el.offsetLeft + el.offsetWidth);
    maxY = Math.max(maxY, el.offsetTop + el.offsetHeight);
  }
  const pad = 50;
  scale = Math.min(1.6, Math.min(
    viewport.clientWidth  / (maxX - minX + pad * 2),
    viewport.clientHeight / (maxY - minY + pad * 2)));
  panX = (viewport.clientWidth  - (maxX - minX) * scale) / 2 - minX * scale;
  panY = (viewport.clientHeight - (maxY - minY) * scale) / 2 - minY * scale;
  applyTransform();
}

/* ============ 面板 ============ */
const src = document.getElementById('src');
const err = document.getElementById('err');
src.value = JSON.stringify(SAMPLE_JSON, null, 2);

document.getElementById('render').onclick = () => {
  try {
    err.textContent = '';
    const obj = JSON.parse(src.value);   // 生产用 jsonc-parser:容错注释/尾逗号
    renderGraph(jsonToGraph(obj, 'root', [], [], null));
  } catch (e) { err.textContent = 'JSON 解析失败:' + e.message; }
};
document.getElementById('demoDag').onclick = () => {
  err.textContent = '';
  src.value = '// 当前展示内置 DAG 示例(多入边非树结构)\n// 左侧编辑区仅对「渲染 JSON → 图」生效';
  renderGraph({ nodes: SAMPLE_DAG.nodes.map(n => ({ ...n, v: '' })), edges: SAMPLE_DAG.edges });
};

document.getElementById('render').click();
</script>
</body>
</html>