从 X6 parent 到领域字段:泳道模型如何避免几何语义泄漏

把一个节点拖进泳道,是一次普通的 embedding 交互。系统要回答「这个要素属于哪个角色、哪个阶段」,一个 X6 parent 远远不够。

视觉包含是图引擎的事;泳道、分栏和业务归属则属于领域模型。它们会在同一次拖动里发生,却不该用同一份数据来表达。

一个 parent 不够回答的问题

设想一个横向或纵向泳道被切成多个分栏。节点进入这个泳道后,图引擎可以建立父子关系:

1
2
Lane
└── Task

真正落到表单、校验和导出时,系统还得知道:

1
Task 属于 Lane A 的第 2 栏

单个 parentId 带不出「第几栏」的语义。一旦让每个消费者重新读取泳道尺寸、解析 SVG markup,再根据节点位置推算分栏,几何逻辑就会散落到表单和校验、导出与后端接口里。

节点嵌入时应一次性推导出稳定的领域字段,例如:

1
2
3
4
type TaskData = {
parentVerticalLane?: string;
parentHorizontalLane?: string;
};

此时,视觉位置只是推导输入;真正被保存和消费的是业务归属。

分栏判定:把矩形切成区间

以纵向泳道为例。设父泳道左边界为 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
markup: [
{
tagName: 'rect',
selector: 'vertical-main-rect-1',
groupSelector: 'vertical-main-rect',
},
{
tagName: 'rect',
selector: 'vertical-name-rect-1',
groupSelector: 'vertical-name-rect',
},
{
tagName: 'text',
selector: 'vertical-name-text-1',
groupSelector: 'vertical-name-text',
},
];

新增纵向分栏时,代码会按已有 selector 的最大编号生成 vertical-name-rect-${max},再通过 refWidth 和 refX 重设每栏的等宽位置。横向泳道生成 horizontal-name-rect-${max},对应使用 refHeight 和 refY。这些 selector 让后续逻辑能够根据节点位置判定分栏:

1
2
3
4
5
6
7
8
9
10
11
12
for (let i = 0; i < count; i++) {
const left = parentX + (parentWidth / count) * i;
const right = parentX + (parentWidth / count) * (i + 1);
const inColumn =
(nodeX >= left && nodeX <= right && nodeX + nodeWidth / 2 <= right) ||
(nodeX <= left && nodeX + nodeWidth / 2 > left);

if (inColumn) {
setLane(`${laneId}-${columnIds[i]}`);
break;
}
}

边界位置可以用一组坐标看清楚。若 parentX = 100、parentWidth = 300,且 count = 3,三个区间依次为 [100, 200]、[200, 300] 和 [300, 400]。节点的 nodeX = 190、nodeWidth = 40 时,半宽位置是 210:它不满足第 1 栏的右边界条件,却满足第 2 栏的起点条件,因此会归入第 2 栏。

1
2
3
第 1 栏                 第 2 栏
100 ├──────────────┤ 200 ├──────────────┤ 300
[ nodeX=190 ······ 中心=210 ]

横向泳道只是把判定轴从 x/width 切到 y/height。算法复杂度仍是 O(k);分栏数量通常不多,拖拽时几乎感觉不到这次遍历。

resolveLaneMembership:把判定规则收敛为接口

现有实现把判定逻辑直接写在 node:embedded 回调里。resolveLaneMembership 将这套规则收拢为一个接口,它不是 X6 已经提供的 API。接口从节点和泳道的几何信息中解析归属,不修改节点,也不碰撤销栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
type LaneOrientation = 'vertical' | 'horizontal';

type Rect = {
x: number;
y: number;
width: number;
height: number;
};

type LaneColumn = {
/** SVG markup 中的 selector,例如 vertical-name-rect-2 */
selector: string;
/** selector 最后一段解析出的业务分栏编号,例如 2 */
columnId: number;
};

type LaneInput = {
id: string;
/** 直接使用 X6 节点的 shape,例如 vertical-lane */
shape: string;
bbox: Rect;
columns: LaneColumn[];
};

type LaneMembership = {
orientation: LaneOrientation;
laneId: string;
columnId: number;
/** 节点属性名 */
field: 'parentVerticalLane' | 'parentHorizontalLane';
/** 兼容当前持久化格式的值 */
value: string;
};

type LaneMembershipResult =
| { status: 'matched'; membership: LaneMembership }
| {
status: 'unmatched';
reason: 'not-a-lane' | 'no-columns' | 'outside-columns' | 'invalid-column';
};

