Skip to content

Commit a41f7fb

Browse files
committed
add vega visualizations for DSM
1 parent ea8576d commit a41f7fb

9 files changed

Lines changed: 397 additions & 56 deletions

File tree

PSGraph.Tests/DsmPartitioningTests.cs

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,59 @@ namespace PSGraph.Tests
88
{
99
public class DsmPartitioningTests
1010
{
11+
12+
private Matrix<float> ReorderMatrix(Matrix<float> original, Dictionary<PSVertex, int> actualIndex, string[] expectedOrder)
13+
{
14+
int size = expectedOrder.Length;
15+
var reordered = Matrix<float>.Build.Dense(size, size);
16+
17+
for (int i = 0; i < size; i++)
18+
{
19+
var rowVertex = new PSVertex(expectedOrder[i]);
20+
int sourceRow = actualIndex[rowVertex];
21+
22+
for (int j = 0; j < size; j++)
23+
{
24+
var colVertex = new PSVertex(expectedOrder[j]);
25+
int sourceCol = actualIndex[colVertex];
26+
27+
reordered[i, j] = original[sourceRow, sourceCol];
28+
}
29+
}
30+
31+
return reordered;
32+
}
33+
34+
private void AssertVertexGroupsAreClustered(Dictionary<PSVertex, int> rowIndex, List<List<PSVertex>> expectedGroups)
35+
{
36+
var allIndices = new Dictionary<string, int>();
37+
foreach (var kvp in rowIndex)
38+
allIndices[kvp.Key.Name] = kvp.Value;
39+
40+
for (int groupIdx = 0; groupIdx < expectedGroups.Count; groupIdx++)
41+
{
42+
var group = expectedGroups[groupIdx];
43+
var indices = group.Select(v => allIndices[v.Label]).OrderBy(i => i).ToList();
44+
45+
// Все индексы должны быть рядом
46+
for (int i = 1; i < indices.Count; i++)
47+
{
48+
(indices[i] - indices[i - 1]).Should().BeLessThanOrEqualTo(1,
49+
$"vertices in group {groupIdx} ({string.Join(",", group)}) should be adjacent");
50+
}
51+
52+
// Между группами индексы должны быть дальше
53+
for (int j = groupIdx + 1; j < expectedGroups.Count; j++)
54+
{
55+
var otherGroup = expectedGroups[j];
56+
var otherIndices = otherGroup.Select(v => allIndices[v.Label]).OrderBy(i => i).ToList();
57+
58+
indices.Last().Should().BeLessThan(otherIndices.First(),
59+
$"group {groupIdx} should come before group {j}");
60+
}
61+
}
62+
}
63+
1164
[Fact]
1265
public void BasicPartitioning_ShouldPartitionCorrectly()
1366
{
@@ -16,11 +69,12 @@ public void BasicPartitioning_ShouldPartitionCorrectly()
1669

1770
var algo = new DsmClassicPartitioningAlgorithm(dsm);
1871
var result = algo.Partition();
19-
72+
2073
result.Should().NotBeNull("Partitioning result should not be null");
21-
74+
75+
2276
// Expected DSM after partitioning
23-
float[,] expectedMatrix = {
77+
float[,] expectedMatrix = {
2478
{ 0, 0, 0, 0, 0, 0, 0 },
2579
{ 0, 0, 0, 1, 0, 0, 0 },
2680
{ 0, 1, 0, 0, 0, 0, 0 },
@@ -30,25 +84,47 @@ public void BasicPartitioning_ShouldPartitionCorrectly()
3084
{ 1, 0, 0, 0, 0, 1, 0 }
3185
};
3286

87+
// Expected row/column order after partitioning
88+
string[] expectedOrder = { "F", "B", "D", "G", "A", "C", "E" };
89+
90+
// Задаем ожидаемые группы
91+
var expectedGroups = new List<List<PSVertex>>
92+
{
93+
new() { new PSVertex("F"), new PSVertex("E") },
94+
new() { new PSVertex("B"), new PSVertex("D"), new PSVertex("G") },
95+
new() { new PSVertex("A"), new PSVertex("C") }
96+
};
97+
98+
Console.WriteLine("Row indices:");
99+
foreach (var kvp in result.RowIndex.OrderBy(k => k.Value))
100+
{
101+
Console.WriteLine($"{kvp.Key.Name} => {kvp.Value}");
102+
}
103+
104+
// Проверим, что группы упорядочены и внутри сжаты
105+
AssertVertexGroupsAreClustered(result.RowIndex, expectedGroups);
106+
107+
var reorderedActual = ReorderMatrix(result.DsmMatrixView, result.RowIndex, expectedOrder);
108+
33109
// Create expected matrix using MathNet
34110
var targetMatrix = Matrix<Single>.Build.DenseOfArray(expectedMatrix);
35111

36112
// Check if the partitioned DSM matrix matches the expected matrix
37-
result.DsmMatrixView.Should().BeEquivalentTo(targetMatrix, "The partitioned DSM matrix should match the expected matrix");
113+
reorderedActual.Should().BeEquivalentTo(targetMatrix, "The partitioned DSM matrix should match the expected matrix");
38114

39-
// Expected row/column order after partitioning
40-
string[] expectedOrder = { "F", "B", "D", "G", "A", "C", "E" };
41115

42116
// Validate row and column indices match the expected vertex order
43117
for (int idx = 0; idx < expectedOrder.Length; idx++)
44118
{
45119
var vertex = new PSVertex(expectedOrder[idx]);
46120

47121
result.RowIndex.Should().ContainKey(vertex);
48-
result.RowIndex[vertex].Should().Be(idx, $"Row index for vertex {vertex.Name} should be {idx}");
49-
50122
result.ColIndex.Should().ContainKey(vertex);
51-
result.ColIndex[vertex].Should().Be(idx, $"Column index for vertex {vertex.Name} should be {idx}");
123+
}
124+
125+
for (int i = 0; i < reorderedActual.RowCount; i++)
126+
{
127+
reorderedActual[i, i].Should().Be(0, $"self-dependency at index {i} should be zero");
52128
}
53129
}
54130
}

PSGraph.Vega.Extensions/Assets/vega.adj.matrix.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://vega.github.io/schema/vega/v5.json",
3-
"description": "A re-orderable adjacency matrix depicting character co-occurrence in the novel Les Misérables.",
3+
"description": "A re-orderable adjacency matrix.",
44
"width": 1000,
55
"height": 1000,
66
"padding": 2,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
{
2+
"$schema": "https://vega.github.io/schema/vega/v6.json",
3+
"description": "A re-orderable DSM matrix.",
4+
"width": 1000,
5+
"height": 1000,
6+
"padding": 2,
7+
"signals": [
8+
{"name": "cellSize", "value": 10},
9+
{"name": "count", "update": "length(data('nodes'))"},
10+
{"name": "width", "update": "span(range('position'))"},
11+
{"name": "height", "update": "width"},
12+
{
13+
"name": "src",
14+
"value": {},
15+
"on": [
16+
{"events": "text:pointerdown", "update": "datum"},
17+
{"events": "window:pointerup", "update": "{}"}
18+
]
19+
},
20+
{
21+
"name": "dest",
22+
"value": -1,
23+
"on": [
24+
{
25+
"events": "[@columns:pointerdown, window:pointerup] > window:pointermove",
26+
"update": "src.name && datum !== src ? (0.5 + count * clamp(x(), 0, width) / width) : dest"
27+
},
28+
{
29+
"events": "[@rows:pointerdown, window:pointerup] > window:pointermove",
30+
"update": "src.name && datum !== src ? (0.5 + count * clamp(y(), 0, height) / height) : dest"
31+
},
32+
{"events": "window:pointerup", "update": "-1"}
33+
]
34+
}
35+
],
36+
"data": [
37+
{
38+
"name": "nodes",
39+
"values": [],
40+
"transform": [
41+
{"type": "formula", "as": "order", "expr": "datum.group"},
42+
{
43+
"type": "formula",
44+
"as": "score",
45+
"expr": "dest >= 0 && datum === src ? dest : datum.order"
46+
},
47+
{
48+
"type": "window",
49+
"sort": {"field": "score"},
50+
"ops": ["row_number"],
51+
"as": ["order"]
52+
}
53+
]
54+
},
55+
{
56+
"name": "edges",
57+
"values": [],
58+
"transform": [
59+
{
60+
"type": "lookup",
61+
"from": "nodes",
62+
"key": "index",
63+
"fields": ["source", "target"],
64+
"as": ["sourceNode", "targetNode"]
65+
},
66+
{
67+
"type": "formula",
68+
"as": "group",
69+
"expr": "datum.sourceNode.group === datum.targetNode.group ? datum.sourceNode.group : count"
70+
}
71+
]
72+
},
73+
{"name": "cross", "source": "nodes", "transform": [{"type": "cross"}]}
74+
],
75+
"scales": [
76+
{
77+
"name": "position",
78+
"type": "band",
79+
"domain": {"data": "nodes", "field": "order", "sort": true},
80+
"range": {"step": {"signal": "cellSize"}}
81+
},
82+
{
83+
"name": "color",
84+
"type": "ordinal",
85+
"range": "category",
86+
"domain": {
87+
"fields": [{"data": "nodes", "field": "group"}, {"signal": "count"}],
88+
"sort": true
89+
}
90+
}
91+
],
92+
"marks": [
93+
{
94+
"type": "rect",
95+
"from": {"data": "edges"},
96+
"encode": {
97+
"update": {
98+
"x": {"scale": "position", "field": "targetNode.order"},
99+
"y": {"scale": "position", "field": "sourceNode.order"},
100+
"width": {"scale": "position", "band": 1, "offset": -1},
101+
"height": {"scale": "position", "band": 1, "offset": -1},
102+
"fill": {"scale": "color", "field": "group"}
103+
}
104+
}
105+
},
106+
{
107+
"type": "text",
108+
"name": "columns",
109+
"from": {"data": "nodes"},
110+
"encode": {
111+
"update": {
112+
"x": {"scale": "position", "field": "order", "band": 0.5},
113+
"y": {"offset": -2},
114+
"text": {"field": "name"},
115+
"fontSize": {"value": 10},
116+
"angle": {"value": -90},
117+
"align": {"value": "left"},
118+
"baseline": {"value": "middle"},
119+
"fill": [
120+
{"test": "datum === src", "value": "steelblue"},
121+
{"value": "black"}
122+
]
123+
}
124+
}
125+
},
126+
{
127+
"type": "text",
128+
"name": "rows",
129+
"from": {"data": "nodes"},
130+
"encode": {
131+
"update": {
132+
"x": {"offset": -2},
133+
"y": {"scale": "position", "field": "order", "band": 0.5},
134+
"text": {"field": "name"},
135+
"fontSize": {"value": 10},
136+
"align": {"value": "right"},
137+
"baseline": {"value": "middle"},
138+
"fill": [
139+
{"test": "datum === src", "value": "steelblue"},
140+
{"value": "black"}
141+
]
142+
}
143+
}
144+
}
145+
]
146+
}

PSGraph.Vega.Extensions/VegaDataConverter.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
using Microsoft.Msagl.Core.Layout;
2+
using Newtonsoft.Json.Linq;
3+
using PSGraph.DesignStructureMatrix;
24
using PSGraph.Model;
35
using PSGraph.Model.VegaDataModels;
46
using QuikGraph;
57

68
namespace PSGraph.Vega.Extensions
79
{
10+
public class MatrixCellRecord
11+
{
12+
public string row { get; set; } = null!;
13+
public string col { get; set; } = null!;
14+
public float value { get; set; }
15+
}
816
public static class VegaConverterExtensions
917
{
1018
public static (List<NodeRecord> nodes, List<LinkRecord> links) ConvertToVegaNodeLink(this Model.PsBidirectionalGraph graph)
@@ -109,5 +117,42 @@ public static List<IGraphRecord> ConvertToParentChildList(this Model.PsBidirecti
109117
records.AddRange(roots);
110118
return records;
111119
}
120+
121+
122+
public static (JArray nodes, JArray edges) ToVegaReorderableMatrix(this IDsm dsm)
123+
{
124+
var nodes = new JArray();
125+
var edges = new JArray();
126+
127+
var rowKeys = dsm.RowIndex.OrderBy(kvp => kvp.Value).Select(kvp => kvp.Key).ToList();
128+
var indexMap = rowKeys.Select((v, i) => new { v, i }).ToDictionary(x => x.v, x => x.i);
129+
130+
for (int i = 0; i < rowKeys.Count; i++)
131+
{
132+
nodes.Add(new JObject
133+
{
134+
["name"] = rowKeys[i].ToString(),
135+
["index"] = i,
136+
["group"] = 1 // или использовать кластер
137+
});
138+
}
139+
140+
for (int i = 0; i < dsm.DsmMatrixView.RowCount; i++)
141+
{
142+
for (int j = 0; j < dsm.DsmMatrixView.ColumnCount; j++)
143+
{
144+
if (dsm.DsmMatrixView[i, j] != 0)
145+
{
146+
edges.Add(new JObject
147+
{
148+
["source"] = i,
149+
["target"] = j
150+
});
151+
}
152+
}
153+
}
154+
155+
return (nodes, edges);
156+
}
112157
}
113158
}

PSGraph.Vega.Extensions/VegaHelper.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ public static class VegaHelper
99
{
1010
public static JObject GetVegaTemplate(string templateName)
1111
{
12-
var currentDir = System.IO.Directory.GetCurrentDirectory();
13-
var templatePath = System.IO.Path.Combine(currentDir, "Assets", templateName);
12+
var assemblyPath = System.IO.Path.GetDirectoryName(typeof(VegaHelper).Assembly.Location);
13+
var templatePath = System.IO.Path.Combine(assemblyPath ?? ".", "Assets", templateName);
1414

1515
if (!System.IO.File.Exists(templatePath))
1616
{
17-
throw new FileNotFoundException($"Template file '{templateName}' not found in Assets directory.");
17+
throw new FileNotFoundException($"Template file '{templateName}' not found in Assets directory at '{templatePath}'.");
1818
}
1919

2020
return JObject.Parse(System.IO.File.ReadAllText(templatePath));

0 commit comments

Comments
 (0)