using System.Globalization; using System.Net; using System.Text; using Microsoft.Msagl.Core.Geometry.Curves; using Microsoft.Msagl.Drawing; static class RichSvgRenderer { public static string Render(LayoutReport report, Graph graph) { var theme = Theme.For(report.GraphKey); var degreeByNodeId = CalculateDegrees(graph); var rawNodes = BuildRawNodes(report, graph, degreeByNodeId); var projection = Projection.Create(report, rawNodes); var projectedNodes = rawNodes .Select(node => ProjectedNode.Create(node, projection, theme)) .ToArray(); var projectedLookup = projectedNodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase); var canvas = CanvasLayout.Create(projectedNodes); var document = new StringBuilder(); document.AppendLine(""""""); document.AppendLine( $""""""); AppendDefs(document, theme); document.AppendLine($""" {Escape(report.GraphTitle)} ({Escape(report.GraphKey)})"""); document.AppendLine( $""" Norn rich renderer output for {Escape(report.GraphKey)} using {Escape(report.LayoutMode)} layout with {report.NodeCount} nodes and {report.EdgeCount} edges."""); document.AppendLine($""" """); document.AppendLine($""" """); AppendGuides(document, report, rawNodes, projection, canvas, theme); AppendHeader(document, report, canvas, theme); AppendEdges(document, graph, projectedLookup, projection, canvas, theme); AppendNodes(document, projectedNodes, canvas, theme); AppendOverlay(document, report, canvas, theme); document.AppendLine(""); return document.ToString(); } private static IReadOnlyList BuildRawNodes( LayoutReport report, Graph graph, IReadOnlyDictionary degreeByNodeId) { var reportNodes = report.Nodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase); return graph.Nodes .Select(node => { var record = reportNodes[node.Id]; var center = node.BoundingBox.Center; var category = SemanticCategoryClassifier.Classify(report.GraphKey, record.Title, record.Role, record.IsEntry); return new RawNode( Id: record.Id, Title: record.Title, Depth: record.Depth, Role: record.Role, IsEntry: record.IsEntry, Degree: degreeByNodeId.GetValueOrDefault(record.Id), Category: category, Token: CreateToken(record.Title, record.Role, record.IsEntry), RawX: center.X, RawY: center.Y); }) .OrderBy(node => node.Depth) .ThenBy(node => node.Id, StringComparer.OrdinalIgnoreCase) .ToArray(); } private static void AppendDefs(StringBuilder document, Theme theme) { document.AppendLine(" "); document.AppendLine(""" """); document.AppendLine($""" """); document.AppendLine($""" """); document.AppendLine($""" """); document.AppendLine(" "); document.AppendLine(""" """); document.AppendLine(""" """); document.AppendLine(""" """); document.AppendLine(" "); document.AppendLine(""" """); document.AppendLine($""" """); document.AppendLine(" "); document.AppendLine(""" """); document.AppendLine(""" """); document.AppendLine(""" """); document.AppendLine(" "); document.AppendLine($""" """); document.AppendLine($""" """); document.AppendLine(" "); document.AppendLine($""" """); document.AppendLine(""" """); document.AppendLine(" "); document.AppendLine(" "); } private static void AppendGuides( StringBuilder document, LayoutReport report, IReadOnlyList rawNodes, Projection projection, CanvasLayout canvas, Theme theme) { switch (projection.Kind) { case ProjectionKind.Arc: AppendArcGuides(document, rawNodes, projection, canvas, theme); break; default: AppendCartesianGuides(document, rawNodes, projection, canvas, theme); break; } } private static void AppendArcGuides( StringBuilder document, IReadOnlyList rawNodes, Projection projection, CanvasLayout canvas, Theme theme) { var depthGroups = rawNodes .Where(node => node.Depth >= 0) .GroupBy(node => node.Depth) .OrderBy(group => group.Key) .ToArray(); if (depthGroups.Length == 0) { return; } document.AppendLine(""" """); foreach (var group in depthGroups) { var averageY = group.Average(node => node.RawY); var radius = projection.GetArcRadius(averageY); var path = BuildArcGuidePath(projection, radius, canvas); var color = theme.GetAccent(group.First().Category, false); var labelPoint = projection.ProjectArcLabel(radius); var labelCanvas = canvas.Transform(labelPoint); document.AppendLine( $""" """); document.AppendLine( $""" DEPTH {group.Key}"""); } document.AppendLine(" "); } private static void AppendCartesianGuides( StringBuilder document, IReadOnlyList rawNodes, Projection projection, CanvasLayout canvas, Theme theme) { var depthGroups = rawNodes .Where(node => node.Depth >= 0) .GroupBy(node => node.Depth) .OrderBy(group => group.Key) .ToArray(); if (depthGroups.Length == 0) { return; } document.AppendLine(""" """); foreach (var group in depthGroups) { var averageY = group.Average(node => node.RawY); var lineStart = canvas.Transform(projection.Project(new Microsoft.Msagl.Core.Geometry.Point(projection.RawMinX, averageY))); var lineEnd = canvas.Transform(projection.Project(new Microsoft.Msagl.Core.Geometry.Point(projection.RawMaxX, averageY))); var color = theme.GetAccent(group.First().Category, false); document.AppendLine( $""" """); document.AppendLine( $""" DEPTH {group.Key}"""); } document.AppendLine(" "); } private static string BuildArcGuidePath(Projection projection, double radius, CanvasLayout canvas) { var points = Enumerable.Range(0, 40) .Select(index => { var t = index / 39.0; var point = projection.ProjectArcPoint(radius, t); return canvas.Transform(point); }) .ToArray(); return BuildPolylinePath(points); } private static void AppendHeader(StringBuilder document, LayoutReport report, CanvasLayout canvas, Theme theme) { var subtitle = $"{report.LayoutMode.ToUpperInvariant()} layout | rich renderer | {report.NodeCount} nodes | {report.EdgeCount} edges"; document.AppendLine(""" """); document.AppendLine( $""" """); document.AppendLine( $""" {Escape(report.GraphTitle)}"""); document.AppendLine( $""" {Escape(subtitle)}"""); var pills = new[] { ("GRAPH", report.GraphKey.ToUpperInvariant()), ("LAYOUT", report.LayoutMode.ToUpperInvariant()), ("RENDER", "RICH"), }; var pillX = canvas.Width - 24; for (var i = pills.Length - 1; i >= 0; i--) { var width = 110.0; pillX -= width; document.AppendLine( $""" """); document.AppendLine( $""" {Escape(pills[i].Item2)}"""); pillX -= 10; } document.AppendLine(" "); } private static void AppendEdges( StringBuilder document, Graph graph, IReadOnlyDictionary projectedLookup, Projection projection, CanvasLayout canvas, Theme theme) { document.AppendLine(""" """); foreach (var edge in graph.Edges) { if (!projectedLookup.TryGetValue(edge.SourceNode.Id, out var source) || !projectedLookup.TryGetValue(edge.TargetNode.Id, out var target)) { continue; } var isMuted = source.Role == GraphPartitionNodeRole.Anchor.ToApiValue() || target.Role == GraphPartitionNodeRole.Anchor.ToApiValue(); var sampledPoints = SampleEdge(edge.EdgeCurve ?? edge.GeometryEdge?.Curve, source, target); var canvasPoints = sampledPoints .Select(point => canvas.Transform(projection.Project(point))) .ToArray(); if (canvasPoints.Length < 2) { continue; } document.AppendLine( $""" """); } document.AppendLine(" "); } private static void AppendNodes( StringBuilder document, IReadOnlyList projectedNodes, CanvasLayout canvas, Theme theme) { document.AppendLine(""" """); foreach (var node in projectedNodes.OrderBy(node => node.IsEntry ? 1 : 0).ThenBy(node => node.Role == GraphPartitionNodeRole.Anchor.ToApiValue() ? 0 : 1)) { var center = canvas.Transform(node.LocalCenter); var labelX = center.X - node.LabelWidth / 2.0; var labelY = center.Y + node.Radius + 9; var badgeCx = center.X + node.Radius * 0.78; var badgeCy = center.Y - node.Radius * 0.78; document.AppendLine( $""" """); if (node.IsEntry) { document.AppendLine( $""" """); } if (node.Role == GraphPartitionNodeRole.Anchor.ToApiValue()) { var diamond = BuildDiamond(center.X, center.Y, node.Radius + 1.5); document.AppendLine( $""" """); } else { document.AppendLine( $""" """); } document.AppendLine( $""" """); document.AppendLine( $""" {Escape(node.Token)}"""); document.AppendLine( $""" """); for (var i = 0; i < node.LabelLines.Count; i++) { document.AppendLine( $""" {Escape(node.LabelLines[i])}"""); } if (node.ShowBadge) { document.AppendLine( $""" """); document.AppendLine( $""" {node.Degree}"""); } document.AppendLine(" "); } document.AppendLine(" "); } private static void AppendOverlay(StringBuilder document, LayoutReport report, CanvasLayout canvas, Theme theme) { var note = report.GraphKey.Equals("source-tree", StringComparison.OrdinalIgnoreCase) ? "Arc projection over MSAGL coordinates. More map, less paperwork." : "Compact glyph rendering over MSAGL coordinates. Still not magic, just less beige."; document.AppendLine(""" """); document.AppendLine( $""" {Escape(note)}"""); document.AppendLine(" "); } private static IReadOnlyList SampleEdge(ICurve? curve, ProjectedNode source, ProjectedNode target) { if (curve is null) { return [ new Microsoft.Msagl.Core.Geometry.Point(source.RawCenter.X, source.RawCenter.Y), new Microsoft.Msagl.Core.Geometry.Point(target.RawCenter.X, target.RawCenter.Y) ]; } var sampleCount = Math.Clamp((int)Math.Ceiling(curve.Length / 24.0), 10, 84); return Enumerable.Range(0, sampleCount + 1) .Select(index => curve[curve.ParStart + ((curve.ParEnd - curve.ParStart) * index / sampleCount)]) .ToArray(); } private static IReadOnlyDictionary CalculateDegrees(Graph graph) { var degrees = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var node in graph.Nodes) { degrees[node.Id] = 0; } foreach (var edge in graph.Edges) { degrees[edge.SourceNode.Id] = degrees.GetValueOrDefault(edge.SourceNode.Id) + 1; degrees[edge.TargetNode.Id] = degrees.GetValueOrDefault(edge.TargetNode.Id) + 1; } return degrees; } private static string CreateToken(string title, string role, bool isEntry) { if (isEntry) { return "E"; } if (role == GraphPartitionNodeRole.Anchor.ToApiValue()) { return "A"; } var parts = title .Split([' ', '/', '-', '_'], StringSplitOptions.RemoveEmptyEntries) .Where(part => !StopWords.Contains(part)) .Select(part => part.Trim()) .Where(part => part.Length > 0) .ToArray(); if (parts.Length == 0) { return "N"; } if (parts.Length == 1) { return parts[0].Length >= 2 ? parts[0][..2].ToUpperInvariant() : parts[0].ToUpperInvariant(); } return $"{char.ToUpperInvariant(parts[0][0])}{char.ToUpperInvariant(parts[1][0])}"; } private static IReadOnlyList WrapLabel(string title, int maxChars, int maxLines) { if (string.IsNullOrWhiteSpace(title)) { return ["Untitled"]; } var tokens = title.Split(' ', StringSplitOptions.RemoveEmptyEntries); var lines = new List(); var current = new StringBuilder(); foreach (var token in tokens) { var candidateLength = current.Length == 0 ? token.Length : current.Length + 1 + token.Length; if (candidateLength > maxChars && current.Length > 0) { lines.Add(current.ToString()); current.Clear(); } if (current.Length > 0) { current.Append(' '); } current.Append(token); if (lines.Count == maxLines) { break; } } if (current.Length > 0 && lines.Count < maxLines) { lines.Add(current.ToString()); } if (lines.Count == 0) { lines.Add(title); } if (lines.Count == maxLines && tokens.Length > 0) { var lastLine = lines[^1]; if (lastLine.Length > maxChars - 3) { lines[^1] = $"{lastLine[..Math.Max(1, maxChars - 3)]}..."; } } return lines; } private static string BuildPolylinePath(IReadOnlyList<(double X, double Y)> points) { if (points.Count == 0) { return string.Empty; } var builder = new StringBuilder(); builder.Append($"M {Fmt(points[0].X)} {Fmt(points[0].Y)}"); for (var i = 1; i < points.Count; i++) { builder.Append($" L {Fmt(points[i].X)} {Fmt(points[i].Y)}"); } return builder.ToString(); } private static string BuildDiamond(double centerX, double centerY, double radius) => $"M {Fmt(centerX)} {Fmt(centerY - radius)} L {Fmt(centerX + radius)} {Fmt(centerY)} L {Fmt(centerX)} {Fmt(centerY + radius)} L {Fmt(centerX - radius)} {Fmt(centerY)} Z"; private static string ToDomId(string prefix, params string[] parts) { var normalized = string.Join("-", parts) .ToLowerInvariant() .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') .ToArray(); var text = new string(normalized); while (text.Contains("--", StringComparison.Ordinal)) { text = text.Replace("--", "-", StringComparison.Ordinal); } return $"{prefix}-{text.Trim('-')}"; } private static string Escape(string value) => WebUtility.HtmlEncode(value); private static string Fmt(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) { "and", "the", "of", "for", "to", "a", "an", "with", }; private sealed record RawNode( string Id, string Title, int Depth, string Role, bool IsEntry, int Degree, SemanticCategory Category, string Token, double RawX, double RawY); private sealed record ProjectedNode( string Id, string Title, int Depth, string Role, bool IsEntry, int Degree, SemanticCategory Category, string Token, Microsoft.Msagl.Core.Geometry.Point RawCenter, (double X, double Y) LocalCenter, double Radius, double StrokeWidth, double TokenFontSize, double LabelFontSize, double LabelWidth, double LabelHeight, double LabelPadding, double LineHeight, bool ShowBadge, string Fill, string InnerFill, string Stroke, string InnerStroke, string TokenColor, string LabelFill, string LabelText, IReadOnlyList LabelLines) { public static ProjectedNode Create(RawNode node, Projection projection, Theme theme) { var localCenter = projection.Project(new Microsoft.Msagl.Core.Geometry.Point(node.RawX, node.RawY)); var radius = node.IsEntry ? 18 : node.Role == GraphPartitionNodeRole.Anchor.ToApiValue() ? 11.5 : Math.Min(16.5, 12.5 + node.Degree * 0.12); var labelFontSize = projection.Kind == ProjectionKind.Arc ? 10.6 : 11.3; var labelLines = WrapLabel(node.Title, projection.Kind == ProjectionKind.Arc ? 18 : 20, 2); var labelPadding = 7.5; var lineHeight = labelFontSize + 4.5; var labelWidth = Math.Max( radius * 2 + 10, labelLines.Max(line => line.Length) * labelFontSize * 0.57 + labelPadding * 2); var labelHeight = labelLines.Count * lineHeight + labelPadding * 2 - 2; var accent = theme.GetAccent(node.Category, node.IsEntry); return new ProjectedNode( Id: node.Id, Title: node.Title, Depth: node.Depth, Role: node.Role, IsEntry: node.IsEntry, Degree: node.Degree, Category: node.Category, Token: node.Token, RawCenter: new Microsoft.Msagl.Core.Geometry.Point(node.RawX, node.RawY), LocalCenter: localCenter, Radius: radius, StrokeWidth: node.IsEntry ? 2.2 : node.Role == GraphPartitionNodeRole.Anchor.ToApiValue() ? 1.35 : 1.55, TokenFontSize: node.Token.Length > 1 ? 9.2 : 10.6, LabelFontSize: labelFontSize, LabelWidth: labelWidth, LabelHeight: labelHeight, LabelPadding: labelPadding, LineHeight: lineHeight, ShowBadge: node.Degree >= 4 || node.IsEntry, Fill: accent, InnerFill: theme.NodeInnerFill, Stroke: node.IsEntry ? "#FDE68A" : accent, InnerStroke: accent, TokenColor: node.Role == GraphPartitionNodeRole.Anchor.ToApiValue() ? "#0F172A" : "#F8FAFC", LabelFill: theme.LabelFill, LabelText: theme.LabelText, LabelLines: labelLines); } } private sealed record CanvasLayout( double Width, double Height, double OffsetX, double OffsetY) { public static CanvasLayout Create(IReadOnlyList nodes) { const double padding = 52; const double headerHeight = 94; const double footerHeight = 36; var minX = nodes.Min(node => Math.Min(node.LocalCenter.X - node.Radius - 12, node.LocalCenter.X - node.LabelWidth / 2.0)); var maxX = nodes.Max(node => Math.Max(node.LocalCenter.X + node.Radius + 12, node.LocalCenter.X + node.LabelWidth / 2.0)); var minY = nodes.Min(node => node.LocalCenter.Y - node.Radius - 22); var maxY = nodes.Max(node => node.LocalCenter.Y + node.Radius + 12 + node.LabelHeight); return new CanvasLayout( Width: Math.Max(1280, maxX - minX + padding * 2), Height: Math.Max(860, headerHeight + maxY - minY + padding + footerHeight), OffsetX: padding - minX, OffsetY: headerHeight + padding - minY); } public (double X, double Y) Transform((double X, double Y) point) => (point.X + OffsetX, point.Y + OffsetY); } private sealed class Projection { public ProjectionKind Kind { get; } public double RawMinX { get; } public double RawMaxX { get; } public double RawMinY { get; } public double RawMaxY { get; } public double ArcStartRadians { get; } public double ArcEndRadians { get; } public double ArcInnerRadius { get; } public double ArcOuterRadius { get; } public double CartesianWidth { get; } public double CartesianHeight { get; } public double CartesianHorizontalScale { get; } private Projection( ProjectionKind kind, double rawMinX, double rawMaxX, double rawMinY, double rawMaxY, double arcStartRadians, double arcEndRadians, double arcInnerRadius, double arcOuterRadius, double cartesianWidth, double cartesianHeight, double cartesianHorizontalScale) { Kind = kind; RawMinX = rawMinX; RawMaxX = rawMaxX; RawMinY = rawMinY; RawMaxY = rawMaxY; ArcStartRadians = arcStartRadians; ArcEndRadians = arcEndRadians; ArcInnerRadius = arcInnerRadius; ArcOuterRadius = arcOuterRadius; CartesianWidth = cartesianWidth; CartesianHeight = cartesianHeight; CartesianHorizontalScale = cartesianHorizontalScale; } public static Projection Create(LayoutReport report, IReadOnlyList rawNodes) { var rawMinX = rawNodes.Min(node => node.RawX); var rawMaxX = rawNodes.Max(node => node.RawX); var rawMinY = rawNodes.Min(node => node.RawY); var rawMaxY = rawNodes.Max(node => node.RawY); var distinctDepths = rawNodes.Where(node => node.Depth >= 0).Select(node => node.Depth).Distinct().Count(); if (report.GraphKey.Equals("source-tree", StringComparison.OrdinalIgnoreCase) && report.LayoutMode.Equals("sugiyama", StringComparison.OrdinalIgnoreCase)) { return new Projection( ProjectionKind.Arc, rawMinX, rawMaxX, rawMinY, rawMaxY, arcStartRadians: DegreesToRadians(162), arcEndRadians: DegreesToRadians(18), arcInnerRadius: 180, arcOuterRadius: Math.Max(430, 210 + distinctDepths * 86), cartesianWidth: 0, cartesianHeight: 0, cartesianHorizontalScale: 1); } var rawWidth = Math.Max(320, rawMaxX - rawMinX); var rawHeight = Math.Max(260, rawMaxY - rawMinY); var horizontalScale = report.GraphKey.Equals("control-flow", StringComparison.OrdinalIgnoreCase) ? 1.18 : 0.92; var verticalScale = report.GraphKey.Equals("control-flow", StringComparison.OrdinalIgnoreCase) ? 1.22 : 1.04; return new Projection( ProjectionKind.Cartesian, rawMinX, rawMaxX, rawMinY, rawMaxY, arcStartRadians: 0, arcEndRadians: 0, arcInnerRadius: 0, arcOuterRadius: 0, cartesianWidth: rawWidth * horizontalScale, cartesianHeight: rawHeight * verticalScale, cartesianHorizontalScale: horizontalScale); } public (double X, double Y) Project(Microsoft.Msagl.Core.Geometry.Point point) { var nx = Normalize(point.X, RawMinX, RawMaxX); var ny = Normalize(point.Y, RawMinY, RawMaxY); return Kind switch { ProjectionKind.Arc => ProjectArc(nx, 1 - ny), _ => ProjectCartesian(nx, ny), }; } public double GetArcRadius(double rawY) { var ny = Normalize(rawY, RawMinY, RawMaxY); return ArcInnerRadius + ((1 - ny) * (ArcOuterRadius - ArcInnerRadius)); } public (double X, double Y) ProjectArcPoint(double radius, double t) { var angle = ArcStartRadians + ((ArcEndRadians - ArcStartRadians) * t); return PolarPoint(radius, angle); } public (double X, double Y) ProjectArcLabel(double radius) { var angle = ArcStartRadians - DegreesToRadians(7); return PolarPoint(radius, angle); } private (double X, double Y) ProjectArc(double nx, double radialT) { var angle = ArcStartRadians + ((ArcEndRadians - ArcStartRadians) * nx); var radius = ArcInnerRadius + ((ArcOuterRadius - ArcInnerRadius) * radialT); return PolarPoint(radius, angle); } private (double X, double Y) ProjectCartesian(double nx, double ny) { var centeredX = (nx * 2.0) - 1.0; var compressedX = Math.Sign(centeredX) * Math.Pow(Math.Abs(centeredX), 0.88); var x = compressedX * (CartesianWidth / 2.0); var y = (1 - ny) * CartesianHeight; return (x, y); } private static (double X, double Y) PolarPoint(double radius, double angle) => (Math.Cos(angle) * radius, -Math.Sin(angle) * radius); private static double Normalize(double value, double min, double max) { var range = Math.Max(0.0001, max - min); return Math.Clamp((value - min) / range, 0, 1); } private static double DegreesToRadians(double value) => value * (Math.PI / 180.0); } private enum ProjectionKind { Cartesian, Arc, } private enum SemanticCategory { Root, Anchor, Shared, Client, Server, Simulation, Content, Default, } private static class SemanticCategoryClassifier { public static SemanticCategory Classify(string graphKey, string title, string role, bool isEntry) { if (isEntry) { return SemanticCategory.Root; } if (role == GraphPartitionNodeRole.Anchor.ToApiValue()) { return SemanticCategory.Anchor; } if (title.Contains("Shared", StringComparison.OrdinalIgnoreCase)) { return SemanticCategory.Shared; } if (title.Contains("Server", StringComparison.OrdinalIgnoreCase) || title.Contains("Auth", StringComparison.OrdinalIgnoreCase) || title.Contains("Persistence", StringComparison.OrdinalIgnoreCase) || title.Contains("Serialization", StringComparison.OrdinalIgnoreCase) || title.Contains("Bootstrap", StringComparison.OrdinalIgnoreCase) || title.Contains("Network", StringComparison.OrdinalIgnoreCase)) { return SemanticCategory.Server; } if (title.Contains("Client", StringComparison.OrdinalIgnoreCase) || title.Contains("UI", StringComparison.OrdinalIgnoreCase) || title.Contains("HUD", StringComparison.OrdinalIgnoreCase) || title.Contains("Menu", StringComparison.OrdinalIgnoreCase) || title.Contains("Input", StringComparison.OrdinalIgnoreCase) || title.Contains("View", StringComparison.OrdinalIgnoreCase) || title.Contains("Weapons", StringComparison.OrdinalIgnoreCase)) { return SemanticCategory.Client; } if (title.Contains("Simulation", StringComparison.OrdinalIgnoreCase) || title.Contains("Math", StringComparison.OrdinalIgnoreCase) || title.Contains("Zone", StringComparison.OrdinalIgnoreCase) || title.Contains("Galaxy", StringComparison.OrdinalIgnoreCase) || title.Contains("Shader", StringComparison.OrdinalIgnoreCase) || title.Contains("GPU", StringComparison.OrdinalIgnoreCase) || title.Contains("Render", StringComparison.OrdinalIgnoreCase) || title.Contains("Algorithm", StringComparison.OrdinalIgnoreCase)) { return SemanticCategory.Simulation; } if (title.Contains("Audio", StringComparison.OrdinalIgnoreCase) || title.Contains("Narrative", StringComparison.OrdinalIgnoreCase) || title.Contains("Prefab", StringComparison.OrdinalIgnoreCase) || title.Contains("Asset", StringComparison.OrdinalIgnoreCase) || title.Contains("Corpus", StringComparison.OrdinalIgnoreCase) || title.Contains("Data", StringComparison.OrdinalIgnoreCase) || title.Contains("Authoring", StringComparison.OrdinalIgnoreCase)) { return SemanticCategory.Content; } return SemanticCategory.Default; } } private sealed record Theme( string FontFamily, string BackgroundStart, string BackgroundMid, string BackgroundEnd, string HeaderFill, string HeaderStroke, string HeaderText, string MutedText, string PillFill, string PillStroke, string LabelFill, string LabelText, string NodeInnerFill, string EdgeStroke, string GlowColor, string BadgeFill, string BadgeText) { public static Theme For(string graphKey) { return graphKey.Equals("control-flow", StringComparison.OrdinalIgnoreCase) ? new Theme( FontFamily: "Bahnschrift, Segoe UI, sans-serif", BackgroundStart: "#0C0914", BackgroundMid: "#121424", BackgroundEnd: "#180D12", HeaderFill: "#120E1C", HeaderStroke: "#FB7185", HeaderText: "#FFF7ED", MutedText: "#FDBA74", PillFill: "#221120", PillStroke: "#FB7185", LabelFill: "#151224", LabelText: "#FFF7ED", NodeInnerFill: "#10151F", EdgeStroke: "#FB7185", GlowColor: "#FB7185", BadgeFill: "#140E18", BadgeText: "#FFF7ED") : new Theme( FontFamily: "Bahnschrift, Segoe UI, sans-serif", BackgroundStart: "#061019", BackgroundMid: "#0B1424", BackgroundEnd: "#111827", HeaderFill: "#091525", HeaderStroke: "#22D3EE", HeaderText: "#F8FAFC", MutedText: "#A5F3FC", PillFill: "#10263C", PillStroke: "#22D3EE", LabelFill: "#0A1524", LabelText: "#F8FAFC", NodeInnerFill: "#09111D", EdgeStroke: "#22D3EE", GlowColor: "#22D3EE", BadgeFill: "#0B1220", BadgeText: "#F8FAFC"); } public string GetAccent(SemanticCategory category, bool isEntry) => isEntry ? "#FDE68A" : category switch { SemanticCategory.Root => "#FDE68A", SemanticCategory.Anchor => "#94A3B8", SemanticCategory.Shared => "#67E8F9", SemanticCategory.Client => "#4ADE80", SemanticCategory.Server => "#60A5FA", SemanticCategory.Simulation => "#C084FC", SemanticCategory.Content => "#F472B6", _ => "#22D3EE", }; } }