function resolveLaneMembership(
nodeBBox: Rect,
lane: LaneInput,
): LaneMembershipResult {
const orientation =
lane.shape === 'vertical-lane'
? 'vertical'
: lane.shape === 'horizontal-lane'
? 'horizontal'
: undefined;

if (!orientation) {
return { status: 'unmatched', reason: 'not-a-lane' };
}

if (!lane.columns.length) {
return { status: 'unmatched', reason: 'no-columns' };
}

if (lane.columns.some(({ columnId }) => !Number.isInteger(columnId))) {
return { status: 'unmatched', reason: 'invalid-column' };
}

const parentStart = orientation === 'vertical' ? lane.bbox.x : lane.bbox.y;
const parentSize =
orientation === 'vertical' ? lane.bbox.width : lane.bbox.height;
const position = orientation === 'vertical' ? nodeBBox.x : nodeBBox.y;
const size = orientation === 'vertical' ? nodeBBox.width : nodeBBox.height;

for (let index = 0; index < lane.columns.length; index++) {
const start = parentStart + (parentSize / lane.columns.length) * index;
const end = parentStart + (parentSize / lane.columns.length) * (index + 1);
const inColumn =
(position >= start && position <= end && position + size / 2 <= end) ||
(position <= start && position + size / 2 > start);

if (inColumn) {
const columnId = lane.columns[index].columnId;
const field =
orientation === 'vertical'
? 'parentVerticalLane'
: 'parentHorizontalLane';

return {
status: 'matched',
membership: {
orientation,
laneId: lane.id,
columnId,
field,
value: `${lane.id}-${columnId}`,
},
};
}
}

return { status: 'unmatched', reason: 'outside-columns' };
}

调用方从 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
2
3
const inColumn =
(position >= start && position <= end && position + size / 2 <= end) ||
(position <= start && position + size / 2 > start);

第二个条件保留了源码里的边界处理:节点左(上)边界还在分栏起点之外,但半个节点已经越过起点,就归入该栏。当前源码按节点左(上)边界与半尺寸判定,未按覆盖面积最大判定。以后要换规则,改这个接口即可,不必再往事件回调里塞分支。

字段映射与精确格式

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
2
3
4
5
6
7
8
9
10
function applyLaneMembership(node: Node, result: LaneMembershipResult) {
node.removePropByPath('parentVerticalLane', { ignoreHistory: true });
node.removePropByPath('parentHorizontalLane', { ignoreHistory: true });

if (result.status === 'matched') {
node.setProp(result.membership.field, result.membership.value, {
ignoreHistory: true,
});
}
}

解析层拿 unmatched 做日志、测试或调用方分支;节点数据层仍用属性不存在表示无归属,并保留 ignoreHistory: true,这样派生字段不会额外占掉一次撤销记录。

为什么不是纯粹的中心点判定

常见做法是只用节点中心点判断分栏。实际产品里,节点被拖到分栏边界时,中心点会让归属突然跳变,体验可能不稳定。

这里把节点左边界和半宽一起纳入判断:节点刚进入某栏、且半个宽度还没越过右边界时,就归到这一栏。这个取舍优先照顾拖拽时的视觉直觉,同时保留确定的结果。

如果业务要求更严格,可以改为下面几种规则:

  • 中心点所在栏;
  • 覆盖面积最大的栏;
  • 覆盖比例超过阈值的栏;
  • 靠近分栏边界时触发磁吸。

无论选哪条规则,都应把它收在可测试的领域策略里,不要继续散在各处拖拽回调中。

跨泳道限制是在保护模型

已有连线的节点不能直接迁到另一个泳道,以免原有关系失去语义。

如果节点跨泳道后,边仍然连着原来的节点,系统就需要重新定义:边是否跨角色?原泳道归属是否还有效?导出和校验如何解释?这些规则还没建模清楚时,先拒绝操作,也比静默留下不一致数据好。

1
允许拖拽 ≠ 模型一定可解释

能画出来、却解释不清的数据状态,最好不要让它落进模型。

派生字段不该污染撤销栈

拖动节点一次,通常会带来两类变化:

  1. 用户直接意图:节点位置和父子关系变化;
  2. 系统推导:重新计算并写入 parentVerticalLane 或 parentHorizontalLane。

若两类变化各自进入历史栈,用户按一次撤销时,可能先撤掉归属字段,再撤位置,短暂得到不一致状态。一次拖拽在用户看来就是一个动作:位置变化可以撤销,归属字段作为派生结果不该单独占一条历史记录。

1
node.setProp('parentVerticalLane', lane, { ignoreHistory: true });

事务边界也就落在这里:用户拖一次,撤销也只该退一步。

粘贴和脱离也要维护同一套语义

领域归属不能只在嵌入时写一次。还有两类操作必须一起处理:

  • 节点脱离父泳道:清除对应的泳道归属字段;
  • 复制粘贴节点:清除源节点的旧归属,让副本在新位置重新推导。

否则,把泳道 A 里的节点复制到画布空白处,副本仍可能带着「属于泳道 A」的旧数据。画布看着没问题,持久化模型已经错了。

可以怎样把它做得更稳

当前实现依赖 SVG selector 命名、泳道等分和事件回调。固定模型下可以直接使用;演进时,优先收敛这三处:

  1. 把横纵泳道抽成参数化函数,减少镜像分支;
  2. 用 resolveLaneMembership(node, lane) 统一归属规则;
  3. 用集成测试覆盖嵌入、脱离、复制、跨泳道和撤销。

图引擎给出几何和结构,业务系统仍要把它们落成领域语言。