Node / Edge / Port 三要素

`FlowGraph` 容器里只有两类实体:`Node` 与 `Edge`。`Port` 不是独立实体,而是通过 `NodeSchema` 声明、用 `PortId`(字符串)在边里引用的逻辑端点。本节拆解这三要素的类型设计。

Node:节点

pub type NodeKind = String;            // 匹配 IFlowNode::kind
pub type NodeData = serde_json::Value; // 自由 JSON

pub struct Node {
    pub id: NodeId,
    pub kind: NodeKind,
    pub data: NodeData,
    pub position: PointF,   // 逻辑坐标左上角
    pub size: SizeF,
}

| 字段 | 设计选择 | 原因 | |------|----------|------| | `kind` | `String` 而非枚举 | 支持任意自定义节点,无需改 core | | `data` | `serde_json::Value` | 字段结构由 `NodeSchema.fields` 声明约束 | | `position` | `PointF` | 布局后由 dagre 填充,逻辑坐标 | | `size` | `SizeF` | 渲染层 `sync_node_sizes` 同步真实尺寸 |

`bounds()` 与 `center()` 是两个常用派生方法:

impl Node {
    pub fn bounds(&self) -> RectF { RectF::new(self.position, self.size) }
    pub fn center(&self) -> PointF { self.bounds().center() }
}

几何算法(命中测试、端口分布、回环边边界)都基于 `bounds()`,因此 `size` 必须准确——这是 `sync_node_sizes` 在 `relayout` 中优先执行的原因。

Edge:边

pub struct Edge {
    pub id: EdgeId,
    pub source: NodeId,
    pub source_port: Option<PortId>,
    pub target: NodeId,
    pub target_port: Option<PortId>,
    pub edge_type: EdgeType,  // 路径算法
    pub kind: EdgeKind,       // 语义类型
}

边用 `NodeId` 引用两端,**端口是可选的**——`None` 表示用默认端口,框架自动推导方向与位置。

EdgeType:路径算法

`EdgeType` 决定边用什么几何算法绘制:

pub enum EdgeType {
    Bezier,       // 三次贝塞尔(默认)
    Straight,     // 直线
    Step,         // 正交直角
    SmoothStep,   // 正交圆角
}

| 类型 | 点数 | 适用场景 | |------|------|----------| | Bezier | 4(P0,ctrl1,ctrl2,P3) | 自由流动连线,默认 | | Straight | 2 | 简洁直连 | | Step | 折线 | 严格正交 | | SmoothStep | 圆角折线 | 正交且美观,结构化流程图 |

具体算法见 [边路径算法](../07-geometry-layout/edge-path-algorithms.md)。

EdgeKind:语义类型

`EdgeKind` 描述边的语义,与渲染算法正交:

pub enum EdgeKind {
    Normal,    // 普通连接(默认)
    LoopBack,  // 循环回环边
}

`LoopBack` 触发 U 形路由算法(见 `loop_back_path`),并让渲染层用虚线/不同样式区分。两者分离意味着:一条回环边可以同时是 `EdgeKind::LoopBack` + `EdgeType::SmoothStep`,语义与算法各自独立演化。

Port:端口

端口通过 `PortSpec` 在 `NodeSchema` 中声明,运行时用 `PortId`(字符串)引用:

pub type PortId = String;

pub enum PortDirection { In, Out }

pub enum PortSide {
    Top, Right, Bottom, Left,
    Auto,  // 默认,框架自动推导
}

| 概念 | 类型 | 说明 | |------|------|------| | `PortId` | `String` | 节点内唯一,如 `"if_0"`、`"loop_body"`、`"done"` | | `PortDirection` | 枚举 | 数据流入(In)或流出(Out) | | `PortSide` | 枚举 | 端口位于节点哪一侧,`Auto` 让框架推导 |

`PortSide` 提供两个工具方法:

impl PortSide {
    pub fn opposite(self) -> Self { /* Top<->Bottom, Left<->Right, Auto->Auto */ }
    pub fn is_horizontal(self) -> bool { matches!(self, Left | Right) }
}

`opposite()` 用于计算对端端口方向,`is_horizontal()` 决定贝塞尔控制点的偏移轴。

Auto 的浮动行为

`PortSide::Auto`(默认)让框架根据相连节点的相对位置动态选边:

节点A 在 B 的右下方
  → B 的出口选 Right(dx 主导)
  → A 的入口选 Left(对端在左)

节点A 在 B 的正上方
  → B 的出口选 Top
  → A 的入口选 Bottom

推导逻辑见 [端口端点计算](../07-geometry-layout/port-calc.md) 中的 `compute_side_from_position`:比较 `dx`/`dy` 绝对值,取大者所在轴的方向。大多数普通节点只需声明 `Auto`,结构化节点(Condition/Loop)才需固定 side。

端口可选性与结构化节点

普通节点(Start/Action/End)的边通常不指定端口:

let edge = Edge::new(source_id, target_id); // source_port/target_port 均为 None

结构化节点必须指定端口以区分语义出口:

// Condition 的第一个分支出口
edge.source_port = Some("if_0".to_string());

// Loop 的循环体入口边
edge.target_port = Some("loop_in".to_string());
edge.kind = EdgeKind::LoopBack;

// Loop 的循环体出口
edge.source_port = Some("loop_body".to_string());

// Loop 的完成出口
edge.source_port = Some("done".to_string());

这些端口 ID 是框架与渲染层的隐式契约,`loop_body_groups`、`loop_back_path` 等算法都依赖这些字符串字面量。

小结

`Node` 用 `String` + JSON 实现开放扩展;`Edge` 把路径算法(`EdgeType`)与语义类型(`EdgeKind`)正交分离;`Port` 不是独立实体而是字符串引用,`Auto` side 让框架自动推导方向。结构化节点通过约定端口 ID(`if_N`/`loop_body`/`loop_in`/`done`)表达控制流语义,这些字符串是贯穿布局与渲染的隐式契约。

下一节:[FlowDocument 互转](document-interop.md)