From 1eef15792f27a9600c16f94b7da8eddbe2ddd934 Mon Sep 17 00:00:00 2001
From: wnj00524 <68168066+wnj00524@users.noreply.github.com>
Date: Tue, 7 Apr 2026 14:28:43 +0100
Subject: [PATCH] Add in-app network editor
Allow users to create new networks and edit traffic types, nodes, profiles, and edges directly in the WPF application.
---
src/MedWNetworkSim.App/MainWindow.xaml | 238 ++++++-
src/MedWNetworkSim.App/MainWindow.xaml.cs | 45 ++
.../ViewModels/EdgeViewModel.cs | 230 ++++++-
.../ViewModels/MainWindowViewModel.cs | 587 +++++++++++++++---
.../ViewModels/NodeTrafficProfileViewModel.cs | 71 ++-
.../ViewModels/NodeViewModel.cs | 84 ++-
.../TrafficTypeDefinitionEditorViewModel.cs | 65 ++
.../ViewModels/ValueChangedEventArgs.cs | 8 +
8 files changed, 1185 insertions(+), 143 deletions(-)
create mode 100644 src/MedWNetworkSim.App/ViewModels/TrafficTypeDefinitionEditorViewModel.cs
create mode 100644 src/MedWNetworkSim.App/ViewModels/ValueChangedEventArgs.cs
diff --git a/src/MedWNetworkSim.App/MainWindow.xaml b/src/MedWNetworkSim.App/MainWindow.xaml
index 40e6b10..9d0dc25 100644
--- a/src/MedWNetworkSim.App/MainWindow.xaml
+++ b/src/MedWNetworkSim.App/MainWindow.xaml
@@ -6,15 +6,15 @@
xmlns:vm="clr-namespace:MedWNetworkSim.App.ViewModels"
mc:Ignorable="d"
Title="{Binding WindowTitle}"
- Width="1600"
- Height="980"
- MinWidth="1280"
- MinHeight="840">
+ Width="1680"
+ Height="1040"
+ MinWidth="1380"
+ MinHeight="900">
-
+
-
-
+
+
+
@@ -301,7 +311,8 @@
Stroke="{StaticResource EdgeBrush}"
StrokeThickness="3"
StrokeStartLineCap="Round"
- StrokeEndLineCap="Round" />
+ StrokeEndLineCap="Round"
+ Visibility="{Binding EdgeVisibility}" />
+ CornerRadius="14"
+ Visibility="{Binding EdgeVisibility}">
+ Text="Simulation And Editing" />
+ Text="Select a traffic type to filter simulation outputs" />
@@ -523,6 +535,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
id;
+ set
+ {
+ if (!SetProperty(ref id, value))
+ {
+ return;
+ }
+
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public string FromNodeId
+ {
+ get => fromNodeId;
+ set
+ {
+ if (!SetProperty(ref fromNodeId, value))
+ {
+ return;
+ }
+
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
- SourceNode.PropertyChanged += HandleEndpointChanged;
- TargetNode.PropertyChanged += HandleEndpointChanged;
+ public string ToNodeId
+ {
+ get => toNodeId;
+ set
+ {
+ if (!SetProperty(ref toNodeId, value))
+ {
+ return;
+ }
+
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
}
- public EdgeModel Model { get; }
+ public double Time
+ {
+ get => time;
+ set
+ {
+ if (!SetProperty(ref time, value))
+ {
+ return;
+ }
- public NodeViewModel SourceNode { get; }
+ OnPropertyChanged(nameof(TotalCost));
+ OnPropertyChanged(nameof(SummaryLabel));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
- public NodeViewModel TargetNode { get; }
+ public double Cost
+ {
+ get => cost;
+ set
+ {
+ if (!SetProperty(ref cost, value))
+ {
+ return;
+ }
- public double Time => Model.Time;
+ OnPropertyChanged(nameof(TotalCost));
+ OnPropertyChanged(nameof(SummaryLabel));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
- public double Cost => Model.Cost;
+ public double? Capacity
+ {
+ get => capacity;
+ set
+ {
+ if (!SetProperty(ref capacity, value))
+ {
+ return;
+ }
- public double? Capacity => Model.Capacity;
+ OnPropertyChanged(nameof(CapacityLabel));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
- public bool IsBidirectional => Model.IsBidirectional;
+ public bool IsBidirectional
+ {
+ get => isBidirectional;
+ set
+ {
+ if (!SetProperty(ref isBidirectional, value))
+ {
+ return;
+ }
- public string DirectionLabel => IsBidirectional ? "2-way" : "1-way";
+ OnPropertyChanged(nameof(DirectionLabel));
+ OnPropertyChanged(nameof(ArrowVisibility));
+ OnPropertyChanged(nameof(ArrowPoints));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
public double TotalCost => Time + Cost;
+ public string DirectionLabel => IsBidirectional ? "2-way" : "1-way";
+
+ public string SummaryLabel => $"t {Time:0.##} | c {Cost:0.##} | tc {TotalCost:0.##}";
+
+ public string CapacityLabel => Capacity.HasValue
+ ? $"cap {Capacity.Value:0.##}"
+ : "cap inf";
+
+ public Visibility ArrowVisibility => IsBidirectional || !HasValidEndpoints ? Visibility.Collapsed : Visibility.Visible;
+
+ public Visibility EdgeVisibility => HasValidEndpoints ? Visibility.Visible : Visibility.Collapsed;
+
public double X1 => GetSegmentEndpoints().start.X;
public double Y1 => GetSegmentEndpoints().start.Y;
@@ -50,19 +166,11 @@ public EdgeViewModel(EdgeModel model, NodeViewModel sourceNode, NodeViewModel ta
public double LabelTop => ((Y1 + Y2) / 2d) - (LabelHeight / 2d);
- public string SummaryLabel => $"t {Time:0.##} | c {Cost:0.##} | tc {TotalCost:0.##}";
-
- public string CapacityLabel => Capacity.HasValue
- ? $"cap {Capacity.Value:0.##}"
- : "cap inf";
-
- public Visibility ArrowVisibility => IsBidirectional ? Visibility.Collapsed : Visibility.Visible;
-
public string ArrowPoints
{
get
{
- if (IsBidirectional)
+ if (IsBidirectional || !HasValidEndpoints)
{
return string.Empty;
}
@@ -97,27 +205,76 @@ public string ArrowPoints
}
}
+ public void ResolveNodes(IReadOnlyDictionary nodeMap)
+ {
+ nodeMap.TryGetValue(FromNodeId, out var resolvedSourceNode);
+ nodeMap.TryGetValue(ToNodeId, out var resolvedTargetNode);
+ UpdateResolvedNodes(resolvedSourceNode, resolvedTargetNode);
+ }
+
public EdgeModel ToModel()
{
return new EdgeModel
{
- Id = Model.Id,
- FromNodeId = Model.FromNodeId,
- ToNodeId = Model.ToNodeId,
- Time = Model.Time,
- Cost = Model.Cost,
- Capacity = Model.Capacity,
- IsBidirectional = Model.IsBidirectional
+ Id = Id,
+ FromNodeId = FromNodeId,
+ ToNodeId = ToNodeId,
+ Time = Time,
+ Cost = Cost,
+ Capacity = Capacity,
+ IsBidirectional = IsBidirectional
};
}
+ private bool HasValidEndpoints => sourceNode is not null && targetNode is not null;
+
+ private void UpdateResolvedNodes(NodeViewModel? newSourceNode, NodeViewModel? newTargetNode)
+ {
+ if (ReferenceEquals(sourceNode, newSourceNode) && ReferenceEquals(targetNode, newTargetNode))
+ {
+ return;
+ }
+
+ if (sourceNode is not null)
+ {
+ sourceNode.PropertyChanged -= HandleEndpointChanged;
+ }
+
+ if (targetNode is not null)
+ {
+ targetNode.PropertyChanged -= HandleEndpointChanged;
+ }
+
+ sourceNode = newSourceNode;
+ targetNode = newTargetNode;
+
+ if (sourceNode is not null)
+ {
+ sourceNode.PropertyChanged += HandleEndpointChanged;
+ }
+
+ if (targetNode is not null)
+ {
+ targetNode.PropertyChanged += HandleEndpointChanged;
+ }
+
+ OnGeometryChanged();
+ OnPropertyChanged(nameof(EdgeVisibility));
+ OnPropertyChanged(nameof(ArrowVisibility));
+ }
+
private (Point start, Point end) GetSegmentEndpoints()
{
- var source = new Point(SourceNode.CenterX, SourceNode.CenterY);
- var target = new Point(TargetNode.CenterX, TargetNode.CenterY);
+ if (sourceNode is null || targetNode is null)
+ {
+ return (new Point(0d, 0d), new Point(0d, 0d));
+ }
+
+ var source = new Point(sourceNode.CenterX, sourceNode.CenterY);
+ var target = new Point(targetNode.CenterX, targetNode.CenterY);
- var outbound = FindRectangleIntersection(source, target, SourceNode.Width / 2d, SourceNode.Height / 2d);
- var inbound = FindRectangleIntersection(target, source, TargetNode.Width / 2d, TargetNode.Height / 2d);
+ var outbound = FindRectangleIntersection(source, target, sourceNode.Width / 2d, sourceNode.Height / 2d);
+ var inbound = FindRectangleIntersection(target, source, targetNode.Width / 2d, targetNode.Height / 2d);
return (outbound, inbound);
}
@@ -145,6 +302,11 @@ private void HandleEndpointChanged(object? sender, PropertyChangedEventArgs e)
return;
}
+ OnGeometryChanged();
+ }
+
+ private void OnGeometryChanged()
+ {
OnPropertyChanged(nameof(X1));
OnPropertyChanged(nameof(Y1));
OnPropertyChanged(nameof(X2));
diff --git a/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs b/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs
index 1394861..018db49 100644
--- a/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs
+++ b/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.IO;
using System.Text.RegularExpressions;
using MedWNetworkSim.App.Models;
@@ -15,12 +16,15 @@ public sealed class MainWindowViewModel : ObservableObject
private readonly List allAllocations = [];
private readonly List allConsumerCostSummaries = [];
- private NetworkModel currentNetwork = new();
private string activeFileLabel = "Bundled sample";
private string networkName = "MedW Network Simulator";
- private string networkDescription = "Load a JSON network file or use the bundled sample to start modelling routes.";
- private string statusMessage = "Load a network file, move nodes on the canvas, then run the simulation.";
+ private string networkDescription = "Load a JSON network file, or create a new one and edit it directly in the app.";
+ private string statusMessage = "Load a network file or create a new one, then edit nodes and edges directly in the application.";
private TrafficSummaryViewModel? selectedTraffic;
+ private NodeViewModel? selectedNode;
+ private NodeTrafficProfileViewModel? selectedNodeTrafficProfile;
+ private EdgeViewModel? selectedEdge;
+ private TrafficTypeDefinitionEditorViewModel? selectedTrafficDefinition;
private double workspaceWidth = 1600d;
private double workspaceHeight = 1000d;
private bool hasNetwork;
@@ -34,14 +38,22 @@ public MainWindowViewModel()
public ObservableCollection Edges { get; } = [];
+ public ObservableCollection TrafficDefinitions { get; } = [];
+
public ObservableCollection TrafficTypes { get; } = [];
+ public ObservableCollection NodeIdOptions { get; } = [];
+
+ public ObservableCollection TrafficTypeNameOptions { get; } = [];
+
public ObservableCollection VisibleAllocations { get; } = [];
public ObservableCollection VisibleConsumerCostSummaries { get; } = [];
public string WindowTitle => HasNetwork ? $"MedW Network Simulator - {NetworkName}" : "MedW Network Simulator";
+ public Array RoutingPreferences { get; } = Enum.GetValues(typeof(RoutingPreference));
+
public string ActiveFileLabel
{
get => activeFileLabel;
@@ -51,7 +63,7 @@ public string ActiveFileLabel
public string NetworkName
{
get => networkName;
- private set
+ set
{
if (SetProperty(ref networkName, value))
{
@@ -63,7 +75,7 @@ private set
public string NetworkDescription
{
get => networkDescription;
- private set => SetProperty(ref networkDescription, value);
+ set => SetProperty(ref networkDescription, value);
}
public string StatusMessage
@@ -88,7 +100,7 @@ private set
public int EdgeCount => Edges.Count;
- public int TrafficTypeCount => TrafficTypes.Count;
+ public int TrafficTypeCount => TrafficDefinitions.Count;
public double WorkspaceWidth
{
@@ -119,6 +131,38 @@ public TrafficSummaryViewModel? SelectedTraffic
}
}
+ public NodeViewModel? SelectedNode
+ {
+ get => selectedNode;
+ set
+ {
+ if (!SetProperty(ref selectedNode, value))
+ {
+ return;
+ }
+
+ SelectedNodeTrafficProfile = null;
+ }
+ }
+
+ public NodeTrafficProfileViewModel? SelectedNodeTrafficProfile
+ {
+ get => selectedNodeTrafficProfile;
+ set => SetProperty(ref selectedNodeTrafficProfile, value);
+ }
+
+ public EdgeViewModel? SelectedEdge
+ {
+ get => selectedEdge;
+ set => SetProperty(ref selectedEdge, value);
+ }
+
+ public TrafficTypeDefinitionEditorViewModel? SelectedTrafficDefinition
+ {
+ get => selectedTrafficDefinition;
+ set => SetProperty(ref selectedTrafficDefinition, value);
+ }
+
public string VisibleAllocationHeadline => SelectedTraffic is null
? $"{VisibleAllocations.Count} routed movement(s) across all traffic"
: $"{VisibleAllocations.Count} routed movement(s) for {SelectedTraffic.Name}";
@@ -141,6 +185,18 @@ public string SuggestedFileName
}
}
+ public void CreateNewNetwork()
+ {
+ LoadNetwork(
+ new NetworkModel
+ {
+ Name = "New Network",
+ Description = "Describe the network here."
+ },
+ null,
+ "Created a new network. Add traffic types, nodes, and edges in the editor.");
+ }
+
public void LoadFromFile(string path)
{
var network = fileService.Load(path);
@@ -149,7 +205,7 @@ public void LoadFromFile(string path)
public void SaveToFile(string path)
{
- var network = BuildCurrentNetwork();
+ var network = BuildValidatedNetwork();
fileService.Save(network, path);
ActiveFileLabel = path;
StatusMessage = $"Saved the current network to '{Path.GetFileName(path)}'.";
@@ -174,7 +230,7 @@ public void RunSimulation()
return;
}
- var current = BuildCurrentNetwork();
+ var current = BuildValidatedNetwork();
var outcomes = simulationEngine.Simulate(current);
var outcomesByTraffic = outcomes.ToDictionary(outcome => outcome.TrafficType, outcome => outcome, Comparer);
@@ -207,11 +263,157 @@ public void AutoArrangeNodes()
return;
}
- var current = BuildCurrentNetwork();
- var arranged = fileService.AutoArrange(current);
+ var arranged = fileService.AutoArrange(BuildValidatedNetwork());
LoadNetwork(arranged, ActiveFileLabel, "Auto-arranged all node positions.");
}
+ public void AddTrafficDefinition()
+ {
+ EnsureNetworkExists();
+
+ var definition = new TrafficTypeDefinitionEditorViewModel(new TrafficTypeDefinition
+ {
+ Name = GetNextUniqueName("Traffic", TrafficDefinitions.Select(item => item.Name)),
+ RoutingPreference = RoutingPreference.TotalCost
+ });
+
+ RegisterTrafficDefinition(definition);
+ SelectedTrafficDefinition = definition;
+ RefreshDerivedStateAfterStructureChange("Added a new traffic type.");
+ }
+
+ public void RemoveSelectedTrafficDefinition()
+ {
+ if (SelectedTrafficDefinition is null)
+ {
+ return;
+ }
+
+ UnregisterTrafficDefinition(SelectedTrafficDefinition);
+ TrafficDefinitions.Remove(SelectedTrafficDefinition);
+ SelectedTrafficDefinition = null;
+ RefreshDerivedStateAfterStructureChange("Removed the selected traffic type definition.");
+ }
+
+ public void AddNode()
+ {
+ EnsureNetworkExists();
+
+ var nodeIndex = Nodes.Count + 1;
+ var node = new NodeViewModel(new NodeModel
+ {
+ Id = GetNextUniqueName("N", Nodes.Select(item => item.Id)),
+ Name = $"Node {nodeIndex}",
+ X = 220d + ((nodeIndex - 1) % 4 * 220d),
+ Y = 180d + ((nodeIndex - 1) / 4 * 170d)
+ });
+
+ RegisterNode(node);
+ SelectedNode = node;
+ RefreshDerivedStateAfterStructureChange("Added a new node.");
+ }
+
+ public void RemoveSelectedNode()
+ {
+ if (SelectedNode is null)
+ {
+ return;
+ }
+
+ var node = SelectedNode;
+ var edgesToRemove = Edges
+ .Where(edge => Comparer.Equals(edge.FromNodeId, node.Id) || Comparer.Equals(edge.ToNodeId, node.Id))
+ .ToList();
+
+ foreach (var edge in edgesToRemove)
+ {
+ UnregisterEdge(edge);
+ Edges.Remove(edge);
+ }
+
+ UnregisterNode(node);
+ Nodes.Remove(node);
+ SelectedNode = null;
+ SelectedNodeTrafficProfile = null;
+ RefreshDerivedStateAfterStructureChange("Removed the selected node and any connected edges.");
+ }
+
+ public void AddTrafficProfileToSelectedNode()
+ {
+ if (SelectedNode is null)
+ {
+ throw new InvalidOperationException("Select a node before adding a traffic profile.");
+ }
+
+ if (TrafficDefinitions.Count == 0)
+ {
+ AddTrafficDefinition();
+ }
+
+ var trafficName = TrafficTypeNameOptions.FirstOrDefault() ?? "Traffic 1";
+ var profile = new NodeTrafficProfileViewModel(new NodeTrafficProfile
+ {
+ TrafficType = trafficName
+ });
+
+ SelectedNode.AddTrafficProfile(profile);
+ SelectedNodeTrafficProfile = profile;
+ RefreshDerivedStateAfterStructureChange("Added a traffic profile to the selected node.");
+ }
+
+ public void RemoveSelectedTrafficProfileFromNode()
+ {
+ if (SelectedNode is null || SelectedNodeTrafficProfile is null)
+ {
+ return;
+ }
+
+ SelectedNode.RemoveTrafficProfile(SelectedNodeTrafficProfile);
+ SelectedNodeTrafficProfile = null;
+ RefreshDerivedStateAfterStructureChange("Removed the selected traffic profile.");
+ }
+
+ public void AddEdge()
+ {
+ EnsureNetworkExists();
+
+ if (Nodes.Count < 2)
+ {
+ throw new InvalidOperationException("Add at least two nodes before creating an edge.");
+ }
+
+ var edge = new EdgeViewModel(
+ new EdgeModel
+ {
+ Id = GetNextUniqueName("E", Edges.Select(item => item.Id)),
+ FromNodeId = Nodes[0].Id,
+ ToNodeId = Nodes[1].Id,
+ Time = 1d,
+ Cost = 1d,
+ IsBidirectional = true
+ },
+ Nodes[0],
+ Nodes[1]);
+
+ RegisterEdge(edge);
+ SelectedEdge = edge;
+ RefreshDerivedStateAfterStructureChange("Added a new edge.");
+ }
+
+ public void RemoveSelectedEdge()
+ {
+ if (SelectedEdge is null)
+ {
+ return;
+ }
+
+ var edge = SelectedEdge;
+ UnregisterEdge(edge);
+ Edges.Remove(edge);
+ SelectedEdge = null;
+ RefreshDerivedStateAfterStructureChange("Removed the selected edge.");
+ }
+
public void MoveNode(NodeViewModel node, double deltaX, double deltaY)
{
ArgumentNullException.ThrowIfNull(node);
@@ -219,6 +421,14 @@ public void MoveNode(NodeViewModel node, double deltaX, double deltaY)
RecalculateWorkspace();
}
+ private void EnsureNetworkExists()
+ {
+ if (!HasNetwork)
+ {
+ CreateNewNetwork();
+ }
+ }
+
private void LoadBundledSampleIfAvailable()
{
try
@@ -227,90 +437,299 @@ private void LoadBundledSampleIfAvailable()
}
catch
{
- HasNetwork = false;
+ CreateNewNetwork();
}
}
private void LoadNetwork(NetworkModel network, string? activeFilePath, string successMessage)
{
- currentNetwork = network;
+ foreach (var node in Nodes.ToList())
+ {
+ UnregisterNode(node);
+ }
- foreach (var node in Nodes)
+ foreach (var edge in Edges.ToList())
+ {
+ UnregisterEdge(edge);
+ }
+
+ foreach (var definition in TrafficDefinitions.ToList())
{
- node.PositionChanged -= HandleNodePositionChanged;
+ UnregisterTrafficDefinition(definition);
}
Nodes.Clear();
Edges.Clear();
+ TrafficDefinitions.Clear();
TrafficTypes.Clear();
+ NodeIdOptions.Clear();
+ TrafficTypeNameOptions.Clear();
VisibleAllocations.Clear();
VisibleConsumerCostSummaries.Clear();
allAllocations.Clear();
allConsumerCostSummaries.Clear();
SelectedTraffic = null;
+ SelectedNode = null;
+ SelectedNodeTrafficProfile = null;
+ SelectedEdge = null;
+ SelectedTrafficDefinition = null;
- var nodeMap = new Dictionary(Comparer);
+ foreach (var definition in BuildDefinitionEditors(network))
+ {
+ RegisterTrafficDefinition(definition);
+ }
foreach (var nodeModel in network.Nodes)
{
- var nodeViewModel = new NodeViewModel(nodeModel);
- nodeViewModel.PositionChanged += HandleNodePositionChanged;
- Nodes.Add(nodeViewModel);
- nodeMap[nodeModel.Id] = nodeViewModel;
+ RegisterNode(new NodeViewModel(nodeModel));
}
+ RefreshNodeIdOptions();
+
+ var nodeMap = CreateNodeMap();
foreach (var edgeModel in network.Edges)
{
- if (!nodeMap.TryGetValue(edgeModel.FromNodeId, out var sourceNode) ||
- !nodeMap.TryGetValue(edgeModel.ToNodeId, out var targetNode))
+ nodeMap.TryGetValue(edgeModel.FromNodeId, out var sourceNode);
+ nodeMap.TryGetValue(edgeModel.ToNodeId, out var targetNode);
+ RegisterEdge(new EdgeViewModel(edgeModel, sourceNode, targetNode));
+ }
+
+ NetworkName = network.Name;
+ NetworkDescription = string.IsNullOrWhiteSpace(network.Description)
+ ? string.Empty
+ : network.Description;
+ ActiveFileLabel = activeFilePath ?? "Unsaved network";
+ HasNetwork = true;
+ RefreshNodeIdOptions();
+ RefreshTrafficTypeNameOptions();
+ RefreshTrafficSummariesFromCurrentState();
+ RecalculateWorkspace();
+ RefreshCounts();
+ StatusMessage = successMessage;
+ }
+
+ private IReadOnlyList BuildDefinitionEditors(NetworkModel network)
+ {
+ var definitionsByName = network.TrafficTypes
+ .Where(definition => !string.IsNullOrWhiteSpace(definition.Name))
+ .GroupBy(definition => definition.Name, Comparer)
+ .ToDictionary(group => group.Key, group => group.First(), Comparer);
+ var orderedTrafficNames = network.TrafficTypes
+ .Select(definition => definition.Name)
+ .Concat(network.Nodes.SelectMany(node => node.TrafficProfiles).Select(profile => profile.TrafficType))
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Distinct(Comparer)
+ .ToList();
+
+ return orderedTrafficNames
+ .Select(name =>
{
- continue;
+ definitionsByName.TryGetValue(name, out var definition);
+ return new TrafficTypeDefinitionEditorViewModel(definition ?? new TrafficTypeDefinition
+ {
+ Name = name,
+ RoutingPreference = RoutingPreference.TotalCost
+ });
+ })
+ .ToList();
+ }
+
+ private NetworkModel BuildValidatedNetwork()
+ {
+ return fileService.NormalizeAndValidate(BuildNetworkSnapshot());
+ }
+
+ private NetworkModel BuildNetworkSnapshot()
+ {
+ return new NetworkModel
+ {
+ Name = NetworkName,
+ Description = NetworkDescription,
+ TrafficTypes = TrafficDefinitions.Select(definition => definition.ToModel()).ToList(),
+ Nodes = Nodes.Select(node => node.ToModel()).ToList(),
+ Edges = Edges.Select(edge => edge.ToModel()).ToList()
+ };
+ }
+
+ private void RegisterNode(NodeViewModel node)
+ {
+ node.PositionChanged += HandleNodePositionChanged;
+ node.DefinitionChanged += HandleNodeDefinitionChanged;
+ node.IdChanged += HandleNodeIdChanged;
+ Nodes.Add(node);
+ }
+
+ private void UnregisterNode(NodeViewModel node)
+ {
+ node.PositionChanged -= HandleNodePositionChanged;
+ node.DefinitionChanged -= HandleNodeDefinitionChanged;
+ node.IdChanged -= HandleNodeIdChanged;
+ }
+
+ private void RegisterEdge(EdgeViewModel edge)
+ {
+ edge.DefinitionChanged += HandleEdgeDefinitionChanged;
+ Edges.Add(edge);
+ }
+
+ private void UnregisterEdge(EdgeViewModel edge)
+ {
+ edge.DefinitionChanged -= HandleEdgeDefinitionChanged;
+ }
+
+ private void RegisterTrafficDefinition(TrafficTypeDefinitionEditorViewModel definition)
+ {
+ definition.PropertyChanged += HandleTrafficDefinitionPropertyChanged;
+ definition.NameChanged += HandleTrafficDefinitionNameChanged;
+ TrafficDefinitions.Add(definition);
+ }
+
+ private void UnregisterTrafficDefinition(TrafficTypeDefinitionEditorViewModel definition)
+ {
+ definition.PropertyChanged -= HandleTrafficDefinitionPropertyChanged;
+ definition.NameChanged -= HandleTrafficDefinitionNameChanged;
+ }
+
+ private void HandleNodePositionChanged(object? sender, EventArgs e)
+ {
+ RecalculateWorkspace();
+ }
+
+ private void HandleNodeDefinitionChanged(object? sender, EventArgs e)
+ {
+ RefreshDerivedStateAfterStructureChange("Updated node data.");
+ }
+
+ private void HandleNodeIdChanged(object? sender, ValueChangedEventArgs e)
+ {
+ foreach (var edge in Edges)
+ {
+ if (Comparer.Equals(edge.FromNodeId, e.OldValue))
+ {
+ edge.FromNodeId = e.NewValue;
+ }
+
+ if (Comparer.Equals(edge.ToNodeId, e.OldValue))
+ {
+ edge.ToNodeId = e.NewValue;
}
+ }
- Edges.Add(new EdgeViewModel(edgeModel, sourceNode, targetNode));
+ RefreshDerivedStateAfterStructureChange("Updated node identifiers and any connected edges.");
+ }
+
+ private void HandleEdgeDefinitionChanged(object? sender, EventArgs e)
+ {
+ RefreshDerivedStateAfterStructureChange("Updated edge data.");
+ }
+
+ private void HandleTrafficDefinitionPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(TrafficTypeDefinitionEditorViewModel.Name))
+ {
+ return;
}
- foreach (var summary in BuildTrafficSummaries(network))
+ RefreshDerivedStateAfterStructureChange("Updated traffic type settings.");
+ }
+
+ private void HandleTrafficDefinitionNameChanged(object? sender, ValueChangedEventArgs e)
+ {
+ foreach (var node in Nodes)
{
- TrafficTypes.Add(summary);
+ foreach (var profile in node.TrafficProfiles.Where(profile => Comparer.Equals(profile.TrafficType, e.OldValue)))
+ {
+ profile.TrafficType = e.NewValue;
+ }
}
- NetworkName = network.Name;
- NetworkDescription = string.IsNullOrWhiteSpace(network.Description)
- ? "No description was provided in the source file."
- : network.Description;
- ActiveFileLabel = activeFilePath ?? "Unsaved network";
- HasNetwork = true;
+ RefreshDerivedStateAfterStructureChange("Renamed a traffic type and updated matching node profiles.");
+ }
+
+ private void RefreshDerivedStateAfterStructureChange(string message)
+ {
+ RefreshNodeIdOptions();
+ RefreshTrafficTypeNameOptions();
+ RefreshEdgeBindings();
+ RefreshTrafficSummariesFromCurrentState();
RecalculateWorkspace();
+ RefreshCounts();
+ InvalidateSimulationResults(message);
+ }
+
+ private void RefreshCounts()
+ {
OnPropertyChanged(nameof(NodeCount));
OnPropertyChanged(nameof(EdgeCount));
OnPropertyChanged(nameof(TrafficTypeCount));
- StatusMessage = successMessage;
}
- private List BuildTrafficSummaries(NetworkModel network)
+ private void RefreshNodeIdOptions()
+ {
+ SynchronizeCollection(NodeIdOptions, Nodes.Select(node => node.Id).OrderBy(id => id, Comparer));
+ }
+
+ private void RefreshTrafficTypeNameOptions()
{
- var definitionsByTraffic = network.TrafficTypes
- .ToDictionary(definition => definition.Name, definition => definition, Comparer);
+ var trafficTypeNames = TrafficDefinitions
+ .Select(definition => definition.Name)
+ .Concat(Nodes.SelectMany(node => node.TrafficProfiles).Select(profile => profile.TrafficType))
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Distinct(Comparer)
+ .OrderBy(name => name, Comparer);
- var summaries = new List();
+ SynchronizeCollection(TrafficTypeNameOptions, trafficTypeNames);
+ }
- foreach (var trafficName in GetOrderedTrafficNames(network))
+ private void RefreshEdgeBindings()
+ {
+ var nodeMap = CreateNodeMap();
+ foreach (var edge in Edges)
+ {
+ edge.ResolveNodes(nodeMap);
+ }
+ }
+
+ private Dictionary CreateNodeMap()
+ {
+ var nodeMap = new Dictionary(Comparer);
+
+ foreach (var node in Nodes)
+ {
+ if (!string.IsNullOrWhiteSpace(node.Id) && !nodeMap.ContainsKey(node.Id))
+ {
+ nodeMap[node.Id] = node;
+ }
+ }
+
+ return nodeMap;
+ }
+
+ private void RefreshTrafficSummariesFromCurrentState()
+ {
+ TrafficTypes.Clear();
+
+ var definitionsByTraffic = TrafficDefinitions
+ .Where(definition => !string.IsNullOrWhiteSpace(definition.Name))
+ .GroupBy(definition => definition.Name, Comparer)
+ .ToDictionary(group => group.Key, group => group.First(), Comparer);
+
+ foreach (var trafficName in GetOrderedTrafficNames())
{
- var profiles = network.Nodes
+ var profiles = Nodes
.Select(node => node.TrafficProfiles.FirstOrDefault(profile => Comparer.Equals(profile.TrafficType, trafficName)))
.Where(profile => profile is not null)
- .Cast()
+ .Cast()
.ToList();
var definition = definitionsByTraffic.GetValueOrDefault(trafficName)
- ?? new TrafficTypeDefinition
+ ?? new TrafficTypeDefinitionEditorViewModel(new TrafficTypeDefinition
{
Name = trafficName,
RoutingPreference = RoutingPreference.TotalCost
- };
+ });
- summaries.Add(new TrafficSummaryViewModel(
+ TrafficTypes.Add(new TrafficSummaryViewModel(
trafficName,
definition.RoutingPreference,
profiles.Sum(profile => profile.Production),
@@ -319,16 +738,14 @@ private List BuildTrafficSummaries(NetworkModel network
profiles.Count(profile => profile.Consumption > 0),
profiles.Count(profile => profile.CanTransship)));
}
-
- return summaries;
}
- private IEnumerable GetOrderedTrafficNames(NetworkModel network)
+ private IEnumerable GetOrderedTrafficNames()
{
var orderedNames = new List();
var seen = new HashSet(Comparer);
- foreach (var definition in network.TrafficTypes)
+ foreach (var definition in TrafficDefinitions)
{
if (!string.IsNullOrWhiteSpace(definition.Name) && seen.Add(definition.Name))
{
@@ -336,44 +753,15 @@ private IEnumerable GetOrderedTrafficNames(NetworkModel network)
}
}
- var undeclaredNames = network.Nodes
+ var profileNames = Nodes
.SelectMany(node => node.TrafficProfiles)
.Select(profile => profile.TrafficType)
- .Where(name => !string.IsNullOrWhiteSpace(name) && !seen.Contains(name))
- .Distinct(Comparer)
- .OrderBy(name => name, Comparer);
+ .Where(name => !string.IsNullOrWhiteSpace(name) && seen.Add(name));
- orderedNames.AddRange(undeclaredNames);
+ orderedNames.AddRange(profileNames);
return orderedNames;
}
- private NetworkModel BuildCurrentNetwork()
- {
- currentNetwork = fileService.NormalizeAndValidate(new NetworkModel
- {
- Name = NetworkName,
- Description = currentNetwork.Description,
- TrafficTypes = currentNetwork.TrafficTypes
- .Select(definition => new TrafficTypeDefinition
- {
- Name = definition.Name,
- Description = definition.Description,
- RoutingPreference = definition.RoutingPreference,
- CapacityBidPerUnit = definition.CapacityBidPerUnit
- })
- .ToList(),
- Nodes = Nodes.Select(node => node.ToModel()).ToList(),
- Edges = Edges.Select(edge => edge.ToModel()).ToList()
- });
-
- return currentNetwork;
- }
-
- private void HandleNodePositionChanged(object? sender, EventArgs e)
- {
- RecalculateWorkspace();
- }
-
private void RecalculateWorkspace()
{
if (Nodes.Count == 0)
@@ -390,6 +778,23 @@ private void RecalculateWorkspace()
WorkspaceHeight = Math.Max(900d, maxY + 180d);
}
+ private void InvalidateSimulationResults(string message)
+ {
+ allAllocations.Clear();
+ allConsumerCostSummaries.Clear();
+ VisibleAllocations.Clear();
+ VisibleConsumerCostSummaries.Clear();
+
+ foreach (var traffic in TrafficTypes)
+ {
+ traffic.ClearOutcome();
+ }
+
+ OnPropertyChanged(nameof(VisibleAllocationHeadline));
+ OnPropertyChanged(nameof(VisibleConsumerCostHeadline));
+ StatusMessage = message;
+ }
+
private void RefreshVisibleAllocations()
{
VisibleAllocations.Clear();
@@ -421,4 +826,30 @@ private void RefreshVisibleConsumerCostSummaries()
OnPropertyChanged(nameof(VisibleConsumerCostHeadline));
}
+
+ private static void SynchronizeCollection(ObservableCollection target, IEnumerable values)
+ {
+ target.Clear();
+ foreach (var value in values)
+ {
+ target.Add(value);
+ }
+ }
+
+ private static string GetNextUniqueName(string prefix, IEnumerable existingNames)
+ {
+ var existing = new HashSet(existingNames.Where(name => !string.IsNullOrWhiteSpace(name)), Comparer);
+ var index = 1;
+
+ while (true)
+ {
+ var candidate = $"{prefix}{index}";
+ if (!existing.Contains(candidate))
+ {
+ return candidate;
+ }
+
+ index++;
+ }
+ }
}
diff --git a/src/MedWNetworkSim.App/ViewModels/NodeTrafficProfileViewModel.cs b/src/MedWNetworkSim.App/ViewModels/NodeTrafficProfileViewModel.cs
index 860d393..c9d2e4f 100644
--- a/src/MedWNetworkSim.App/ViewModels/NodeTrafficProfileViewModel.cs
+++ b/src/MedWNetworkSim.App/ViewModels/NodeTrafficProfileViewModel.cs
@@ -2,15 +2,76 @@
namespace MedWNetworkSim.App.ViewModels;
-public sealed class NodeTrafficProfileViewModel(NodeTrafficProfile profile) : ObservableObject
+public sealed class NodeTrafficProfileViewModel : ObservableObject
{
- public string TrafficType { get; } = profile.TrafficType;
+ private string trafficType;
+ private double production;
+ private double consumption;
+ private bool canTransship;
- public double Production { get; } = profile.Production;
+ public NodeTrafficProfileViewModel(NodeTrafficProfile profile)
+ {
+ trafficType = profile.TrafficType;
+ production = profile.Production;
+ consumption = profile.Consumption;
+ canTransship = profile.CanTransship;
+ }
+
+ public string TrafficType
+ {
+ get => trafficType;
+ set
+ {
+ if (!SetProperty(ref trafficType, value))
+ {
+ return;
+ }
+
+ OnPropertyChanged(nameof(RoleSummary));
+ }
+ }
+
+ public double Production
+ {
+ get => production;
+ set
+ {
+ if (!SetProperty(ref production, value))
+ {
+ return;
+ }
+
+ OnPropertyChanged(nameof(RoleSummary));
+ }
+ }
+
+ public double Consumption
+ {
+ get => consumption;
+ set
+ {
+ if (!SetProperty(ref consumption, value))
+ {
+ return;
+ }
+
+ OnPropertyChanged(nameof(RoleSummary));
+ }
+ }
- public double Consumption { get; } = profile.Consumption;
+ public bool CanTransship
+ {
+ get => canTransship;
+ set
+ {
+ if (!SetProperty(ref canTransship, value))
+ {
+ return;
+ }
- public bool CanTransship { get; } = profile.CanTransship;
+ OnPropertyChanged(nameof(RoleSummary));
+ }
+ }
public string RoleSummary
{
diff --git a/src/MedWNetworkSim.App/ViewModels/NodeViewModel.cs b/src/MedWNetworkSim.App/ViewModels/NodeViewModel.cs
index 1f201cf..7af7450 100644
--- a/src/MedWNetworkSim.App/ViewModels/NodeViewModel.cs
+++ b/src/MedWNetworkSim.App/ViewModels/NodeViewModel.cs
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using MedWNetworkSim.App.Models;
namespace MedWNetworkSim.App.ViewModels;
@@ -8,24 +9,62 @@ public sealed class NodeViewModel : ObservableObject
public const double DefaultWidth = 176d;
public const double DefaultHeight = 118d;
+ private string id;
+ private string name;
private double x;
private double y;
public NodeViewModel(NodeModel model)
{
- Id = model.Id;
- Name = model.Name;
+ id = model.Id;
+ name = model.Name;
x = model.X ?? 0d;
y = model.Y ?? 0d;
TrafficProfiles = new ObservableCollection(
model.TrafficProfiles.Select(profile => new NodeTrafficProfileViewModel(profile)));
+ TrafficProfiles.CollectionChanged += HandleTrafficProfilesChanged;
+
+ foreach (var profile in TrafficProfiles)
+ {
+ profile.PropertyChanged += HandleTrafficProfilePropertyChanged;
+ }
}
public event EventHandler? PositionChanged;
- public string Id { get; }
+ public event EventHandler? DefinitionChanged;
+
+ public event EventHandler>? IdChanged;
+
+ public string Id
+ {
+ get => id;
+ set
+ {
+ var oldValue = id;
+ if (!SetProperty(ref id, value))
+ {
+ return;
+ }
+
+ IdChanged?.Invoke(this, new ValueChangedEventArgs(oldValue, value));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
- public string Name { get; }
+ public string Name
+ {
+ get => name;
+ set
+ {
+ if (!SetProperty(ref name, value))
+ {
+ return;
+ }
+
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
public double Width => DefaultWidth;
@@ -34,7 +73,7 @@ public NodeViewModel(NodeModel model)
public double X
{
get => x;
- private set
+ set
{
if (!SetProperty(ref x, value))
{
@@ -50,7 +89,7 @@ private set
public double Y
{
get => y;
- private set
+ set
{
if (!SetProperty(ref y, value))
{
@@ -83,6 +122,16 @@ private set
Environment.NewLine,
TrafficProfiles.Select(profile => $"{profile.TrafficType}: {profile.RoleSummary}"));
+ public void AddTrafficProfile(NodeTrafficProfileViewModel profile)
+ {
+ TrafficProfiles.Add(profile);
+ }
+
+ public void RemoveTrafficProfile(NodeTrafficProfileViewModel profile)
+ {
+ TrafficProfiles.Remove(profile);
+ }
+
public void MoveBy(double deltaX, double deltaY)
{
X = Math.Max(Width / 2d, X + deltaX);
@@ -108,4 +157,27 @@ public NodeModel ToModel()
.ToList()
};
}
+
+ private void HandleTrafficProfilesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ foreach (var profile in e.NewItems?.OfType() ?? [])
+ {
+ profile.PropertyChanged += HandleTrafficProfilePropertyChanged;
+ }
+
+ foreach (var profile in e.OldItems?.OfType() ?? [])
+ {
+ profile.PropertyChanged -= HandleTrafficProfilePropertyChanged;
+ }
+
+ OnPropertyChanged(nameof(TrafficProfileCountLabel));
+ OnPropertyChanged(nameof(FullTrafficSummary));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void HandleTrafficProfilePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ OnPropertyChanged(nameof(FullTrafficSummary));
+ DefinitionChanged?.Invoke(this, EventArgs.Empty);
+ }
}
diff --git a/src/MedWNetworkSim.App/ViewModels/TrafficTypeDefinitionEditorViewModel.cs b/src/MedWNetworkSim.App/ViewModels/TrafficTypeDefinitionEditorViewModel.cs
new file mode 100644
index 0000000..778cb6a
--- /dev/null
+++ b/src/MedWNetworkSim.App/ViewModels/TrafficTypeDefinitionEditorViewModel.cs
@@ -0,0 +1,65 @@
+using MedWNetworkSim.App.Models;
+
+namespace MedWNetworkSim.App.ViewModels;
+
+public sealed class TrafficTypeDefinitionEditorViewModel : ObservableObject
+{
+ private string name;
+ private string description;
+ private RoutingPreference routingPreference;
+ private double? capacityBidPerUnit;
+
+ public TrafficTypeDefinitionEditorViewModel(TrafficTypeDefinition definition)
+ {
+ name = definition.Name;
+ description = definition.Description;
+ routingPreference = definition.RoutingPreference;
+ capacityBidPerUnit = definition.CapacityBidPerUnit;
+ }
+
+ public event EventHandler>? NameChanged;
+
+ public string Name
+ {
+ get => name;
+ set
+ {
+ var oldValue = name;
+ if (!SetProperty(ref name, value))
+ {
+ return;
+ }
+
+ NameChanged?.Invoke(this, new ValueChangedEventArgs(oldValue, value));
+ }
+ }
+
+ public string Description
+ {
+ get => description;
+ set => SetProperty(ref description, value);
+ }
+
+ public RoutingPreference RoutingPreference
+ {
+ get => routingPreference;
+ set => SetProperty(ref routingPreference, value);
+ }
+
+ public double? CapacityBidPerUnit
+ {
+ get => capacityBidPerUnit;
+ set => SetProperty(ref capacityBidPerUnit, value);
+ }
+
+ public TrafficTypeDefinition ToModel()
+ {
+ return new TrafficTypeDefinition
+ {
+ Name = Name,
+ Description = Description,
+ RoutingPreference = RoutingPreference,
+ CapacityBidPerUnit = CapacityBidPerUnit
+ };
+ }
+}
diff --git a/src/MedWNetworkSim.App/ViewModels/ValueChangedEventArgs.cs b/src/MedWNetworkSim.App/ViewModels/ValueChangedEventArgs.cs
new file mode 100644
index 0000000..3b38012
--- /dev/null
+++ b/src/MedWNetworkSim.App/ViewModels/ValueChangedEventArgs.cs
@@ -0,0 +1,8 @@
+namespace MedWNetworkSim.App.ViewModels;
+
+public sealed class ValueChangedEventArgs(T oldValue, T newValue) : EventArgs
+{
+ public T OldValue { get; } = oldValue;
+
+ public T NewValue { get; } = newValue;
+}