从 X6 parent 到领域字段:泳道模型如何避免几何语义泄漏
把一个节点拖进泳道,是一次普通的 embedding 交互。系统要回答「这个要素属于哪个角色、哪个阶段」,一个 X6 parent 远远不够。
视觉包含是图引擎的事;泳道、分栏和业务归属则属于领域模型。它们会在同一次拖动里发生,却不该用同一份数据来表达。
一个 parent 不够回答的问题
设想一个横向或纵向泳道被切成多个分栏。节点进入这个泳道后,图引擎可以建立父子关系:
1 | Lane |
真正落到表单、校验和导出时,系统还得知道:
1 | Task 属于 Lane A 的第 2 栏 |
单个 parentId 带不出「第几栏」的语义。一旦让每个消费者重新读取泳道尺寸、解析 SVG markup,再根据节点位置推算分栏,几何逻辑就会散落到表单和校验、导出与后端接口里。
节点嵌入时应一次性推导出稳定的领域字段,例如:
1 | type TaskData = { |
此时,视觉位置只是推导输入;真正被保存和消费的是业务归属。
分栏判定:把矩形切成区间
以纵向泳道为例。设父泳道左边界为 parentX,宽度为 parentWidth,共有 k 个分栏。第 i 栏的水平区间为:
$$
[x_i, x_{i+1}] = [parentX + \frac{parentWidth}{k}i,\ parentX + \frac{parentWidth}{k}(i + 1)]
$$
节点进入泳道后,当前实现会从泳道 markup 找到各分栏的 selector,并把 selector 末尾的编号作为业务分栏编号。vertical-lane 初始 markup 中实际使用的相关片段如下:
1 | markup: [ |
新增纵向分栏时,代码会按已有 selector 的最大编号生成 vertical-name-rect-${max},再通过 refWidth 和 refX 重设每栏的等宽位置。横向泳道生成 horizontal-name-rect-${max},对应使用 refHeight 和 refY。这些 selector 让后续逻辑能够根据节点位置判定分栏:
1 | for (let i = 0; i < count; i++) { |
边界位置可以用一组坐标看清楚。若 parentX = 100、parentWidth = 300,且 count = 3,三个区间依次为 [100, 200]、[200, 300] 和 [300, 400]。节点的 nodeX = 190、nodeWidth = 40 时,半宽位置是 210:它不满足第 1 栏的右边界条件,却满足第 2 栏的起点条件,因此会归入第 2 栏。
1 | 第 1 栏 第 2 栏 |
横向泳道只是把判定轴从 x/width 切到 y/height。算法复杂度仍是 O(k);分栏数量通常不多,拖拽时几乎感觉不到这次遍历。
resolveLaneMembership:把判定规则收敛为接口
现有实现把判定逻辑直接写在 node:embedded 回调里。resolveLaneMembership 将这套规则收拢为一个接口,它不是 X6 已经提供的 API。接口从节点和泳道的几何信息中解析归属,不修改节点,也不碰撤销栈。
1 | type LaneOrientation = 'vertical' | 'horizontal'; |
调用方从 X6 节点取得 node.getBBox(),从候选父节点取得 currentParent.getBBox() 和 currentParent.shape,再用 currentParent.getMarkup() 过滤分栏 selector 并组装 columns。接口只接受 vertical-lane 和 horizontal-lane,其他 shape 返回 not-a-lane。垂直泳道使用 vertical-name-rect- 前缀,横向泳道使用 horizontal-name-rect- 前缀。selector vertical-name-rect-2 对应 { selector: 'vertical-name-rect-2', columnId: 2 }。
接口根据 shape 得出结果里的 orientation,并保留当前的判定语义:vertical 沿 x/width 等分,horizontal 沿 y/height 等分;按分栏顺序返回第一个命中的结果。对每个区间 [start, end],位置轴记为 position,节点尺寸记为 size,命中条件是:
1 | const inColumn = |
第二个条件保留了源码里的边界处理:节点左(上)边界还在分栏起点之外,但半个节点已经越过起点,就归入该栏。当前源码按节点左(上)边界与半尺寸判定,未按覆盖面积最大判定。以后要换规则,改这个接口即可,不必再往事件回调里塞分支。
字段映射与精确格式
matched 结果保留结构化的 laneId 和 columnId,在写回节点属性时才序列化为当前兼容格式:
结果 orientation |
SVG selector 示例 | 节点属性 field |
value 精确格式 |
示例 |
|---|---|---|---|---|
vertical |
vertical-name-rect-2 |
parentVerticalLane |
${laneId}-${columnId} |
lane-order-42-2 |
horizontal |
horizontal-name-rect-3 |
parentHorizontalLane |
${laneId}-${columnId} |
lane-role-7-3 |
lane-order-42-2 表示父泳道 ID 为 lane-order-42、分栏编号为 2。当前删除分栏的逻辑同样通过最后一个连字符后的编号识别分栏,所以 columnId 必须是 selector 最后一段可转换出的数字,不能把任意业务字符串直接塞进后缀。
无归属的表示与写回约定
unmatched 是解析接口明确给出的“没有归属”结果:候选父节点不是泳道,或泳道没有有效分栏;节点没有命中任何分栏,或 selector 解析不出数字分栏编号。不要用空字符串、0 或 -1 代替它,否则很容易和真实分栏混在一起。
节点数据层沿用现有模型:缺少对应属性即无归属。当结果为 unmatched、节点脱离父泳道,或粘贴副本尚未在新位置完成判定时,应删除两个可选字段,而不是写入 null:
1 | function applyLaneMembership(node: Node, result: LaneMembershipResult) { |
解析层拿 unmatched 做日志、测试或调用方分支;节点数据层仍用属性不存在表示无归属,并保留 ignoreHistory: true,这样派生字段不会额外占掉一次撤销记录。
为什么不是纯粹的中心点判定
常见做法是只用节点中心点判断分栏。实际产品里,节点被拖到分栏边界时,中心点会让归属突然跳变,体验可能不稳定。
这里把节点左边界和半宽一起纳入判断:节点刚进入某栏、且半个宽度还没越过右边界时,就归到这一栏。这个取舍优先照顾拖拽时的视觉直觉,同时保留确定的结果。
如果业务要求更严格,可以改为下面几种规则:
- 中心点所在栏;
- 覆盖面积最大的栏;
- 覆盖比例超过阈值的栏;
- 靠近分栏边界时触发磁吸。
无论选哪条规则,都应把它收在可测试的领域策略里,不要继续散在各处拖拽回调中。
跨泳道限制是在保护模型
已有连线的节点不能直接迁到另一个泳道,以免原有关系失去语义。
如果节点跨泳道后,边仍然连着原来的节点,系统就需要重新定义:边是否跨角色?原泳道归属是否还有效?导出和校验如何解释?这些规则还没建模清楚时,先拒绝操作,也比静默留下不一致数据好。
1 | 允许拖拽 ≠ 模型一定可解释 |
能画出来、却解释不清的数据状态,最好不要让它落进模型。
派生字段不该污染撤销栈
拖动节点一次,通常会带来两类变化:
- 用户直接意图:节点位置和父子关系变化;
- 系统推导:重新计算并写入
parentVerticalLane或parentHorizontalLane。
若两类变化各自进入历史栈,用户按一次撤销时,可能先撤掉归属字段,再撤位置,短暂得到不一致状态。一次拖拽在用户看来就是一个动作:位置变化可以撤销,归属字段作为派生结果不该单独占一条历史记录。
1 | node.setProp('parentVerticalLane', lane, { ignoreHistory: true }); |
事务边界也就落在这里:用户拖一次,撤销也只该退一步。
粘贴和脱离也要维护同一套语义
领域归属不能只在嵌入时写一次。还有两类操作必须一起处理:
- 节点脱离父泳道:清除对应的泳道归属字段;
- 复制粘贴节点:清除源节点的旧归属,让副本在新位置重新推导。
否则,把泳道 A 里的节点复制到画布空白处,副本仍可能带着「属于泳道 A」的旧数据。画布看着没问题,持久化模型已经错了。
可以怎样把它做得更稳
当前实现依赖 SVG selector 命名、泳道等分和事件回调。固定模型下可以直接使用;演进时,优先收敛这三处:
- 把横纵泳道抽成参数化函数,减少镜像分支;
- 用
resolveLaneMembership(node, lane)统一归属规则; - 用集成测试覆盖嵌入、脱离、复制、跨泳道和撤销。
图引擎给出几何和结构,业务系统仍要把它们落成领域语言。