用于网络可视化的 .NET 画布
用于交互式可视化网络数据的 .NET 语言画布控件。
- 下载演示示例
- 从 github 下载最新的源代码
- 下载 canvas.zip
通过 nuget 安装 sciBASIC# 包以获取二进制 dll 文件
PM> Install-Package sciBASIC -Pre
然后添加对这些 dll 模块的引用
- Microsoft.VisualBasic.Data.Csv.dll
- Microsoft.VisualBasic.MIME.Markup.dll
- Microsoft.VisualBasic.Imaging.dll
- Microsoft.VisualBasic.Architecture.Framework_v3.0_22.0.76.201__8da45dcd8060cc9a.dll
- Microsoft.VisualBasic.Data.visualize.Network.dll
- Microsoft.VisualBasic.Data.visualize.Network.Canvas.dll
引言和背景
在我最近有关 GCModeller 的工作中,我想开发一个用于生物网络数据可视化的模块。其中一个解决方案是使用 d3js 进行数据可视化,另一个方案是使用 Cytoscape 软件。
Cytoscape 软件在网络可视化方面做得最好,因为它提供了大量的样式映射以及数据导入导出功能。在我最近的工作中,Cytoscape 软件被频繁用于可视化生物网络数据。但问题是,Cytoscape 软件只能生成网络的静态图像,并且只能以 Web 应用格式导出交互式输出。此外,Cytoscape 软件也无法用 VB.NET 进行编程。由于我想构建一个内部的交互式模块用于生物网络可视化,所以我必须尝试其他方法来完成这项工作。在 HTML 编程中,还有一个重要的工具可以可视化网络数据:d3js。d3js 是我在数据可视化方面最喜欢的工具之一。
这是我最喜欢的来自 whichlight 先生的项目:一个使用 d3js 可视化 reddit 讨论网络的作品。
d3js 是我在这项工作中的首选。这是我用 d3js 与 VB.NET 混合编程编写的力导向图引擎(完整的源代码和示例可以从这里下载)。
function d3Network(jsonFile, width, height) {
var color = d3.scale.category20();
var force = d3.layout.force()
.charge(-120)
.linkDistance(30)
.size([width, height]);
var svg = d3.select("body").append("svg")
.attr("fill", "#DBF3FF")
.attr("width", width)
.attr("height", height)
.attr("class", "chart");
d3.json(jsonFile, function (error, graph) {
if (error) throw error;
force
.nodes(graph.nodes)
.links(graph.links)
.start();
var link = svg.selectAll(".link")
.data(graph.links)
.enter().append("line")
.attr("class", "link")
.style("stroke-width", 0.5);
var node = svg.selectAll(".node")
.data(graph.nodes)
.enter().append("circle")
.attr("class", "node")
.attr("r", function (d) {
return Math.sqrt(d.size)+1;
})
.style("fill", function (d) {
return color(d.group);
})
.style("opacity", 0.8)
.call(force.drag);
node.append("title")
.attr("class", "tooltip")
.style("font-size", 16)
.html(function (d) {
return "name:\t" + d.name + "\ntype:\t" + d.type + "\nlinks:\t" + d.size;
});
force.on("tick", function () {
link.attr("x1", function (d) { return d.source.x; })
.attr("y1", function (d) { return d.source.y; })
.attr("x2", function (d) { return d.target.x; })
.attr("y2", function (d) { return d.target.y; });
node.attr("cx", function (d) { return d.x; })
.attr("cy", function (d) { return d.y; });
});
});
}
VB.NET 语言与 JavaScript 混合编程,在本地客户端作为服务器端运行。通过使用 WinForm 的 Web 浏览器控件,我们可以显示网络可视化数据。但由于 .NET 的 Webbrowser 控件基于 IE 浏览器,对 d3js 的支持不好,所以我不得不将 .NET Webbrowser 控件更换为开源的 Firefox 浏览器或 Google Chrome。在项目中使用 Firefox 确实可行,但问题在于我们需要将应用程序与 Chrome 内核(约 100MB)和 Firefox 内核(约 50MB)一起部署。这对我的客户来说既不方便也不友好。
在 GitHub 上搜索之后,我找到了一个用 .NET 语言自身的方式来解决这个交互式网络可视化工作的方法。
这个网络画布库主要基于 Woong Gyu La 先生的工作。
概述
这个画布库主要由4个部分组成
代码详解
力导向布局引擎
力导向网络布局引擎位于类型 Microsoft.VisualBasic.DataVisualization.Network.Layouts.ForceDirected2D 中。关于这个布局提供程序的介绍可以在 Woong Gyu La 先生的文章中找到:“EpForceDirectedGraph.cs- A 2D/3D force directed graph algorithm in C#”。
用于 WinForm 的画布控件
画布控件为布局引擎提供了用户界面和渲染任务线程。只需使用 Canvas.Graph 属性即可设置网络数据。
Public Property Graph As NetworkGraph
Get
If net Is Nothing Then
Call __invokeSet(New NetworkGraph)
End If
Return net
End Get
Set(value As NetworkGraph)
Call __invokeSet(value)
End Set
End Property
Private Sub __invokeSet(g As NetworkGraph)
net = g
fdgPhysics = New ForceDirected2D(net, FdgArgs.Stiffness, FdgArgs.Repulsion, FdgArgs.Damping)
fdgRenderer = New Renderer(
Function() paper,
Function() New Rectangle(New Point, Size),
fdgPhysics)
inputs = New InputDevice(Me)
fdgRenderer.Asynchronous = False
End Sub
这些组件用于布局引擎和图像渲染。
''' <summary>
''' The network data model for the visualization
''' </summary>
Dim net As NetworkGraph
''' <summary>
''' Layout provider engine
''' </summary>
Protected Friend fdgPhysics As ForceDirected2D
''' <summary>
''' The graphics updates thread.
''' </summary>
Protected Friend timer As New UpdateThread(30, AddressOf __invokePaint)
''' <summary>
''' The graphics rendering provider
''' </summary>
Protected Friend fdgRenderer As Renderer
''' <summary>
''' GDI+ interface for the canvas control.
''' </summary>
Dim paper As Graphics
所有的渲染工作都从 timer 对象开始。
''' <summary>
''' The graphics updates thread.
''' </summary>
Protected Friend timer As New UpdateThread(30, AddressOf __invokePaint)
Private Sub __invokePaint()
Call Me.Invoke(Sub() Invalidate())
End Sub
通过在计时器线程中周期性地通知 Windows 消息说控件需要更新,然后会触发 Paint 事件,接着我们就可以在渲染引擎中使用 GDI+ 图形接口进行网络可视化。如您所见,这个事件函数为渲染引擎提供了所需的 GDI+ 接口,并调用更新网络布局和控件的图形图像。
Private Sub Canvas_Paint(sender As Object, e As PaintEventArgs) Handles Me.Paint
paper = e.Graphics
paper.CompositingQuality = Drawing2D.CompositingQuality.HighQuality
paper.SmoothingMode = Drawing2D.SmoothingMode.HighQuality
Call fdgRenderer.Draw(0.05F)
End Sub
这是用 JavaScript 实现的相同功能。
force.on("tick", function () {
link.attr("x1", function (d) { return d.source.x; })
.attr("y1", function (d) { return d.source.y; })
.attr("x2", function (d) { return d.target.x; })
.attr("y2", function (d) { return d.target.y; });
node.attr("cx", function (d) { return d.x; })
.attr("cy", function (d) { return d.y; });
});
用于处理用户鼠标事件的 InputDevice
在 d3js 可视化中,通过将鼠标事件绑定到每个节点对象,图形节点可以被鼠标拖动。
var node = svg.selectAll(".node")
.data(graph.nodes)
.enter().append("circle")
.attr("class", "node")
.attr("r", function (d) {
return Math.sqrt(d.size)+1;
})
.style("fill", function (d) {
return color(d.group);
})
.style("opacity", 0.8)
.call(force.drag);
而这个输入设备类实现了与 JavaScript 中 .call(force.drag) 代码相同的功能。
首先,当我们的鼠标在控件上移动时,会触发一个鼠标移动事件。要拖动控件上的一个节点,会触发一个鼠标按下事件。当拖动完成时,会触发鼠标抬起事件。这些事件都有一个 MouseEventArgs 参数,我们可以利用这个参数值来进行节点检测和节点拖动。
从一开始,我们开始拖动一个节点,然后我们可以设置拖动事件开始,并使用 __getNode(Point) 函数来确定哪个节点被点击了。
Private Sub Canvas_MouseDown(sender As Object, e As MouseEventArgs) Handles Canvas.MouseDown
drag = True
dragNode = __getNode(e.Location)
End Sub
__getNode(Point) 函数遍历网络中的节点,并使用 System.Drawing.Rectangle.Contains(System.Drawing.Point) As Boolean 方法来检测鼠标位置位于哪个节点的区域内。由于节点位置是数据模型中的位置,不能直接在用户客户端上使用,因此该函数中使用了一个 Canvas.fdgRenderer.GraphToScreen 函数,用于在数据模型和用户界面之间进行投影。
Private Function __getNode(p As Point) As Node
For Each node As Node In Canvas.Graph.nodes
Dim r As Single = node.Data.radius
Dim npt As Point =
Canvas.fdgRenderer.GraphToScreen(
Canvas.fdgPhysics.GetPoint(node).position)
Dim pt As New Point(npt.X - r / 2, npt.Y - r / 2)
Dim rect As New Rectangle(pt, New Size(r, r))
If rect.Contains(p) Then
Return node
End If
Next
Return Nothing
End Function
然后,通过开始移动鼠标,我们就可以移动被拖动节点的位置。
Private Sub Canvas_MouseMove(sender As Object, e As MouseEventArgs) Handles Canvas.MouseMove
If Not drag Then
Return
End If
If dragNode IsNot Nothing Then
Dim vec As FDGVector2 =
Canvas.fdgRenderer.ScreenToGraph(
New Point(e.Location.X, e.Location.Y))
dragNode.Pinned = True
Canvas.fdgPhysics.GetPoint(dragNode).position = vec
Else
dragNode = __getNode(e.Location)
End If
End Sub
如果处于拖动模式,那么我们将被拖动节点的位置设置为鼠标当前位置,这样就实现了拖动事件。当我们完成拖动事件时,会触发一个鼠标抬起事件,我们在这里释放被拖动的节点。
Private Sub Canvas_MouseUp(sender As Object, e As MouseEventArgs) Handles Canvas.MouseUp
drag = False
If dragNode IsNot Nothing Then
dragNode.Pinned = False
dragNode = Nothing
End If
End Sub
用于网络数据图形渲染的 Renderer
如上所述,内部数据模型的世界和用户界面的世界是不同的,所以我们需要两个函数在内部数据模型世界和外部用户世界之间进行投影。
''' <summary>
''' Projects the data model to our screen for display.
''' </summary>
''' <param name="iPos"></param>
''' <returns></returns>
Public Function GraphToScreen(iPos As FDGVector2) As Point
Dim rect = __regionProvider()
Dim x = CInt(Math.Truncate(iPos.x + (CSng(rect.Right - rect.Left) / 2.0F)))
Dim y = CInt(Math.Truncate(iPos.y + (CSng(rect.Bottom - rect.Top) / 2.0F)))
Return New Point(x, y)
End Function
''' <summary>
''' Projects the client graphics data to the data model.
''' </summary>
''' <param name="iScreenPos"></param>
''' <returns></returns>
Public Function ScreenToGraph(iScreenPos As Point) As FDGVector2
Dim retVec As New FDGVector2()
Dim rect = __regionProvider()
retVec.x = CSng(iScreenPos.X) - (CSng(rect.Right - rect.Left) / 2.0F)
retVec.y = CSng(iScreenPos.Y) - (CSng(rect.Bottom - rect.Top) / 2.0F)
Return retVec
End Function
这两个投影函数需要客户端控件上的一个图形区域,其数据源由画布控件中的一个 lambda 表达式提供。
Imports System.Collections.Generic
Imports System.Linq
Imports System.Text
Imports Microsoft.VisualBasic.DataVisualization.Network.Graph
Imports Microsoft.VisualBasic.DataVisualization.Network.Layouts
Imports Microsoft.VisualBasic.DataVisualization.Network.Layouts.Interfaces
Public Class Renderer
Inherits AbstractRenderer
''' <summary>
''' Gets the graphics source
''' </summary>
Dim __graphicsProvider As Func(Of Graphics)
''' <summary>
''' gets the graphics region for the projections: <see cref="GraphToScreen"/> and <see cref="ScreenToGraph"/>
''' </summary>
Dim __regionProvider As Func(Of Rectangle)
构造函数中的 lambda 表达式为数据可视化渲染器提供了所需的客户端数据。
Private Sub __invokeSet(g As NetworkGraph)
net = g
fdgPhysics = New ForceDirected2D(net, FdgArgs.Stiffness, FdgArgs.Repulsion, FdgArgs.Damping)
fdgRenderer = New Renderer(
Function() paper,
Function() New Rectangle(New Point, Size),
fdgPhysics)
inputs = New InputDevice(Me)
fdgRenderer.Asynchronous = False
End Sub
然后通过使用 GraphToScreen 函数,我们知道节点应该在哪里绘制;通过使用 ScreenToGraph,我们可以将被拖动节点的位置更新到数据模型中。
要绘制一条边,我们只需使用 Graphics.DrawLine(pen As Pen, x1 As Integer, y1 As Integer, x2 As Integer, y2 As Integer) 函数;而通过使用 Graphics.FillPie(brush As Brush, rect As Rectangle, startAngle As Single, sweepAngle As Single) 函数,我们可以在画布上绘制一个节点。
如何使用?
只需创建一个空窗体,然后像这样将画布控件放入您的窗体中即可。
Imports System.Windows.Forms
Imports Microsoft.VisualBasic.DataVisualization.Network.Canvas
Imports Microsoft.VisualBasic.DataVisualization.Network.FileStream
Public Class Form1
Dim canvas As New Canvas With {
.Dock = DockStyle.Fill
}
Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
Call Me.Controls.Add(canvas)
canvas.Graph = CytoscapeExportAsGraph(
App.HOME & "\Resources\xcb-main-Edges.csv",
App.HOME & "\Resources\xcb-main-Nodes.csv")
End Sub
End Class
性能问题
无论是 Visual Basic 还是 C#,在渲染大型图像时都存在 GDI+ 图形的性能问题,因为所有的 GDI+ 图形工作都在 CPU 上进行。这个工具在小型网络上运行良好,但在尝试渲染大规模网络数据时会卡顿,并且图形显示不够流畅。
计划在未来的工作中将图形引擎从 GDI+ 更换为 Microsoft Win2D 或 OpenGL。
运行测试
测试项目的源代码可以从 GitHub 下载,这里是示例发布程序。您可以通过修改程序目录下的 ini 文件来调整力导向图的物理参数:ForceDirectedArgs.ini。
[ForceDirectedArgs]
Stiffness=80
Repulsion=4000
Damping=0.83