动态端口与 ports_for_node

何时需要动态端口

大多数节点端口静态固定——schema 声明什么就是什么。但当节点的出口数量**随数据变化**时,就需要覆写 `ports_for_node`。典型场景:

| 场景 | 例子 | |------|------| | 多条件分支 | Condition 的 if_0, if_1, ... 随 conditions 数组 | | 多输出通道 | 自定义「Split」节点按配置输出 N 路 | | 状态机出口 | 每个状态一个出口,状态数动态 | | Switch 分支 | 类似 Condition 但无 else 兜底 |

ports_for_node vs schema().ports

| 方法 | 用途 | 调用时机 | |------|------|----------| | `schema().ports` | 静态声明,用于面板/校验/默认值 | 注册时确定 | | `ports_for_node(node)` | 动态查询,用于实际连线/布局推导 | 渲染/布局时确定 |

`schema().ports` 是「这个 kind 的节点最多有哪些端口」,`ports_for_node` 是「这个具体节点实例当前有哪些端口」。Condition 的 schema 声明 `in/else/if_0/if_1`(默认 2 条件),但 `ports_for_node` 根据 `node.data["conditions"]` 返回实际端口列表。

实现:以 Switch 节点为例

下面实现一个 Switch 节点——按 `cases` 数组动态生成出口端口,无 else 兜底。

schema 声明(静态默认)

impl SwitchNode {
    pub fn new() -> Self {
        Self {
            schema: NodeSchema::new("switch", "Switch")
                .with_size(SizeF::new(220.0, TITLE_H + ITEM_H * 3.0))
                // schema 声明默认 2 case 端口(静态,用于面板默认值)
                .with_port(PortSpec::new("in", PortDirection::In, PortSide::Auto))
                .with_port(PortSpec::new("case_0", PortDirection::Out, PortSide::Auto))
                .with_port(PortSpec::new("case_1", PortDirection::Out, PortSide::Auto))
                .with_field(
                    FieldSpec::new("label", "Label", FieldType::Text)
                        .with_default(serde_json::json!("")),
                )
                .with_field(
                    FieldSpec::new("cases", "Cases",
                        FieldType::List(ListSpec::new(vec![
                            FieldSpec::new("id", "ID", FieldType::Text)...,
                            FieldSpec::new("label", "Match Expression", FieldType::CodeEditor)...,
                        ]))
                        .with_default(serde_json::json!([
                            { "id": "case_0", "label": "" },
                            { "id": "case_1", "label": "" }
                        ]))),
                ),
        }
    }
}

ports_for_node 覆写(动态生成)

fn ports_for_node(&self, node: &Node) -> Vec<PortSpec> {
    let cases = get_cases(node);  // 解析 node.data["cases"]
    let mut ports = vec![
        PortSpec::new("in", PortDirection::In, PortSide::Auto),
    ];
    for (id, _) in &cases {
        ports.push(PortSpec::new(id.as_str(), PortDirection::Out, PortSide::Auto));
    }
    ports
}

fn get_cases(node: &Node) -> Vec<(String, String)> {
    node.data.get("cases")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter().filter_map(|item| {
                let id = item.get("id")?.as_str()?.to_string();
                let label = item.get("label")?.as_str()?.to_string();
                Some((id, label))
            }).collect()
        })
        .unwrap_or_else(|| vec![
            ("case_0".to_string(), String::new()),
            ("case_1".to_string(), String::new()),
        ])
}

数据变化时端口同步

用户在面板增删 case 后,触发 `NodeAction::SetData("cases", new_array)`,编辑器更新 `node.data` 后调用 `update_node_size_if_changed`。如果 `content_size` 也覆写了(高度随 case 数变化),尺寸变化触发 `relayout`,新的端口列表通过 `ports_for_node` 被布局引擎读取——**端口拓扑自动同步**。

端口 ID 的命名约定

端口 ID 是字符串,但建议遵循约定:

| 命名 | 含义 | 例子 | |------|------|------| | `in` / `out` | 单一主端口 | Action/Variable | | `if_<i>` | 索引分支 | Condition | | `case_<i>` | 索引匹配 | Switch | | `<语义名>` | 语义端口 | Loop 的 `done`/`loop_body`/`loop_in` |

