WPFでツリートポロジーを描画する方法
親子関係があるデータ構造を表現する時には以下のようにツリートポロジーで表現しますが、TreeView以外にWPFにはグラフィカルに表示するためのコンポーネントやライブラリがありませんでした。
また、TreeViewはノードが多く・深くなっていくにつれて見づらくなります。
本記事では、以下のようにツリートポロジーをグラフィカルに描画する方法を紹介します。
また、トポロジーを表示しているアプリケーションは以下のGithubで公開しています。
ツリー構造のデータ
アプリケーションではツリー構造のデータが格納されたjsonファイルを読み込んで、WPFのCanvasにツリートポロジーを描画しており、jsonファイルは実行ファイル直下の「data.json」を読み込んでいます。
本記事で表示しているトポロジーのjsonデータは以下を使用しています。
{
"Text": "Root",
"Children": [
{
"Text": "Node1",
"Children": [
{
"Text": "Node1-1",
"Children": []
},
{
"Text": "Node1-2",
"Children": []
},
{
"Text": "Node1-2",
"Children": []
}
]
},
{
"Text": "Node2",
"Children": [
{
"Text": "Node2-1",
"Children": []
},
{
"Text": "Node2-2",
"Children": [
{
"Text": "Node2-2-1",
"Children": []
},
{
"Text": "Node2-2-2",
"Children": []
}
]
},
{
"Text": "Node2-2",
"Children": []
}
]
},
{
"Text": "Node3",
"Children": []
}
]
}
また、上記のツリー構造は「TreeNode」クラスに展開してプログラム上で参照します。
「Parent」に親ノード、「Children」に子ノードが設定され、ツリー構造にノードが繋がるような構造となります。
プロパティに「X」、「Y」がありますが、これはツリー構造を描画するときの座標で、後述する座標計算の結果(座標)を格納する用のプロパティです。また、他に計算用のプロパティや関数がありますが詳細は省略します。
namespace TreeTopology
{
public class TreeNode
{
public TreeNode? Parent { get; set; } = null;
public List<TreeNode> Children { get; set; } = new List<TreeNode>();
public string Text { get; set; } = string.Empty;
public float X { get; set; }
public float Y { get; set; }
public float Mod { get; set; }
public void SetParentReferences()
{
foreach (var child in Children)
{
child.Parent = this;
child.SetParentReferences();
}
}
(省略)
}
}
ツリー構造を描画するまでの全体処理
ツリートポロジーを描画するまでの処理概要と、プログラムを以下に示します。
プログラムは「Read topology json」ボタンを押した時に処理となります。「data.json」ファイルを読み込んで、「JsonSerializer」を使用してTreeNodeクラスにパースします。
- ツリー構造が定義されたjsonファイル読み込み
- jsonデータをパースしてTreeNodeクラスを作成、親子ノードのプロパティセット
- ツリートポロジーの座標計算
- ツリートポロジーの描画
private void ReadTopologyJsonButton_Click(object sender, RoutedEventArgs e)
{
try
{
// Get the path directly under the executable file
string filePath = AppDomain.CurrentDomain.BaseDirectory + "data.json";
// Check file exsited
if (!File.Exists(filePath))
{
Console.WriteLine("File not found: data.json.");
return;
}
// Read file contents
string jsonData = File.ReadAllText(filePath);
// Deserialize JSON to TreeNode object
TreeNode root = JsonSerializer.Deserialize<TreeNode>(jsonData, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? throw new InvalidOperationException("Failed to deserialize JSON.");
bool isHorizontal = HorizontalRadioButton.IsChecked ?? false;
if (root != null)
{
// Set Parent property
root.SetParentReferences();
// Calculate coordinates of tree topology
TreeHelper.CalculateNodePositions(root, isHorizontal);
// Draw Topology
DrawTopology.Draw(TopologyCanvas, root, isHorizontal);
}
}
catch (Exception ex)
{
// Error
Console.WriteLine("Exception Error: " + ex.Message);
}
}
トポロジーの座標計算
ツリー構造を描画するために、各ノードの座標位置を計算します。この時、ツリー構造を"きれい"に表示するために、以下のルールに従い座標を計算します。
- 木のエッジがその他のエッジと重ならない
- 同じ深さにあるノードが同じ水平のライン上に配置される
- 木が出来る限り狭く描かれる
- 親のノードがその子のノードの中央に描かれる
- サブツリーがどこに存在しても、同じ構造のサブツリーが同じように描かれる
上記のルールに従い、以下の処理手順で座標を計算します。基本の考え方は、末端の葉ノードから順に座標及びサブツリーを構成し、サブツリー間で位置調整しながらルート(親)方向に座標計算を進めていきます。
-
木の深さ優先探索 (DFS) を用いて葉ノードを処理
- 木の最下層(葉ノード)からスタートします。
- 葉ノードは初期的に適当な座標に初期化します。
- Y座標はノードの深さで設定する。
-
子ノードの配置
- 葉ノードでサブツリー内で最初の子ノードの場合は、X座標を0にする
- 2個目以降は、隣の子ノードのX座標+1とする
- サブツリー内で子ノードの配置が完了すれば、親ノードを子ノードの中間に配置する
-
サブツリー間の重なりを解消
- 各サブツリー間の間隔が適切に確保されるように、シフト操作を行います。
- この時、子ノードを含むサブツリー全体を平行移動させて重なりを解消する。
-
絶対位置の決定
- 2~3の手順を葉ノードから順に計算し、最終的にルートノードまでの座標を計算する
最終敵には以下のようなツリー構造が作成される。座標はTreeNodeクラスの「X」、「Y」プロパティに格納される。また、「X」と「Y」の座標を入れ替えることで横方向のツリー構造の座標に変更することが出来ます。
トポロジーの描画
計算した各ノードの座標に従い、以下のコードでCanvasにツリートポロジーを描画します。ノードをRectangleで描画して、ノード間をLineで繋げます。
public static class DrawTopology
{
// Node dimensions
private static double nodeWidth = 140.0d;
private static double nodeHeight = 40.0d;
// Line colors
private static SolidColorBrush green = Brushes.Green;
private static SolidColorBrush black = Brushes.Black;
// Canvas size
private static double canvasHeight = 0;
private static double canvasWidth = 0;
/// <summary>
/// Draws the tree topology on the canvas.
/// </summary>
/// <param name="canvas">The Canvas to draw on.</param>
/// <param name="root">The root node of the tree.</param>
/// <param name="isHorizontal">True for horizontal layout; false for vertical layout.</param>
public static void Draw(this Canvas canvas, TreeNode root, bool isHorizontal = false)
{
canvas.Children.Clear();
canvasHeight = 0;
canvasWidth = 0;
if (isHorizontal) canvas.drawHorizontal(root);
else canvas.drawVertical(root);
}
/// <summary>
/// Creates a line connecting two points.
/// </summary>
/// <param name="x1">Start point X-coordinate.</param>
/// <param name="y1">Start point Y-coordinate.</param>
/// <param name="x2">End point X-coordinate.</param>
/// <param name="y2">End point Y-coordinate.</param>
/// <param name="brush">Color of the line.</param>
/// <param name="thickness">Thickness of the line.</param>
/// <returns>A Line object.</returns>
private static Line createLine(double x1, double y1, double x2, double y2, Brush brush, double thickness)
{
Line line = new Line();
line.X1 = x1;
line.Y1 = y1;
line.X2 = x2;
line.Y2 = y2;
line.Stroke = brush;
line.StrokeThickness = thickness;
return line;
}
/// <summary>
/// Creates a rectangle representing a node.
/// </summary>
/// <param name="x">X-coordinate of the rectangle.</param>
/// <param name="y">Y-coordinate of the rectangle.</param>
/// <param name="width">Width of the rectangle.</param>
/// <param name="height">Height of the rectangle.</param>
/// <param name="stroke">Border color of the rectangle.</param>
/// <param name="thickness">Border thickness of the rectangle.</param>
/// <param name="fill">Fill color of the rectangle.</param>
/// <returns>A Rectangle object.</returns>
private static Rectangle createRect(double x, double y, double width, double height, Brush stroke, double thickness, Brush fill)
{
Rectangle rect = new Rectangle();
rect.RadiusX = 8d;
rect.RadiusY = 8d;
Canvas.SetLeft(rect, x);
Canvas.SetTop(rect, y);
rect.Width = width;
rect.Height = height;
rect.Stroke = stroke;
rect.StrokeThickness = thickness;
rect.Fill = fill;
return rect;
}
/// <summary>
/// Creates text content to display within a node.
/// </summary>
/// <param name="text">The text content.</param>
/// <param name="fontSize">Font size of the text.</param>
/// <param name="brush">Text color.</param>
/// <param name="x">X-coordinate of the text.</param>
/// <param name="y">Y-coordinate of the text.</param>
/// <param name="widh">Width of the text container.</param>
/// <param name="height">Height of the text container.</param>
/// <param name="hAlign">Horizontal alignment of the text.</param>
/// <param name="vAlign">Vertical alignment of the text.</param>
/// <returns>A ContentControl containing the text.</returns>
private static ContentControl createText(string text, double fontSize, Brush brush, double x, double y, double widh, double height, HorizontalAlignment hAlign, VerticalAlignment vAlign)
{
ContentControl content = new ContentControl();
Canvas.SetLeft(content, x);
Canvas.SetTop(content, y);
content.Width = widh;
content.Height = height;
TextBlock tb = new TextBlock();
tb.Text = text;
tb.FontSize = fontSize;
tb.Foreground = brush;
tb.HorizontalAlignment = hAlign;
tb.VerticalAlignment = vAlign;
content.Content = tb;
return content;
}
/// <summary>
/// Draws the tree in a horizontal layout.
/// </summary>
/// <param name="canvas">The Canvas to draw on.</param>
/// <param name="node">The current node to draw.</param>
private static void drawHorizontal(this Canvas canvas, TreeNode node)
{
// Spacing between nodes
double spaceX = 120;
double spaceY = 50;
// Draw the current node
var x = node.X * spaceX + node.X * nodeWidth;
var y = node.Y * spaceY + node.Y * nodeHeight;
canvas.Children.Add(createRect(x, y, nodeWidth, nodeHeight, green, 2.0d, Brushes.White));
canvas.Children.Add(createText(node.Text, 16.0d, black, x, y - 1, nodeWidth, nodeHeight, HorizontalAlignment.Center, VerticalAlignment.Center));
// Update canvas size
canvas.updateCanvasSize(x + nodeWidth, y + nodeHeight);
foreach (var child in node.Children)
{
// Draw connecting line
var line_x = child.X * spaceX + child.X * nodeWidth;
var line_y = child.Y * spaceY + child.Y * nodeHeight + nodeHeight / 2;
canvas.Children.Add(createLine(x + nodeWidth, y + nodeHeight / 2, line_x, line_y, green, 2.5d));
// Draw child nodes
canvas.drawHorizontal(child);
}
}
/// <summary>
/// Draws the tree in a vertical layout.
/// </summary>
/// <param name="canvas">The Canvas to draw on.</param>
/// <param name="node">The current node to draw.</param>
private static void drawVertical(this Canvas canvas, TreeNode node)
{
// Spacing between nodes
double spaceX = 50;
double spaceY = 120;
// Draw the current node
var x = node.X * spaceX + node.X * nodeWidth;
var y = node.Y * spaceY + node.Y * nodeHeight;
canvas.Children.Add(createRect(x, y, nodeWidth, nodeHeight, green, 2.0d, Brushes.White));
canvas.Children.Add(createText(node.Text, 16.0d, black, x, y - 1, nodeWidth, nodeHeight, HorizontalAlignment.Center, VerticalAlignment.Center));
// Update canvas size
canvas.updateCanvasSize(x + nodeWidth, y + nodeHeight);
foreach (var child in node.Children)
{
// Draw connecting line
var line_x = child.X * spaceX + child.X * nodeWidth + nodeWidth / 2;
var line_y = child.Y * spaceY + child.Y * nodeHeight;
canvas.Children.Add(createLine(x + nodeWidth / 2, y + nodeHeight, line_x, line_y, green, 2.5d));
// Draw child nodes
canvas.drawVertical(child);
}
}
/// <summary>
/// Updates the canvas size based on the drawn elements.
/// </summary>
/// <param name="canvas">The Canvas to update.</param>
/// <param name="widht">The new width of the canvas.</param>
/// <param name="height">The new height of the canvas.</param>
private static void updateCanvasSize(this Canvas canvas, double widht, double height)
{
if (widht > canvasWidth)
{
canvasWidth = widht;
canvas.Width = widht;
}
if (height > canvasHeight)
{
canvasHeight = height;
canvas.Height = height;
}
}
}
ソースコード
作成したアプリケーションのプロジェクトファイルは以下のサイトで販売しています。ソースコード全体を確認したい方は、ご購入の程よろしくお願いいたします。