FlowAPI / FlowAPI.Application /Services /AutoLayoutEngine.cs
danylokhodus's picture
feat: Upgrade AutoLayoutEngine to support rich node generation (color, status, progress, tags, subgoals, etc.)
b01b49e
Raw
History Blame Contribute Delete
8.45 kB
using System;
using System.Collections.Generic;
using System.Linq;
using FlowAPI.Application.DTOs.AI;
using FlowAPI.Application.DTOs.Graph;
namespace FlowAPI.Application.Services
{
public static class AutoLayoutEngine
{
private const double StepX = 300.0;
private const double StepY = 180.0;
public static FullGraphResponseDto LayoutGraph(QuestSchemaDto schema, Guid userId)
{
var graphId = Guid.NewGuid();
var response = new FullGraphResponseDto
{
Id = graphId,
UserId = userId,
Title = schema.Title,
Description = schema.Description,
CreatedAt = DateTime.UtcNow,
IsShared = false,
Permission = "Owner",
Nodes = new List<NodeDto>(),
Edges = new List<EdgeDto>()
};
if (schema.Nodes == null || !schema.Nodes.Any())
return response;
// 1. Map string string-based IDs from LLM into Guids
var idMap = new Dictionary<string, Guid>();
foreach (var node in schema.Nodes)
{
idMap[node.Id] = Guid.NewGuid();
}
// 2. Calculate logical layers (Sugiyama-like ranking)
var layerMap = new Dictionary<string, int>();
foreach (var node in schema.Nodes)
{
layerMap[node.Id] = 0; // Default layer
}
// Transitively propagate layer ranks based on dependencies (max N passes)
int n = schema.Nodes.Count;
for (int pass = 0; pass < n; pass++)
{
bool changed = false;
foreach (var node in schema.Nodes)
{
if (!string.IsNullOrEmpty(node.DependsOn) && idMap.ContainsKey(node.DependsOn))
{
int parentLayer = layerMap[node.DependsOn];
int currentLayer = layerMap[node.Id];
if (currentLayer < parentLayer + 1)
{
layerMap[node.Id] = parentLayer + 1;
changed = true;
}
}
}
if (!changed) break; // Optimization if stabilized early
}
// 3. Group nodes by layers and calculate coordinates
var layers = new Dictionary<int, List<QuestNodeDto>>();
foreach (var node in schema.Nodes)
{
int layer = layerMap[node.Id];
if (!layers.ContainsKey(layer))
{
layers[layer] = new List<QuestNodeDto>();
}
layers[layer].Add(node);
}
// Set coordinates symmetrically per layer
foreach (var layerKvp in layers)
{
int layer = layerKvp.Key;
var layerNodes = layerKvp.Value;
int count = layerNodes.Count;
for (int i = 0; i < count; i++)
{
var node = layerNodes[i];
var nodeGuid = idMap[node.Id];
// Symmetric distribution horizontally around X = 0, and sequential progression top-to-bottom
double posX = (i - (count - 1) / 2.0) * StepX;
double posY = layer * StepY;
// Determine visual properties
string? nodeColor = node.Color;
if (string.IsNullOrEmpty(nodeColor) && !string.IsNullOrEmpty(node.Condition))
{
if (node.Condition.Equals("Fail", StringComparison.OrdinalIgnoreCase))
{
nodeColor = "pink";
}
else if (node.Condition.Equals("Success", StringComparison.OrdinalIgnoreCase))
{
nodeColor = "green";
}
}
// Map Status string to integer State Enum value (Todo = 0, In Progress = 1, Review = 2, Done = 3)
int stateVal = 0;
if (!string.IsNullOrEmpty(node.Status))
{
if (node.Status.Equals("Done", StringComparison.OrdinalIgnoreCase)) stateVal = 3;
else if (node.Status.Equals("Review", StringComparison.OrdinalIgnoreCase)) stateVal = 2;
else if (node.Status.Equals("In Progress", StringComparison.OrdinalIgnoreCase) || node.Status.Equals("Progress", StringComparison.OrdinalIgnoreCase)) stateVal = 1;
}
// Build rich data object for serialization into Decision column
var decisionObj = new Dictionary<string, object?>();
decisionObj["decision"] = node.Condition;
decisionObj["description"] = node.Description ?? "";
decisionObj["progress"] = node.Progress ?? 0;
decisionObj["showProgress"] = node.ShowProgress ?? false;
decisionObj["color"] = nodeColor ?? "default";
decisionObj["isPinned"] = node.IsPinned ?? false;
decisionObj["tags"] = node.Tags ?? new List<string>();
decisionObj["status"] = node.Status ?? "Todo";
decisionObj["assignees"] = node.Assignees ?? new List<QuestAssigneeDto>();
if (node.Subgoals != null)
{
decisionObj["subgoals"] = new { total = node.Subgoals.Total, done = node.Subgoals.Done };
}
if (node.Options != null)
{
decisionObj["options"] = node.Options.Select(o => new { id = o.Id, label = o.Label }).ToList();
}
if (node.SelectedOptions != null)
{
decisionObj["selectedOptions"] = node.SelectedOptions;
}
if (node.MultiSelect != null)
{
decisionObj["multiSelect"] = node.MultiSelect.Value;
}
if (node.ResetRule != null)
{
decisionObj["resetRule"] = node.ResetRule;
}
if (!string.IsNullOrEmpty(node.Url))
{
decisionObj["url"] = node.Url;
}
// For image/path/text nodes, save payload inside Data if needed
string? nodeData = null;
if (node.Type == "image" && !string.IsNullOrEmpty(node.Url))
{
nodeData = node.Url;
}
string decisionJson = System.Text.Json.JsonSerializer.Serialize(decisionObj);
response.Nodes.Add(new NodeDto
{
Id = nodeGuid,
Label = node.Label,
PosX = posX,
PosY = posY,
Type = node.Type ?? "sketch",
State = stateVal,
Decision = decisionJson, // JSON string matches frontend expected parsing!
Description = node.Description,
Color = nodeColor,
IsPinned = node.IsPinned ?? false,
Width = node.Width ?? 220,
Height = node.Height ?? 116,
Data = nodeData
});
// 4. Create Edges
if (!string.IsNullOrEmpty(node.DependsOn) && idMap.ContainsKey(node.DependsOn))
{
response.Edges.Add(new EdgeDto
{
Id = Guid.NewGuid(),
FromNodeId = idMap[node.DependsOn],
ToNodeId = nodeGuid,
Condition = node.Condition ?? "True"
});
}
}
}
return response;
}
}
}