OpenSiv3D でグラフ描画を行うサンプルとチュートリアルです。
TextGraph | MultipleGraphs |
RailwayMap | JSONViewer |
PathSearch | |
まず、描画するグラフのエッジの配列を引数にして ConnectedGraph
クラスを作ります(ConnectedGraph
はすべてのノードがエッジでつながれた連結グラフ
を表すデータ構造です。)
次に ConnectedGraph
を LayoutCircular
クラスに渡して初期化します。
ここで円形にノードを配置する座標が計算されます。
その後 layout.draw()
を呼んで画面にグラフを描画します。
このとき、LayoutCircular
はグラフの配置情報しか持たないので、引数に BasicGraphVisualizer
クラスを与えて色や大きさなど描画の方法を指定します。
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const ConnectedGraph graph = { {
{0, 1},
{2, 1},
{1, 3},
{3, 4},
{3, 5},
{4, 6},
{5, 6},
} };
const LayoutCircular layout{ graph };
while (System::Update())
{
layout.draw(BasicGraphVisualizer{});
}
}
今度は LayoutForceDirected
クラスに ConnectedGraph
を渡して ForceDirected レイアウトを行います。
LayoutForceDirected
のレイアウト計算は複雑なグラフに対して時間がかかるため、Circular レイアウトと異なり通常は layout.update()
を呼んだタイミングでのみ行われます。
ここでは、例として簡単なグラフを扱うので、設定に .startImmediately = StartImmediately::Yes
を指定してレイアウト計算をその場で実行します(複雑なグラフをループで少しずつ計算する方法は チュートリアル3 インタラクティブな描画 を参照してください。)
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const ConnectedGraph graph = { {
{0, 1},
{2, 1},
{1, 3},
{3, 4},
{3, 5},
{4, 6},
{5, 6},
} };
const LayoutForceDirected layout{ graph, ForceDirectedConfig{ .startImmediately = StartImmediately::Yes } };
while (System::Update())
{
layout.draw(BasicGraphVisualizer{});
}
}
連結でないグラフは GraphSet
クラスを使用して ConnectedGraph
に分解することで描画できます。
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const GraphSet graphs = { {
// [0]
{0, 1},
{1, 2},
{2, 0},
// [1]
{3, 4},
{4, 5},
{5, 6},
{6, 3},
} };
const ForceDirectedConfig config{ .startImmediately = StartImmediately::Yes };
const LayoutForceDirected layout0{ graphs[0], config };
const LayoutForceDirected layout1{ graphs[1], config };
while (System::Update())
{
layout0.draw(BasicGraphVisualizer{});
layout1.draw(BasicGraphVisualizer{});
}
}
テキストファイルからグラフを読み込むには次の関数を使います。
ReadEdgeListText()
: エッジリスト (.txt)ReadMMCoordinateFormat()
: Matrix Market Exchange Formats 形式 (.mtx)
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const GraphSet graphs = ReadMMCoordinateFormat(U"primitives.mtx");
const ForceDirectedConfig config{ .startImmediately = StartImmediately::Yes };
int32 index = 0;
LayoutForceDirected layout{ graphs[index], config };
const Font font{ 24 };
while (System::Update())
{
if (KeySpace.down())
{
index = (index + 1) % graphs.size();
layout = LayoutForceDirected{ graphs[index], config };
}
layout.draw(BasicGraphVisualizer{});
font(U"グラフ", index + 1, U"/", graphs.size(), U"(Space キーでグラフを切り替える)").draw(0, 0, Palette::Yellow);
}
}
layout.setDrawArea()
で描画する範囲を指定することができます。
例として Rect
の端を掴んで描画範囲を動かせるプログラムを作ってみます。
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const GraphSet graphs = ReadEdgeListText(U"simpleGraph.txt");
auto layout = LayoutForceDirected{ graphs[0], ForceDirectedConfig{.startImmediately = StartImmediately::Yes } };
RectF rect = Scene::Rect().stretched(-100);
const BasicGraphVisualizer visualizer;
while (System::Update())
{
rect.drawFrame(2.0);
const Circle cursorCircle{ Cursor::Pos(), 30.0 };
const bool mouseOverLeft = rect.left().intersects(cursorCircle);
const bool mouseOverRight = rect.right().intersects(cursorCircle);
const bool mouseOverTop = rect.top().intersects(cursorCircle);
const bool mouseOverBottom = rect.bottom().intersects(cursorCircle);
Cursor::SetDefaultStyle(CursorStyle::Default);
if (mouseOverLeft || mouseOverRight)
{
Cursor::SetDefaultStyle(CursorStyle::ResizeLeftRight);
}
else if (mouseOverTop || mouseOverBottom)
{
Cursor::SetDefaultStyle(CursorStyle::ResizeUpDown);
}
if (MouseL.pressed())
{
if (mouseOverLeft)
{
rect = RectF(Arg::bottomRight = rect.br(), rect.br().x - Cursor::Pos().x, rect.h);
}
else if (mouseOverRight)
{
rect = RectF(Arg::topLeft = rect.tl(), Cursor::Pos().x - rect.tl().x, rect.h);
}
else if (mouseOverTop)
{
rect = RectF(Arg::bottomRight = rect.br(), rect.w, rect.br().y - Cursor::Pos().y);
}
else if (mouseOverBottom)
{
rect = RectF(Arg::topLeft = rect.tl(), rect.w, Cursor::Pos().y - rect.tl().y);
}
}
layout.setDrawArea(rect);
layout.draw(visualizer);
}
}
BasicGraphVisualizer
の引数にノードの半径、エッジの太さ、ノードの色、エッジの色を指定することができます。
RectF rect = Scene::Rect().stretched(-100);
- const BasicGraphVisualizer visualizer;
+ Scene::SetBackground(Color(U"#f7f1cf"));
+ BasicGraphVisualizer visualizer{ 15, 5, Color(U"#7adb6b"), Color(U"#e5da9a") };
while (System::Update())
また、layout.setDrawArea()
は渡された Rect
にノードの中心座標を揃えるため、(1) のプログラムは描画したときに外側のノードがはみ出ています。
これを描画範囲にぴったり収めるにはノードの半径分縮めた Rect
を layout.setDrawArea()
に渡すようにします。
}
- layout.setDrawArea(rect);
+ layout.setDrawArea(rect.stretched(-visualizer.m_nodeRadius));
layout.draw(visualizer);
BasicGraphVisualizer
クラスを継承して描画関数をカスタマイズすることができます。
drawNode()
をオーバーライドしてノードの描画をラベル付きにする LabelGraphVisualizer
クラスを作ってみましょう。
ついでに drawEdge()
も書き換えてエッジの描画スタイルを点線に変更してみます。
class LabelGraphVisualizer : public BasicGraphVisualizer
{
public:
explicit LabelGraphVisualizer(const Font& font, ColorF fontColor, double nodeRadius = 10.0, double edgeThickness = 1.0, ColorF nodeColor = Palette::White, ColorF edgeColor = ColorF(0.8))
: BasicGraphVisualizer{ nodeRadius, edgeThickness, nodeColor, edgeColor }
, m_labelFont(font)
, m_labelColor(fontColor)
{}
virtual ~LabelGraphVisualizer() = default;
virtual void drawNode(const Vec2& pos, GraphEdge::IndexType nodeIndex) const override
{
pos.asCircle(m_nodeRadius).draw(m_nodeColor);
m_labelFont(nodeIndex).drawAt(pos, m_labelColor);
}
virtual void drawEdge(const Line& line, GraphEdge::IndexType, GraphEdge::IndexType) const override
{
line.draw(LineStyle::RoundDot, m_edgeThickness, m_edgeColor);
}
Font m_labelFont;
ColorF m_labelColor;
};
そして visualizer
を上で定義した LabelGraphVisualizer
に置き換えます。
Scene::SetBackground(Color(U"#f7f1cf"));
- BasicGraphVisualizer visualizer{ 15, 5, Color(U"#7adb6b"), Color(U"#e5da9a") };
+ LabelGraphVisualizer visualizer{ Font{16, Typeface::Heavy }, Color(U"#f7f1cf"), 15, 5, Color(U"#7adb6b"), Color(U"#e5da9a") };
while (System::Update())
LayoutForceDirected
は乱数を使うため実行するたびに異なるレイアウトに収束します。
予め乱数のシードを固定することで、同じレイアウトを再現することが可能です。
const GraphSet graphs = ReadEdgeListText(U"simpleGraph.txt");
+ Reseed(0); // シード値を0に設定
auto layout = LayoutForceDirected{ graphs[0], ForceDirectedConfig{.startImmediately = StartImmediately::Yes } };
Transformer2D
を作って描画範囲ごと回転したりスケールをかけたりすることができます。
+ double angle = 30_deg;
while (System::Update())
{
+ // マウスホイールで回転する
+ angle += Mouse::Wheel() * 0.1;
+
+ const auto mat = Mat3x2::Rotate(angle, Scene::Center());
+ const Transformer2D t(mat, TransformCursor::Yes);
rect.drawFrame(2.0);
ここで layout.setDrawArea()
の第二引数に Mat3x2
を渡せば、描画範囲を固定したままトランスフォームをかけることができます。
while (System::Update())
{
// マウスホイールで回転する
angle += Mouse::Wheel() * 0.1;
+ rect.drawFrame(2.0);
+
const auto mat = Mat3x2::Rotate(angle, Scene::Center());
- const Transformer2D t(mat, TransformCursor::Yes);
+ const Transformer2D t(mat);
- rect.drawFrame(2.0);
}
- layout.setDrawArea(rect.stretched(-visualizer.m_nodeRadius));
+ layout.setDrawArea(rect.stretched(-visualizer.m_nodeRadius), mat);
layout.draw(visualizer);
}
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
class LabelGraphVisualizer : public BasicGraphVisualizer
{
public:
explicit LabelGraphVisualizer(const Font& font, ColorF fontColor, double nodeRadius = 10.0, double edgeThickness = 1.0, ColorF nodeColor = Palette::White, ColorF edgeColor = ColorF(0.8))
: BasicGraphVisualizer{ nodeRadius, edgeThickness, nodeColor, edgeColor }
, m_labelFont(font)
, m_labelColor(fontColor)
{}
virtual ~LabelGraphVisualizer() = default;
virtual void drawNode(const Vec2& pos, GraphEdge::IndexType nodeIndex) const override
{
pos.asCircle(m_nodeRadius).draw(m_nodeColor);
m_labelFont(nodeIndex).drawAt(pos, m_labelColor);
}
virtual void drawEdge(const Line& line, GraphEdge::IndexType, GraphEdge::IndexType) const override
{
line.draw(LineStyle::RoundDot, m_edgeThickness, m_edgeColor);
}
Font m_labelFont;
ColorF m_labelColor;
};
void Main()
{
const GraphSet graphs = ReadEdgeListText(U"simpleGraph.txt");
Reseed(0);
auto layout = LayoutForceDirected{ graphs[0], ForceDirectedConfig{.startImmediately = StartImmediately::Yes } };
RectF rect = Scene::Rect().stretched(-100);
Scene::SetBackground(Color(U"#f7f1cf"));
LabelGraphVisualizer visualizer{ Font{16, Typeface::Heavy }, Color(U"#f7f1cf"), 15, 5, Color(U"#7adb6b"), Color(U"#e5da9a") };
double angle = 30_deg;
while (System::Update())
{
// マウスホイールで回転する
angle += Mouse::Wheel() * 0.1;
rect.drawFrame(2.0);
const auto mat = Mat3x2::Rotate(angle, Scene::Center());
const Transformer2D t(mat);
const Circle cursorCircle{ Cursor::Pos(), 30.0 };
const bool mouseOverLeft = rect.left().intersects(cursorCircle);
const bool mouseOverRight = rect.right().intersects(cursorCircle);
const bool mouseOverTop = rect.top().intersects(cursorCircle);
const bool mouseOverBottom = rect.bottom().intersects(cursorCircle);
Cursor::SetDefaultStyle(CursorStyle::Default);
if (mouseOverLeft || mouseOverRight)
{
Cursor::SetDefaultStyle(CursorStyle::ResizeLeftRight);
}
else if (mouseOverTop || mouseOverBottom)
{
Cursor::SetDefaultStyle(CursorStyle::ResizeUpDown);
}
if (MouseL.pressed())
{
if (mouseOverLeft)
{
rect = RectF(Arg::bottomRight = rect.br(), rect.br().x - Cursor::Pos().x, rect.h);
}
else if (mouseOverRight)
{
rect = RectF(Arg::topLeft = rect.tl(), Cursor::Pos().x - rect.tl().x, rect.h);
}
else if (mouseOverTop)
{
rect = RectF(Arg::bottomRight = rect.br(), rect.w, rect.br().y - Cursor::Pos().y);
}
else if (mouseOverBottom)
{
rect = RectF(Arg::topLeft = rect.tl(), rect.w, Cursor::Pos().y - rect.tl().y);
}
}
layout.setDrawArea(rect.stretched(-visualizer.m_nodeRadius), mat);
layout.draw(visualizer);
}
}
ForceDirected レイアウトを使ってグラフの配置をインタラクティブに編集するアプリケーションを作ってみます。
これまでは全て初期化時にレイアウトの計算を行っていました。 しかし、規模の大きいグラフ(ノード数が10000以上)になるとレイアウトの計算が完了するまでに数十秒かかることもあります。
このような場合、初期化時に計算を行わずにループの中で layout.update()
を呼ぶことで、描画を更新しながらレイアウトの計算を行うことができます。
layout.update()
の引数には、計算に使う時間をミリ秒で指定します。
他に重い処理がないプログラムであれば、16
ミリ秒としておけば 60FPS を維持しながら計算を進めます。
また、これまでは layout.setDrawArea()
は最初に一度呼んだきりでしたが、レイアウトが更新されるたびに座標が変わるので呼びなおす必要があります。
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const GraphSet graphs = ReadEdgeListText(U"sierpinski.txt");
const double nodeRadius = 7;
BasicGraphVisualizer visualizer{ nodeRadius };
Reseed(0);
LayoutForceDirected layout{ graphs[0], ForceDirectedConfig{} };
while (System::Update())
{
layout.update(16);
layout.setDrawArea(Scene::Rect().stretched(-50));
layout.draw(visualizer);
}
}
ノードの現在位置は layout.activeNodePositions()
で取得することができます。
これを使ってクリックされたノードのインデックスを表示する機能を追加します。
void Main()
{
+ const Font font(16, Typeface::Heavy);
+
+ Optional<GraphEdge::IndexType> clickedNode;
const GraphSet graphs = ReadEdgeListText(U"sierpinski.txt");
while (System::Update())
{
layout.update(16);
layout.setDrawArea(Scene::Rect().stretched(-50));
layout.draw(visualizer);
+ for (auto& [nodeIndex, nodePos] : layout.activeNodePositions())
+ {
+ const auto nodeCircle = nodePos.asCircle(nodeRadius);
+
+ if (nodeCircle.leftClicked())
+ {
+ clickedNode = nodeIndex;
+ }
+
+ if (clickedNode == nodeIndex)
+ {
+ nodeCircle.draw(Palette::Orange);
+
+ const auto labelPos = nodePos + Circular{ 20, 30_deg };
+ font(nodeIndex).draw(labelPos + Vec2{ 1, 1 }, Palette::Black);
+ font(nodeIndex).draw(labelPos, Palette::Orange);
+ }
+ }
}
ノードのクリック判定が取れるようになったので、次はクリックしたノードの座標をカーソル位置に移動するようにします。
まず config.autoSuspend
を false
にしてレイアウトが完了しても座標更新を続けるようにします。
そして config.updateFunction
にはレイアウト計算でそれぞれのノードに対して呼ばれる座標更新関数を設定することができます。
これにクリック中のノードの座標をカーソル位置に移動する処理を加えましょう。
Reseed(0);
- LayoutForceDirected layout{ graphs[0], ForceDirectedConfig{} };
+ ForceDirectedConfig config
+ {
+ .autoSuspend = false,
+ .initialTimeStep = 0.01, // クリック時の見た目のぶれを小さくするため
+ };
+
+ config.updateFunction = [&](GraphEdge::IndexType nodeIndex, const Vec2& /*oldPos*/, const Vec2& newPos)
+ {
+ if (clickedNode && clickedNode.value() == nodeIndex)
+ {
+ return Cursor::PosF();
+ }
+
+ return newPos;
+ };
+
+ LayoutForceDirected layout{ graphs[0], config };
あとはマウスを離したときに clickedNode
をリセットする処理を入れればドラッグ移動ができるようになります。
ただし、これだけだとドラッグしながら描画範囲から出た時に layout.setDrawArea()
で全体を縮小する処理が連続して走ってしまうため、ドラッグ中は layout.setDrawArea()
が呼ばれないように変更します。
while (System::Update())
{
layout.update(16);
- layout.setDrawArea(Scene::Rect().stretched(-50));
+ if (!MouseL.pressed())
+ {
+ clickedNode = none;
+
+ layout.setDrawArea(Scene::Rect().stretched(-50));
+ }
layout.draw(visualizer);
#include <Siv3D.hpp> // OpenSiv3D v0.6
#include "include/GraphDrawing.hpp"
void Main()
{
const Font font(16, Typeface::Heavy);
Optional<GraphEdge::IndexType> clickedNode;
const GraphSet graphs = ReadEdgeListText(U"sierpinski.txt");
const double nodeRadius = 7;
BasicGraphVisualizer visualizer{ nodeRadius };
Reseed(0);
ForceDirectedConfig config
{
.autoSuspend = false,
.initialTimeStep = 0.01, // クリック時の見た目のぶれを小さくするため
};
config.updateFunction = [&](GraphEdge::IndexType nodeIndex, const Vec2& /*oldPos*/, const Vec2& newPos)
{
if (clickedNode && clickedNode.value() == nodeIndex)
{
return Cursor::PosF();
}
return newPos;
};
LayoutForceDirected layout{ graphs[0], config };
while (System::Update())
{
layout.update(16);
if (!MouseL.pressed())
{
clickedNode = none;
layout.setDrawArea(Scene::Rect().stretched(-50));
}
layout.draw(visualizer);
for (auto& [nodeIndex, nodePos] : layout.activeNodePositions())
{
const auto nodeCircle = nodePos.asCircle(nodeRadius);
if (nodeCircle.leftClicked())
{
clickedNode = nodeIndex;
}
if (clickedNode == nodeIndex)
{
nodeCircle.draw(Palette::Orange);
const auto labelPos = nodePos + Circular{ 20, 30_deg };
font(nodeIndex).draw(labelPos + Vec2{ 1, 1 }, Palette::Black);
font(nodeIndex).draw(labelPos, Palette::Orange);
}
}
}
}