1. 为什么管理后台的层级数据总在“挤成一团”如果你做过管理后台的菜单树、文档目录、组织架构图大概率遇到过这种场景数据层级有五六层节点上百个用普通列表渲染出来就是一条看不到头的长龙用户想找某个三级分类得靠浏览器搜索。这时候Collapsible Indented Tree可折叠缩进树就是最合适的方案——它用缩进表达层级用点击展开/收起控制信息密度既不像力导向图那样需要拖拽调位置也不像 treemap 那样牺牲可读性。d3 的d3.layout.tree()配合缩进布局能把这个需求做得非常干净。它的核心思路是把树的 x 坐标当作“行号”y 坐标当作“缩进深度”每个节点渲染成一个矩形条点击时切换children和_children两个字段再走一遍update()重算位置。整个过程只有一次数据遍历性能对几百个节点完全够用。这篇会从数据格式开始一步步给出可复制的 d3 配置、折叠交互代码以及怎么用 TaoToken 统一 Key 通道让 AI 工具帮你生成和调试这段树组件最后在浏览器里验证折叠展开和缩进渲染是否符合预期。适合正在做后台目录、文档导航、配置项分组的开发者直接抄改。2. TaoToken 前置把 Key 和 API 通道先理顺写这类可视化组件时我经常需要让 AI 帮忙生成测试数据、检查 d3 v3 和 v7 的 API 差异、或者解释某段diagonal投影为什么画出来是斜的。如果每个工具都单独配 Key切换起来很烦。TaoToken 的做法是提供一个统一的 API 入口你只需要在控制台生成一次 Key就能在多个 AI 工具里复用。具体操作路径是这样的先到 TaoToken 控制台 创建一个 API Key然后在 API Keys 管理页 复制出来。这个 Key 就是后面所有请求的凭证。如果你用的是 Claude Code 这类编码工具可以参考 ClaudeCodeAnthropic 接入文档 把 base URL 指到https://taotoken.net/api这样工具里的对话请求就会走统一通道。需要说明的是TaoToken 在这里的角色是“统一 Key/API 通道”不是替代你的编辑器或 d3 运行时。你依然是在本地写 HTML、跑浏览器只是把 AI 辅助生成和调试这一环的请求收敛到一个入口。对于长期做编码和 Agent 任务的场景可以看看 Coding Plan 的额度方式如果只是想快速验证某个模型对 d3 代码的理解直接去 模型对话 页面试就行。3. 可复制配置数据格式 d3 树布局 折叠交互3.1 数据格式flare.json 的层级结构d3 的树布局吃的是嵌套 JSON每个节点有name有子节点就带children数组叶子节点可以带size之类的值。下面是一个精简版示例结构和你后台返回的目录树可以一一对应{ name: flare, children: [ { name: analytics, children: [ { name: cluster, children: [ {name: AgglomerativeCluster, size: 3938}, {name: CommunityStructure, size: 3812}, {name: HierarchicalCluster, size: 6714}, {name: MergeEdge, size: 743} ] }, { name: graph, children: [ {name: BetweennessCentrality, size: 3534}, {name: LinkDistance, size: 5731}, {name: MaxFlowMinCut, size: 7840} ] } ] }, { name: animate, children: [ {name: Easing, size: 17010}, {name: FunctionSequence, size: 5842}, { name: interpolate, children: [ {name: ArrayInterpolator, size: 1983}, {name: ColorInterpolator, size: 2047} ] } ] } ] }关键点children是“当前展开的子节点”_children是“被折叠起来的子节点”。d3 的tree.nodes()只会遍历children所以折叠时把children挪到_children展开时再挪回来布局自然就变了。3.2 树布局与缩进计算d3 v3 的写法在旧项目里很常见核心是d3.layout.tree().size([0, 100])然后手动把每个节点的x改成行号乘以行高var margin {top: 30, right: 20, bottom: 30, left: 20}, width 960 - margin.left - margin.right, barHeight 20, barWidth width * 0.8; var i 0, duration 400, root; var tree d3.layout.tree() .size([0, 100]); var diagonal d3.svg.diagonal() .projection(function(d) { return [d.y, d.x]; }); var svg d3.select(body).append(svg) .attr(width, width margin.left margin.right) .append(g) .attr(transform, translate( margin.left , margin.top ));这里size([0, 100])的第二个参数 100 是缩进的最大深度范围实际 y 值由tree.nodes()根据层级算出来。然后update()里把n.x i * barHeight让每个节点占一行行高 20px节点多了就动态撑高 SVGfunction update(source) { var nodes tree.nodes(root); var height Math.max(500, nodes.length * barHeight margin.top margin.bottom); d3.select(svg).attr(height, height); d3.select(self.frameElement).style(height, height px); nodes.forEach(function(n, i) { n.x i * barHeight; }); var node svg.selectAll(g.node) .data(nodes, function(d) { return d.id || (d.id i); }); var nodeEnter node.enter().append(g) .attr(class, node) .attr(transform, function(d) { return translate( source.y0 , source.x0 ); }) .style(opacity, 1e-6); nodeEnter.append(rect) .attr(y, -barHeight / 2) .attr(height, barHeight) .attr(width, barWidth) .style(fill, color) .on(click, click); nodeEnter.append(text) .attr(dy, 3.5) .attr(dx, 5.5) .text(function(d) { return d.name; }); nodeEnter.transition() .duration(duration) .attr(transform, function(d) { return translate( d.y , d.x ); }) .style(opacity, 1); node.transition() .duration(duration) .attr(transform, function(d) { return translate( d.y , d.x ); }) .style(opacity, 1) .select(rect) .style(fill, color); node.exit().transition() .duration(duration) .attr(transform, function(d) { return translate( source.y , source.x ); }) .style(opacity, 1e-6) .remove(); var link svg.selectAll(path.link) .data(tree.links(nodes), function(d) { return d.target.id; }); link.enter().insert(path, g) .attr(class, link) .attr(d, function(d) { var o {x: source.x0, y: source.y0}; return diagonal({source: o, target: o}); }) .transition() .duration(duration) .attr(d, diagonal); link.transition() .duration(duration) .attr(d, diagonal); link.exit().transition() .duration(duration) .attr(d, function(d) { var o {x: source.x, y: source.y}; return diagonal({source: o, target: o}); }) .remove(); nodes.forEach(function(d) { d.x0 d.x; d.y0 d.y; }); }3.3 折叠交互与颜色状态点击事件只做一件事把children和_children互换然后重新update()。颜色函数用三个状态区分有折叠子节点深蓝、有展开子节点浅蓝、叶子节点橙色function click(d) { if (d.children) { d._children d.children; d.children null; } else { d.children d._children; d._children null; } update(d); } function color(d) { return d._children ? #3182bd : d.children ? #c6dbef : #fd8d3c; }配套的 CSS 也很短主要是让矩形可点、文字不挡点击.node rect { cursor: pointer; fill: #fff; fill-opacity: .5; stroke: #3182bd; stroke-width: 1.5px; } .node text { font: 10px sans-serif; pointer-events: none; } path.link { fill: none; stroke: #9ecae1; stroke-width: 1.5px; }如果你用的是 d3 v7d3.layout.tree()要换成d3.tree()d3.svg.diagonal()换成d3.linkHorizontal()其余折叠逻辑不变。这个差异可以让 AI 工具帮你批量替换把旧代码贴进去问“这段 d3 v3 的 tree 布局在 v7 里等价写法是什么”比手动查文档快。4. 验证请求浏览器里看折叠展开与缩进把index.html和flare.json放在同一目录用本地静态服务器起一下比如python -m http.server 8080打开浏览器控制台确认没有 404。正常渲染后你应该看到初始状态只显示根节点flare和它的一级子节点因为flare.x0 0; flare.y0 0;之后直接update(root)根节点的children还在所以一级是展开的。点击带浅蓝色矩形的节点它的子节点会从父节点位置“长出来”行号重新排布下面的节点整体下移。点击带深蓝色矩形的节点子节点收起矩形变回浅蓝行号回收。叶子节点是橙色点击无反应因为d.children和d._children都为空。缩进效果由d.y决定tree.nodes()算出来的y是层级深度映射到[0, 100]区间所以每深一层矩形就往右移一点。如果你觉得缩进太窄把tree.size([0, 100])的 100 调大比如 200层级间距会拉开。验证时重点看两个地方一是折叠后path.link有没有正确消失二是快速连续点击同一个节点时过渡动画会不会错位。如果错位通常是d.x0/d.y0没在update()末尾更新检查那两行赋值是否漏了。5. 本篇常见错排查5.1 报错tree.nodes is not a function这是 d3 v3 和 v4 的 API 差异。v3 里d3.layout.tree()返回的对象有.nodes()方法v4 之后改成d3.tree()用法是const root d3.hierarchy(data); tree(root);节点列表从root.descendants()拿。如果你抄的是旧代码但引入的是新版 d3就会报这个。解决办法要么锁 v3 的 CDN要么按新版重写。5.2 折叠后节点重叠或行号错乱多半是nodes.forEach(function(n, i) { n.x i * barHeight; })这行没执行或者执行顺序在tree.nodes()之前。必须先用tree.nodes(root)拿到扁平节点列表再遍历改x。另外barHeight太小比如小于文字高度也会视觉重叠20px 是安全值。5.3 点击没反应检查rect上的.on(click, click)是否绑在了nodeEnter上。如果你在node.transition()之后才绑已经存在的节点不会重新绑定。正确做法是在nodeEnter.append(rect)时直接绑。另外text的pointer-events: none必须加否则文字会挡住矩形的点击区域。5.4 缩进方向反了diagonal.projection里返回[d.y, d.x]表示 y 作横轴、x 作纵轴。如果你写成[d.x, d.y]树就变成从上往下长缩进变垂直。确认你的transform用的是translate(d.y, d.x)和 projection 保持一致。5.5 数据里children为空数组时被当成有子节点if (d.children)对空数组是 truthy会导致点击后_children []颜色判断也出错。稳妥写法是if (d.children d.children.length)或者在数据预处理时把空数组删掉。6. 把 AI 辅助接进你的 d3 工作流写到这里树组件的核心配置和排障点都齐了。回到工具链这一环当你需要让 AI 帮你生成一份符合后台目录结构的测试 JSON或者把 d3 v3 的diagonal迁移到 v7 的linkHorizontal统一走 TaoToken 的 Key 通道会省掉反复配环境的时间。控制台建 Key、API Keys 页复制、文档页查 base URL三步就能把请求收敛到https://taotoken.net/api。如果你只是偶尔问一两个 d3 问题用 模型对话 直接贴代码问就行如果是要长期在编码工具里做 Agent 任务Coding Plan 的额度方式更适合。接入细节在 接入文档 里Key 在 API Keys 页管理。树组件本身还是跑在你本地浏览器里AI 只是帮你更快地生成数据和排查 API 差异。
