2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【WPF】ツリートポロジーを描画する方法

Posted at

WPFでツリートポロジーを描画する方法

親子関係があるデータ構造を表現する時には以下のようにツリートポロジーで表現しますが、TreeView以外にWPFにはグラフィカルに表示するためのコンポーネントやライブラリがありませんでした。
また、TreeViewはノードが多く・深くなっていくにつれて見づらくなります。

本記事では、以下のようにツリートポロジーをグラフィカルに描画する方法を紹介します。
また、トポロジーを表示しているアプリケーションは以下のGithubで公開しています。

fig1.png

ツリー構造のデータ

アプリケーションではツリー構造のデータが格納された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);
    }
}

トポロジーの座標計算

ツリー構造を描画するために、各ノードの座標位置を計算します。この時、ツリー構造を"きれい"に表示するために、以下のルールに従い座標を計算します。

  • 木のエッジがその他のエッジと重ならない
  • 同じ深さにあるノードが同じ水平のライン上に配置される
  • 木が出来る限り狭く描かれる
  • 親のノードがその子のノードの中央に描かれる
  • サブツリーがどこに存在しても、同じ構造のサブツリーが同じように描かれる

上記のルールに従い、以下の処理手順で座標を計算します。基本の考え方は、末端の葉ノードから順に座標及びサブツリーを構成し、サブツリー間で位置調整しながらルート(親)方向に座標計算を進めていきます。

  1. 木の深さ優先探索 (DFS) を用いて葉ノードを処理

    • 木の最下層(葉ノード)からスタートします。
    • 葉ノードは初期的に適当な座標に初期化します。
    • Y座標はノードの深さで設定する。
  2. 子ノードの配置

    • 葉ノードでサブツリー内で最初の子ノードの場合は、X座標を0にする
    • 2個目以降は、隣の子ノードのX座標+1とする
    • サブツリー内で子ノードの配置が完了すれば、親ノードを子ノードの中間に配置する
  3. サブツリー間の重なりを解消

    • 各サブツリー間の間隔が適切に確保されるように、シフト操作を行います。
    • この時、子ノードを含むサブツリー全体を平行移動させて重なりを解消する。
  4. 絶対位置の決定

    • 2~3の手順を葉ノードから順に計算し、最終的にルートノードまでの座標を計算する

最終敵には以下のようなツリー構造が作成される。座標はTreeNodeクラスの「X」、「Y」プロパティに格納される。また、「X」と「Y」の座標を入れ替えることで横方向のツリー構造の座標に変更することが出来ます。

fig3.png

トポロジーの描画

計算した各ノードの座標に従い、以下のコードで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;
        }
    }
}

ソースコード

作成したアプリケーションのプロジェクトファイルは以下のサイトで販売しています。ソースコード全体を確認したい方は、ご購入の程よろしくお願いいたします。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?