17 · 节点图¶
NodeGraph 是节点式工作流、数据流和可视化编辑器的画布基础。应用持有图结构和视口;组件只渲染节点、端口、连线,并把平移缩放请求回调给应用。
基础图¶
graph := ui.NodeGraphData{
Nodes: []ui.NodeGraphNode{
ui.NewNodeGraphNode("request", "HTTP Request", ui.NodeGraphPoint{X: 80, Y: 140}).
Outputs(ui.NewNodeGraphPort("body", "Body")),
ui.NewNodeGraphNode("parse", "Parse JSON", ui.NodeGraphPoint{X: 380, Y: 92}).
Inputs(ui.NewNodeGraphPort("input", "Input")),
},
Edges: []ui.NodeGraphEdge{
ui.NewNodeGraphEdge("request-body",
ui.NewNodeGraphEndpoint("request", "body"),
ui.NewNodeGraphEndpoint("parse", "input")),
},
}
节点和端口 ID 在同一图中必须稳定且唯一。边的起点必须引用输出端口,终点必须引用输入端口。位置和尺寸使用与 DPI 无关的图坐标;缩放为 1 时,一个图单位对应一个 dp。
新增节点由应用更新自己的 Model,再将新的切片传回 NodeGraph;组件不会在内部修改图数据:
nextID := len(model.Nodes) + 1
model.Nodes = append(model.Nodes,
ui.NewNodeGraphNode("node-"+strconv.Itoa(nextID), title,
ui.NodeGraphPoint{X: 80, Y: 96}).
Inputs(ui.NewNodeGraphPort("in", "In")).
Outputs(ui.NewNodeGraphPort("out", "Out")),
)
NodeGraph 使用 FlowUI 的工具表面层级:画布是带细边框和 8dp 圆角的 SurfaceSecondary,节点使用 FieldBackground(浅色主题为纯白)。默认显示可见的低强调细线网格,可调用 Grid(false) 关闭;悬停节点会出现轻微阴影,可拖拽节点显示抓取光标,可连接端口显示十字光标。
Grid(true) 控制网格显示,GridPattern 可在细线与点阵之间切换:
GridColor 与 GridOpacity 可只覆盖当前 NodeGraph 的颜色和透明度,不会修改应用主题:
ui.NodeGraph("workflow", graph).
GridPattern(ui.NodeGraphGridDots).
GridColor(color.NRGBA{R: 0x32, G: 0x78, B: 0xc8, A: 0xff}).
GridOpacity(.35)
画布、网格、节点、端口、边和选择态使用 Theme.Components.NodeGraph 的独立令牌。业务主题可按需覆盖,例如:
ui.CustomizeTheme(func(theme *ui.Theme) {
theme.Components.NodeGraph.CanvasBackground = color.NRGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff}
theme.Components.NodeGraph.CanvasBorder = color.NRGBA{R: 0xd9, G: 0xe1, B: 0xeb, A: 0xff}
theme.Components.NodeGraph.GridColor = color.NRGBA{R: 0xd9, G: 0xe1, B: 0xeb, A: 0xff}
theme.Components.NodeGraph.GridOpacity = .65
})
Culling(true) 默认开启,会跳过画布视口外节点和边的绘制操作,适合大图。命中测试、Minimap 和 FitView 仍使用完整图数据;只有需要排查绘制时才应关闭它。
画布事件与拖放¶
画布事件回调返回图坐标,因此业务可以在用户交互的位置创建节点。节点和边事件带有目标 ID;悬停回调只在进入或离开目标时触发:
ui.NodeGraph("workflow", graph).
OnCanvasDoubleClick(func(event ui.NodeGraphCanvasEvent) {
addNode(event.Position)
}).
OnNodeContextMenu(func(event ui.NodeGraphNodeEvent) {
openNodeMenu(event.NodeID, event.Position)
}).
OnEdgeHover(func(event ui.NodeGraphEdgeEvent) {
setInspectedEdge(event.EdgeID)
})
OnCanvasClick、OnNodeClick、OnEdgeClick 分别接收主键点击,均有对应的 DoubleClick 变体。OnCanvasContextMenu、OnNodeContextMenu、OnEdgeContextMenu 接收右键按下事件。
接收外部 Gio 拖放数据时,声明允许的 MIME 类型,并在 OnDrop 中处理复制后的数据:
ui.NodeGraph("workflow", graph).
DropTypes("application/x-flowui-node").
OnDrop(func(event ui.NodeGraphDropEvent) {
addNodeFromPayload(event.Position, event.Data)
})
拖拽源仍由业务持有,并通过 Gio transfer API 提供其中一个已声明的 MIME 类型。NodeGraph 会验证类型、将负载限制为 1 MiB,并回调图坐标落点。
受控视口¶
将视口保存在 Model 中,回调只发送消息,再用最新 Model 重建组件:
ui.NodeGraph("workflow", graph).
Height(520).
Viewport(model.Viewport).
OnViewportChange(func(next ui.NodeGraphViewport) {
send(ViewportChanged{Viewport: next})
})
空白区域可用主键拖拽平移,任意位置可用中键拖拽平移,滚轮以鼠标位置为中心缩放。ZoomRange 设置缩放上下限,Grid 和 GridSize 控制背景网格。未提供 Viewport 时,可用 DefaultViewport 指定该组件状态生命周期的初始视口。
ZoomOnScroll、PanOnScroll、PanOnPrimaryButton 和 PanOnMiddleButton 可分别控制这些输入方式。默认保留滚轮缩放和主键/中键平移;关闭 ZoomOnScroll 后可启用 PanOnScroll(true) 以使用滚轮平移。
FitView(true) 会在首次拥有节点和可用画布尺寸时,计算包含全部节点的视口,并优先于 DefaultViewport。受控视口会通过 OnViewportChange 发出该计算结果;应用回写后组件才会稳定在新位置。FitViewPadding 设置每个画布边缘保留的比例,默认是 0.1。需要再次适配时,将 FitView 从 false 切回 true:
ui.NodeGraph("workflow", graph).
Viewport(model.Viewport).
FitView(model.RequestFit).
FitViewPadding(.1).
OnViewportChange(func(next ui.NodeGraphViewport) {
send(ViewportChanged{Viewport: next})
})
可以按需显示 Minimap 和缩放控制器。两者都通过受控视口工作,放大、缩小和 Fit 操作会触发 OnViewportChange;点击 Minimap 会将视口居中到该位置,拖动当前视口框可连续平移:
ui.NodeGraph("workflow", graph).
Viewport(model.Viewport).
Minimap(true).
Controls(true).
OnViewportChange(func(next ui.NodeGraphViewport) {
send(ViewportChanged{Viewport: next})
})
画布浮层与查询¶
Panel 是固定在画布上的 FlowUI Widget,对应 XYFlow 的 Panel;它不随图平移或缩放。ViewportOverlay 使用图坐标定位内容,对应 ViewportPortal,会随着图平移和缩放。两者按 NodeGraph key 和子项 key 隔离状态:
ui.NodeGraph("workflow", graph).
Panel("actions", ui.NodeGraphPanelTopRight,
ui.Button("fit", "Fit")).
ViewportOverlay("annotation", ui.NodeGraphPoint{X: 240, Y: 128},
ui.Text("检查此节点"))
NodeGraphNodesBounds、NodeGraphIntersectingNodes、NodeGraphNodeConnections、NodeGraphPortConnections、NodeGraphNodeWorldPosition 和坐标转换函数提供不依赖组件状态的查询能力。它们返回应用数据副本,适合属性面板、自动布局和业务命令;NodeGraphFitViewport 可为全部或指定节点计算受控视口。
选择与拖拽¶
选择和位置变更通过 OnNodesChange 回到 Model。ApplyNodeGraphChanges 是直接可用的纯函数;也可以按业务约束自行应用变更:
ui.NodeGraph("workflow", graph).
SelectionMode(ui.NodeGraphSelectionMultiple).
SnapToGrid(true).
SnapGrid(16, 16).
OnNodesChange(func(changes []ui.NodeGraphNodeChange) {
send(NodesChanged{Nodes: ui.ApplyNodeGraphChanges(model.Nodes, changes)})
})
主键点击节点会单选,Shift、Ctrl 或 Command 点击可切换多选;拖拽已选节点会作为一组移动。NodeDragThreshold 控制开始拖拽所需的指针移动距离。NodesDraggable、NodesSelectable 提供全局默认值,单个节点可用 Draggable(false) 或 Selectable(false) 覆盖。SelectedKeys 可以独立受控选择集;未设置它时,节点的 Selected 字段是选择来源。
NodesResizable(true) 会为已选节点显示右下角缩放手柄;单个节点可以 Resizable(true) 或 Resizable(false) 覆盖。缩放通过 NodeChangeSize 返回,并可用 SizeRange 限制交互尺寸:
node := ui.NewNodeGraphNode("transform", "Transform", ui.NodeGraphPoint{X: 80, Y: 80}).
WithSize(180, 100).
Resizable(true).
SizeRange(120, 72, 480, 360)
默认仅显示右下角手柄以保持原行为。使用 ResizeHandles(ui.NodeGraphResizeHandleAll) 可启用四边与四角缩放;节点也可用 ResizeHandles 单独覆盖。左侧或上侧缩放会同时返回位置和尺寸请求,因此 ApplyNodeGraphChanges 仍可直接应用:
分组与父子节点¶
Parent 可把节点放入一个父节点。子节点的 Position 始终是相对父节点左上角的局部坐标,组件在布局、边锚点、框选、缩放适配和 Minimap 中自动解析为世界坐标。因此移动父节点只需要回写父节点的位置,子节点的应用数据不会被重复修改:
group := ui.NewNodeGraphNode("extract", "Extract", ui.NodeGraphPoint{X: 120, Y: 80}).
WithSize(440, 300)
child := ui.NewNodeGraphNode("parse", "Parse", ui.NodeGraphPoint{X: 32, Y: 64}).
Parent("extract").
ConstrainToParent(true)
父节点可以出现在 Nodes 的任意位置,NodeGraph 会先解析并绘制父节点,保证子节点显示在其上方。父引用必须存在且不能形成环;无效引用会立即报告错误。拖动同时选中的父节点和子节点时,只会回调父节点的位置变化。ConstrainToParent(true) 会把该子节点的交互拖动限制在直接父节点尺寸内,默认不限制,便于由业务规则管理越界布局。
编辑命令与历史¶
画布获得焦点后,Ctrl/Cmd+A 全选,Ctrl/Cmd+C、Ctrl/Cmd+X 和 Ctrl/Cmd+V 分别请求复制、剪切和粘贴,Ctrl/Cmd+Z、Ctrl/Cmd+Y 以及 Ctrl/Cmd+Shift+Z 请求撤销和重做。NodeGraph 不会自行改变应用图数据:OnCopy、OnCut、OnPaste、OnUndo 和 OnRedo 都只发送受控命令。
Fragment 会保留被选节点的后代及其内部连线;单独复制子节点时会自动转为当前世界坐标的根节点。PasteFragment 要求应用提供节点和边 ID 分配器,避免组件猜测业务身份。History 是可选的快照历史,应只在拖拽或缩放结束等完整操作后 Commit:
history := ui.NewNodeGraphHistory(model.Graph).Limit(100)
ui.NodeGraph("workflow", model.Graph).
OnPaste(func(fragment ui.NodeGraphFragment, offset ui.NodeGraphPoint) {
next := ui.PasteNodeGraphFragment(model.Graph, fragment,
func(node ui.NodeGraphNode) string { return newNodeID(node) },
func(edge ui.NodeGraphEdge) string { return newEdgeID(edge) },
offset,
)
send(GraphReplaced{Graph: next})
}).
OnUndo(func() {
if previous, ok := history.Undo(); ok {
send(GraphReplaced{Graph: previous})
}
})
画布获得键盘焦点后,方向键会在空间上相邻的节点之间移动焦点,Home/End 跳到首尾节点,Enter 或 Space 选择当前焦点节点。焦点节点不在可见区域时,视口会最小量平移以显示它。节点会输出 Gio Button 语义、标签和选中状态;自定义内容中的控件仍保留自己的焦点和语义。
边选择与删除¶
边具有与节点相同的受控选择模式。组件在可见线条周围保留较大的命中区域,主键点击可选择边,Shift、Ctrl 或 Command 可多选。SelectedEdgeKeys 独立控制边选择集;未设置时使用 Edge.Selected。EdgesSelectable 和 Edge.Selectable(false) 控制默认行为。
OnEdgesChange 返回选择或移除请求,ApplyNodeGraphEdgeChanges 可直接回写 Model:
ui.NodeGraph("workflow", graph).
OnEdgesChange(func(changes []ui.NodeGraphEdgeChange) {
send(EdgesChanged{Edges: ui.ApplyNodeGraphEdgeChanges(model.Edges, changes)})
})
画布获得焦点后,Backspace 或 Delete 会请求移除选中的元素。NodesDeletable、EdgesDeletable 是全局默认值,Node.Deletable(false) 与 Edge.Deletable(false) 可覆盖。移除节点时,所有关联边会一并收到移除请求。
框选与连线¶
开启 SelectionOnDrag(true) 后,主键在空白区域拖拽会框选节点,中键拖拽仍用于平移。SelectionBoxMode(ui.NodeGraphSelectionBoxPartial) 选择相交节点;NodeGraphSelectionBoxFull 只选择完全包含的节点。
从输出端口拖到输入端口会显示连线预览。通过 IsValidConnection 按业务规则拒绝候选连接;释放在有效目标时,OnConnect 收到连接请求:
ui.NodeGraph("workflow", graph).
IsValidConnection(func(connection ui.NodeGraphConnection) bool {
return connection.Source.NodeID != connection.Target.NodeID
}).
OnConnect(func(connection ui.NodeGraphConnection) {
// 在 Update 中创建 Edge 后,重新传入 graph.Edges。
})
NodesConnectable 是节点和端口的全局默认值;Node.Connectable(false) 和 Port.Connectable(false) 可分别禁用局部连接。
端口还可以表达无需业务回调的通用约束。Type 两端都非空时必须相同;MaxConnections(1) 可限制为单连接。已有边会计入限制,而重连时会排除正在重连的那条边:
source := ui.NewNodeGraphPort("payload", "Payload").Type("json")
target := ui.NewNodeGraphPort("input", "Input").Type("json").MaxConnections(1)
边重连¶
拖动已有边的端点可请求替换这一端连接的端口。重连和新建连线一样经过 IsValidConnection 校验,但组件不会直接修改应用数据,而是通过 OnReconnect 返回原边和完整的新连接。ReconnectNodeGraphEdge 是保留原边 ID 的纯函数回写助手:
ui.NodeGraph("workflow", graph).
OnReconnect(func(oldEdge ui.NodeGraphEdge, connection ui.NodeGraphConnection) {
send(EdgeReconnected{
Edges: ui.ReconnectNodeGraphEdge(oldEdge, connection, model.Edges),
})
})
默认两端都可重连。EdgesReconnectable(false) 可关闭没有局部设置的边;单条边可用 Reconnectable 限制范围:
sourceOnly := ui.NewNodeGraphEdge("source-only", source, target).
Reconnectable(ui.NodeGraphReconnectSource)
targetOnly := ui.NewNodeGraphEdge("target-only", source, target).
Reconnectable(ui.NodeGraphReconnectTarget)
NodeGraphReconnectBoth 允许两端,NodeGraphReconnectNone 禁用该边的重连。拖动 Source 端只能落在输出端口,拖动 Target 端只能落在输入端口,因此回调始终保持输出到输入的连接方向。
边样式与标签¶
边默认使用 Bezier 曲线,也可以切换为直线、折线或圆角折线。绘制和命中检测会使用同一种几何:
edge := ui.NewNodeGraphEdge("request-body", source, target).
Type(ui.NodeGraphEdgeSmoothStep).
Width(1.5).
Dashed(true).
Markers(ui.NodeGraphMarkerNone, ui.NodeGraphMarkerArrow).
Label("JSON")
Animated(true) 会让虚线沿边移动,并自动启用虚线。Width 使用 dp,零值使用主题默认线宽;Color、Selected 和 Selectable 仍可独立控制。
LabelContent 可用任意 FlowUI Widget 替换静态标签,并按图和边 ID 隔离内部状态,适合放置按钮、开关或其他可交互控件:
edge := ui.NewNodeGraphEdge("request-body", source, target).
LabelContent(ui.Button("edge-action", "Inspect"))
自定义节点内容与 Handle¶
节点可以用 Content 提供内部 Widget。内容会按节点 ID 建立独立的状态作用域;没有显式尺寸时,Widget 的测量结果会参与节点的自动尺寸计算,WithSize 提供的宽高优先:
content := ui.WidgetFunc(func(ctx *ui.Context, gtx layout.Context) layout.Dimensions {
return ui.Text("运行中").Layout(ctx, gtx)
})
node := ui.NewNodeGraphNode("worker", "Worker", ui.NodeGraphPoint{X: 120, Y: 80}).
Content(content).
Inputs(ui.NewNodeGraphPort("request", "Request").Position(ui.NodeGraphHandleTop)).
Outputs(ui.NewNodeGraphPort("result", "Result").Position(ui.NodeGraphHandleBottom))
端口默认输入在左侧、输出在右侧。Port.Position 可以将任意输入或输出放到 NodeGraphHandleLeft、NodeGraphHandleRight、NodeGraphHandleTop 或 NodeGraphHandleBottom;边锚点、命中测试和重连会使用相同的解析位置。自定义内容只替换节点标题和内置正文,端口仍由 NodeGraph 统一绘制和交互。
应用不应依赖组件内的图数据副本;图变更始终通过受控回调写回 Model。
完整示例:examples/node_graph。