索引命名(`if_0`、`case_1`)便于 `port_position` 用 `pid[3..].parse::<usize>()` 解析索引,按行/列定位。语义命名(`done`)便于在 `port_position` 用 `match port_id.as_str()` 直接分支。

port_position 必须配合覆写

覆写 `ports_for_node` 后,**必须同时覆写 `port_position`**——否则新端口的位置由框架默认算法计算(节点边缘中点),多个动态端口会重叠在边缘中心。

fn port_position(&self, node: &Node, port_id: &PortId, layout: LayoutDirection) -> Option<PointF> {
    let cases = get_cases(node);
    let n_cases = cases.len();
    let right = node.position.x + node.size.w;
    let left = node.position.x;
    let mid_x = node.position.x + node.size.w * 0.5;
    let top = node.position.y;

    match port_id.as_str() {
        "in" => match layout {
            LayoutDirection::Horizontal => Some(PointF::new(left, node.position.y + node.size.h * 0.5)),
            LayoutDirection::Vertical => Some(PointF::new(mid_x, top)),
        },
        pid if pid.starts_with("case_") => {
            let idx: usize = pid[5..].parse().ok()?;
            match layout {
                LayoutDirection::Horizontal => {
                    let y = node.position.y + TITLE_H + ITEM_H * (idx as f32 + 0.5);
                    Some(PointF::new(right, y))
                }
                LayoutDirection::Vertical => {
                    let t = (idx as f32 + 0.5) / n_cases as f32;
                    Some(PointF::new(left + node.size.w * t, node.position.y + node.size.h))
                }
            }
        }
        _ => None,
    }
}

纵向布局下 `(idx + 0.5) / n_cases` 让端口沿底部均匀分布——与 Condition 的 if_i 算法一致。

content_size 也必须配合

动态端口通常意味着动态高度:

fn content_size(&self, node: &Node) -> SizeF {
    let n_cases = get_cases(node).len();
    let h = TITLE_H + ITEM_H * n_cases as f32;
    SizeF::new(node.size.w, h)
}

**三件套联动**:`ports_for_node`(端口数)→ `content_size`(高度)→ `port_position`(每端口 Y 坐标)。三者必须基于同一份 `node.data` 计算,保持一致。

端口方向的一致性

`PortSpec::new(id, direction, side)` 中:

- `direction`:In/Out,决定端口是输入还是输出 - `side`:Top/Right/Bottom/Left/Auto,决定端口在节点哪一边

动态端口通常 `side = Auto`——让 `port_position` 完全控制位置,框架的 Auto 推导只用于无 `port_position` 覆写时的回退。Condition 的所有端口都是 Auto,实际位置由 `port_position` 精确指定。

容错与回退

`get_cases` 必须有回退默认值:

.unwrap_or_else(|| vec![
    ("case_0".to_string(), String::new()),
    ("case_1".to_string(), String::new()),
])

`node.data` 在节点刚创建、JSON 损坏、字段缺失等情况下可能不完整。回退默认值保证节点始终可渲染——这是内置节点的通用范式。

验证端口与边的一致性

动态端口增删后,已存在的边可能引用失效端口:

// 用户删除 case_1,但仍有边 source_port = "case_1"
// 渲染时 port_position 返回 None,边端点用节点边缘中心

框架对失效端口有容错(`port_position` 返回 `None` 时用默认算法),但建议在面板编辑 cases 时同步清理失效边——这属于编辑器职责,不在 IFlowNode 范围内。

小结

动态端口通过覆写 `ports_for_node` 实现——根据 `node.data` 实时生成端口列表。典型场景是多分支节点(Condition/Switch)。覆写时必须三件套联动:`ports_for_node`(端口数)+ `content_size`(高度)+ `port_position`(每端口坐标),三者基于同一份数据计算保持一致。端口 ID 命名遵循约定(`if_<i>`/`case_<i>`/语义名),`get_cases` 类函数必须有回退默认值保证节点始终可渲染。

下一节:[port_position 与 content_size](port-position-size.md)