Azgaar's Fantasy Map Generator v1.86.7 层次树(Hierarchy Tree)交互重构:多起源编辑、拖拽排序与模块化架构实战解析

原创2026-10-03 17:43:511,825 阅读
文章标签:前端图形学

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):

  1. 节点被 #c13119(品牌红)描边高亮;
  2. 详情区显示节点名称与缩写编辑框(maxlength='3',限 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)实现了两阶段动画:

  1. 链接先更新(50ms):新增链接淡入、删除链接淡出、既有链接在旧布局位置刷新路径;
  2. 节点再移动(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"——在源码中有两处直接印证:

  1. 模块独立:完整实现收敛于单个文件 src/controllers/hierarchy-tree.ts,对外只暴露 HierarchyTree = { open } 一个入口;
  2. 动态加载:在控制器注册表 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,带终点箭头);
  • 落到目标节点上时,会做三重校验:不能拖给自己、不能拖给已有的起源、不能拖给自己的后代(避免成环);
  • 校验通过后把目标 i push 进 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() 调用模式即可快速落地。

登录后查看全文
Fantasy-Map-Generator