Azgaar's Fantasy Map Generator v1.86.7 层次树(Hierarchy Tree)交互重构:多起源编辑、拖拽排序与模块化架构实战解析
Azgaar's Fantasy Map Generator v1.86.7 层次树(Hierarchy Tree)交互重构:多起源编辑、拖拽排序与模块化架构实战解析
本文基于 v1.86.7 更新说明 并结合仓库源码编写,聚焦该版本对文化与宗教层次树的 UX 重构——包括缩放拖拽、起源编辑、排序策略、动画过渡,以及将层次树抽离为通用动态加载模块的架构调整。读者读完后将掌握层次树交互的完整操作方式、
origins数据模型与 D3 底层实现原理,并能按同样的模式把树视图复用到其他地图元素上。
一、版本背景:为什么 v1.86.7 要重做层次树交互
在 Fantasy Map Generator(下称 FMG)的设定中,文化与宗教不再只有单一"父级",每个节点可以拥有多个起源(origins)——其中第一个起源是主起源(primary origin),其余为次要起源(secondary origins)。这种数据模型在 religions-generator.ts 中体现得很直接:
- 索引为
0的元素没有起源(origins: null),表示"无宗教"; - Folk 宗教只从其母文化派生(
origins: [0]); - 基于文化的宗教以民间信仰为唯一起源(
origins: [folkReligion.i]); - 测试用例中还可以看到多起源场景,例如
origins: <a href="https://link.gitcode.com/i/7cddeff7d84394355bcf1a5997822b97" target="_blank">1, 2](见 [religions-generator.test.ts)。
v1.86.7 更新说明(docs/updates/v1.86.7/v1.86.7 - Hierarchy Tree UX.md)开头即承认:上一版的层次树"能用但很难用",在大型层次树(例如拥有大量派系的文化树、宗教树)面前尤其痛苦。因此作者专门花了一周时间,目标是让层次树"真正可用且令人愉悦"。由此引出了本次更新的核心变更清单:
- 拖拽与缩放行为(需要时缩放缩小以查看整棵树);
- 点击节点打开起源编辑菜单;
- 编辑态下点击某个起源即可移除该链接;
- 编辑态下的起源选择菜单:主起源用单选按钮(radio),次要起源用复选框(checkbox);
- 对节点排序,使共享更多次要链接的节点彼此靠近;
- 精致的树更新动画;
- 技术层面:将层次树迁移到独立的通用动态加载模块。
二、交互层:六大 UX 变更逐个拆解
2.1 拖拽与缩放:先看全貌,再精确定位
大型树的核心痛点是"装不下"。为此层次树容器绑定了 D3 zoom 行为,缩放范围被限定为 [0.2, 1.5],即最小可缩小到原尺寸的 20%(一览全树)、最大放大到 150%(查看细节):
// src/controllers/hierarchy-tree.ts
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.2, 1.5])
.on("zoom", handleZoom);
const handleZoom = (event: D3ZoomEvent<SVGSVGElement, unknown>) =>
viewbox.attr("transform", event.transform.toString());
拖拽与缩放都作用于 #hierarchyTree > svg 下的 g#hierarchyTree_viewbox 分组,通过 transform 属性实现整体平移。树窗口的初始尺寸会根据树本身的规模自适应:treeWidth = root.leaves().length * 50、treeHeight = root.height * 50,再经 minmax(treeWidth, 300, innerWidth * 0.75) 夹取到合理范围(hierarchy-tree.ts),保证大树的窗口不超过屏幕宽高的 75%,留出缩放空间。
2.2 点击节点:进入起源编辑状态
任意节点(根节点除外)被点击后即进入编辑态(selectElement,hierarchy-tree.ts):
- 节点被
#c13119(品牌红)描边高亮; - 详情区显示节点名称与缩写编辑框(
maxlength='3',限 3 字符,空值与非 3 字符以内会被拒绝并弹出错误提示); - 展示当前所有起源按钮,点击任意按钮即可移除对应起源链接。
hover 节点时页面还会提示 Drag to other node to add parent, click to edit(拖到其他节点上可添加父级,点击可编辑),同时通过 onNodeEnter/onNodeLeave 回调把对应的文化/宗教在地图上高亮,形成"树上选点、地图联动"的交互闭环。
2.3 编辑态:点起源即移除
已选节点的起源以按钮形式呈现,每个按钮带 data-tip 提示(区分 Primary origin 与 Secondary origin,并提示点击可移除),按钮尾部有 CSS ::after 伪元素生成的 "✕" 符号(hierarchy-tree.ts):
<button data-id="${origin}" class="hierarchyTree_selectedButton hierarchyTree_selectedOrigin"
data-tip="${tipText}">${code}</button>
点击事件处理逻辑会从 origins 数组中过滤掉被点中的起源;若过滤后为空,则回退为 [0](即挂回顶层),随后立即触发 updateTree() 重排动画。
2.4 起源选择菜单:radio 定主、checkbox 定次
点击详情区 "Edit" 按钮会打开 "Select origins" 对话框(hierarchy-tree.ts)。菜单中每个可选元素包含:
- 一个
type="radio" name="primary"单选按钮——决定主起源; - 一个复选框——决定该元素是否作为次要起源;
- 颜色块 +
code: name标签,方便按颜色辨识(元素颜色在文化/宗教编辑器中被赋予语义)。
同时菜单顶部提供一个特殊的 "Top level"(顶层)选项(对应 i === 0),允许把节点直接挂为无主起源的顶层节点。确认(Select)后,代码读取选中的 radio 值与勾选的 checkbox 值,组装成 dataElement.origins = [primary, ...secondary] 并重新渲染——这正是多起源模型在 UI 上的完整表达。
2.5 排序:让共享次要链接的节点彼此靠近
为了减少次要链接(虚线)的长距离交叉,更新引入了 getSortIndex 排序函数(hierarchy-tree.ts):
const getSortIndex = (node: any): number => {
const descendants = node.descendants();
const secondaryOrigins = descendants.flatMap(({ data }: any) => data.origins.slice(1));
if (secondaryOrigins.length === 0) return node.data.i;
return mean(secondaryOrigins) ?? 0;
};
其含义是:对一个子树,收集其全部后代节点的次要起源(origins.slice(1)),取平均值作为排序键;没有次要起源的子树则退化为按自身索引排序。这样共享相似次要起源的节点会被 D3 tree 布局的 root.sort() 排到相邻位置,虚线链接更短、更少交叉。
2.6 动画:分层过渡的"精致树更新"
updateTree()(hierarchy-tree.ts)实现了两阶段动画:
- 链接先更新(50ms):新增链接淡入、删除链接淡出、既有链接在旧布局位置刷新路径;
- 节点再移动(1000ms,延迟 50ms):先用
mapCoords把新旧布局中相同i的节点坐标对齐,再统一过渡到新坐标,避免"整树跳变"。
const linksUpdateDuration = 50;
const moveDuration = 1000;
正是"先动线、后动点"的节奏让结构变化(增加/删除起源)看起来像树在自然生长,而非瞬移。所有路径均为平滑贝塞尔曲线(M...C... 四次曲线控制点由 getLinkPath 生成,hierarchy-tree.ts)。
三、数据模型与容错:origins 数组如何保证树始终可渲染
HierarchyElement 的核心字段是 origins: (number | null)<a href="https://link.gitcode.com/i/fa5d81eaabdba4e0fa47baeb8351f1eb" target="_blank">]——第一个元素为主起源(可为 null),其余为次要起源([hierarchy-tree.ts)。打开树之前会先经过 cleanupOrigins 清洗(hierarchy-tree.ts):
if (d.i === 0) d.origins = [null]; // 根元素:无主起源
else if (!d.origins.length) d.origins = [0]; // 空起源 → 挂到顶层
else if (!existingElements.find(el => d.origins[0] === el.i)) d.origins = [0]; // 主起源已失效 → 重置为顶层
清洗完成后用 D3 stratify() 把扁平数组构造成树:id = i,parentId = origins[0]。若数据本身有环或悬挂引用导致构造失败,会弹出 Hierarchy data issue 错误提示并回退到上一次成功的树(oldRoot),保证 UI 不崩溃。
四、架构升级:独立的通用动态加载模块
原文档特别强调的技术变更——"move hierarchy tree to its own generic dynamically loaded module"——在源码中有两处直接印证:
- 模块独立:完整实现收敛于单个文件 src/controllers/hierarchy-tree.ts,对外只暴露
HierarchyTree = { open }一个入口; - 动态加载:在控制器注册表 src/controllers/index.ts 中以懒加载方式注册:
HierarchyTree: () => import("@/controllers/hierarchy-tree").then(m => m.HierarchyTree),
这意味着只有真正打开树视图时,该模块(连同其 D3 依赖)才会被拉取执行,不拖累主包体积。
open() 接口接收一组回调(hierarchy-tree.ts),把"树怎么画"(getShape)、"节点描述"(getDescription)与"地图联动"(onNodeEnter/onNodeLeave)全部参数化——这正是"generic"(通用)的含义:同一棵树,不同数据源。
五、实战复用:从文化与宗教扩展到其他元素
v1.86.7 中层次树仍只服务于文化与宗教,但模块化设计使得新增一个树视图几乎零成本。对比两个现有接入点即可看出复用模式:
文化侧 showHierarchy:
Controllers.HierarchyTree.open({
type: "cultures",
data: pack.cultures as any,
onNodeEnter: cultureHighlightOn,
onNodeLeave: cultureHighlightOff,
getDescription, // 生成 "名称 + 类型 + 人口/Extinct" 描述
getShape // 按文化类型映射节点形状
});
文化节点的形状映射为:Generic→圆形、River→菱形、Lake→六边形、Naval→方形、Highland→内凹、Nomadic→八边形、Hunting→五边形。
宗教侧 showHierarchy 完全同构,形状映射为:Folk→圆形、Organized→方形、Cult→六边形、Heresy→菱形。
形状本身由 shapesMap 以 SVG path 形式预置(圆形、方形、六边形、菱形、内凹、八边形、五边形,未识别类型回退小圆点,hierarchy-tree.ts)。只要目标元素具备 { i, name, code, color, origins } 结构并补齐 getShape/getDescription,即可复用整套交互——这也解释了原文档中"it is very easy to add tree view for other elements"的技术底气。
六、进阶玩法:拖拽连线构建起源
除了菜单式编辑,v1.86.7 还保留了并完善了拖拽连父级能力(dragToReorigin,hierarchy-tree.ts):
- 拖拽节点时绘制一条虚线(
#hierarchyTree_dragLine,带终点箭头); - 落到目标节点上时,会做三重校验:不能拖给自己、不能拖给已有的起源、不能拖给自己的后代(避免成环);
- 校验通过后把目标
ipush 进origins;若节点原本的主起源是顶层0,则先清空再追加,确保新链接成为主起源。
虚线在拖拽线样式上使用 stroke-dasharray: 5 与长达 80s 的 dash 位移动画,营造"流动的箭头"视觉效果。
七、小结
v1.86.7 的层次树更新可以概括为三条主线:
| 维度 | v1.86.7 之前的痛点 | v1.86.7 的解法 |
|---|---|---|
| 交互 | 大树上难以定位、难以编辑 | 缩放(0.2×–1.5×)+ 拖拽平移 + 点击编辑 |
| 多起源编辑 | 结构不直观、操作繁琐 | 主起源 radio + 次要起源 checkbox + 点击即移除 |
| 架构 | 树视图与业务代码耦合 | 独立通用模块 + 动态加载(index.ts) |
从 更新说明 到 hierarchy-tree.ts 的实现,可以清楚看到作者对"大树可用性"的系统性思考:用缩放解决空间、用排序解决连线交叉、用动画解决认知跳跃、用模块化解决扩展成本。对于想要为其他元素(例如州、贸易路线)添加层级视图的开发者而言,照搬文化与宗教两处接入点的 open() 调用模式即可快速落地。