/ GUNRPG.Core / Rendering / CombatEventTimelineRenderer.cs
CombatEventTimelineRenderer.cs
  1  using GUNRPG.Core.Events;
  2  using GUNRPG.Core.Operators;
  3  using Microsoft.FSharp.Core;
  4  using Plotly.NET;
  5  using Plotly.NET.CSharp;
  6  using Plotly.NET.ImageExport;
  7  using Plotly.NET.LayoutObjects;
  8  
  9  namespace GUNRPG.Core.Rendering;
 10  
 11  public sealed class CombatEventTimelineRenderer
 12  {
 13      private const int MinEventDurationMs = 2;
 14  
 15      public IReadOnlyList<CombatEventTimelineEntry> BuildTimelineEntries(
 16          IReadOnlyList<ISimulationEvent> events,
 17          Operator player,
 18          Operator enemy,
 19          IReadOnlyList<CombatEventTimelineEntry>? additionalEntries = null)
 20      {
 21          var entries = events
 22              .Select(evt => ToTimelineEntry(evt, player, enemy))
 23              .ToList();
 24  
 25          if (additionalEntries != null && additionalEntries.Count > 0)
 26          {
 27              entries.AddRange(additionalEntries);
 28          }
 29  
 30          entries = entries
 31              .OrderBy(entry => entry.StartTimeMs)
 32              .ThenBy(entry => entry.ActorName, StringComparer.Ordinal)
 33              .ToList();
 34  
 35          return entries;
 36      }
 37  
 38      public void RenderTimeline(
 39          IReadOnlyList<CombatEventTimelineEntry> entries,
 40          string outputPath)
 41      {
 42          if (entries.Count == 0)
 43          {
 44              Console.WriteLine("No combat events captured for timeline rendering.");
 45              return;
 46          }
 47  
 48          var (labels, durations, bases) = BuildChartSeries(entries);
 49          var timelineBarTraces = new List<GenericChart>(entries.Count);
 50  
 51          for (int i = 0; i < labels.Count; i++)
 52          {
 53              var trace = Plotly.NET.CSharp.Chart.Bar<double, string, string>(
 54                  new[] { durations[i] },
 55                  Keys: new[] { labels[i] },
 56                  Base: bases[i],
 57                  Width: 0.6,
 58                  ShowLegend: false);
 59              timelineBarTraces.Add(trace);
 60          }
 61  
 62          var chart = Plotly.NET.Chart.Combine(timelineBarTraces);
 63  
 64          var orientation = Trace2DStyle.Bar<double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, double, Trace>(
 65              Orientation: FSharpOption<StyleParam.Orientation>.Some(StyleParam.Orientation.Horizontal));
 66          chart = Plotly.NET.GenericChart.mapTrace(orientation, chart);
 67  
 68          chart = Plotly.NET.CSharp.GenericChartExtensions.WithXAxisStyle<double, double, string>(
 69              chart,
 70              TitleText: "Simulated Time (ms)");
 71          chart = Plotly.NET.CSharp.GenericChartExtensions.WithYAxisStyle<double, double, string>(
 72              chart,
 73              TitleText: "Combat Events",
 74              CategoryOrder: StyleParam.CategoryOrder.Array,
 75              CategoryArray: labels);
 76  
 77          var title = Plotly.NET.Title.init(FSharpOption<string>.Some("Combat Event Timeline"));
 78          var margin = Margin.init<int, int, int, int, int, bool>(
 79              Left: FSharpOption<int>.Some(280),
 80              Right: FSharpOption<int>.Some(40),
 81              Top: FSharpOption<int>.Some(60),
 82              Bottom: FSharpOption<int>.Some(60),
 83              Pad: FSharpOption<int>.Some(8),
 84              Autoexpand: FSharpOption<bool>.Some(true));
 85  
 86          chart = Plotly.NET.GenericChartExtensions.WithLayoutStyle<string>(
 87              chart,
 88              Title: FSharpOption<Plotly.NET.Title>.Some(title),
 89              Margin: FSharpOption<Margin>.Some(margin),
 90              Height: FSharpOption<int>.Some(Math.Clamp(entries.Count * 28, 320, 1400)));
 91  
 92          SaveChart(chart, outputPath);
 93      }
 94  
 95      private static void SaveChart(GenericChart chart, string outputPath)
 96      {
 97          var extension = Path.GetExtension(outputPath);
 98          if (string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase))
 99          {
100              try
101              {
102                  chart.SavePNG(
103                      outputPath,
104                      EngineType: FSharpOption<ExportEngine>.Some(ExportEngine.PuppeteerSharp),
105                      Width: FSharpOption<int>.Some(1600),
106                      Height: FSharpOption<int>.Some(900));
107                  return;
108              }
109              catch (Exception ex)
110              {
111                  var fallbackPath = Path.ChangeExtension(outputPath, ".html");
112                  Plotly.NET.GenericChartExtensions.SaveHtml(
113                      chart,
114                      fallbackPath,
115                      FSharpOption<bool>.Some(true));
116                  Console.WriteLine("PNG export failed. Saved HTML fallback instead:");
117                  Console.WriteLine($"  {fallbackPath}");
118                  Console.WriteLine($"  Details: {ex.GetType().Name} - {ex.Message}");
119                  Console.WriteLine("  Hint: Ensure PuppeteerSharp dependencies are available.");
120                  return;
121              }
122          }
123  
124          Plotly.NET.GenericChartExtensions.SaveHtml(
125              chart,
126              outputPath,
127              FSharpOption<bool>.Some(true));
128      }
129  
130      private static CombatEventTimelineEntry ToTimelineEntry(
131          ISimulationEvent evt,
132          Operator player,
133          Operator enemy)
134      {
135          var actorName = ResolveActorName(evt.OperatorId, player, enemy);
136          var (start, end) = ResolveEventWindow(evt);
137  
138          return new CombatEventTimelineEntry(FormatEventType(evt), start, end, actorName);
139      }
140  
141      private static int InferDurationMs(ISimulationEvent evt)
142      {
143          return evt switch
144          {
145              ShotFiredEvent shot => Math.Max((int)Math.Round((double)shot.TravelTimeMs, MidpointRounding.AwayFromZero), MinEventDurationMs),
146              DamageAppliedEvent => MinEventDurationMs,
147              ShotMissedEvent => MinEventDurationMs,
148              ReloadCompleteEvent reload => Math.Max(reload.ActionDurationMs, MinEventDurationMs),
149              ADSCompleteEvent ads => Math.Max(ads.ActionDurationMs, MinEventDurationMs),
150              ADSTransitionUpdateEvent adsUpdate => Math.Max(adsUpdate.ActionDurationMs, MinEventDurationMs),
151              MovementIntervalEvent movement => Math.Max(movement.IntervalDurationMs, MinEventDurationMs),
152              SlideCompleteEvent slide => Math.Max(slide.ActionDurationMs, MinEventDurationMs),
153              MicroReactionEvent microReaction => Math.Max(microReaction.ActionDurationMs, MinEventDurationMs),
154              SuppressionStartedEvent => MinEventDurationMs,
155              SuppressionUpdatedEvent => MinEventDurationMs,
156              SuppressionEndedEvent suppressionEnded => Math.Max((int)suppressionEnded.DurationMs, MinEventDurationMs),
157              MovementStartedEvent movementStarted => Math.Max((int)movementStarted.DurationMs, MinEventDurationMs),
158              MovementCancelledEvent => MinEventDurationMs,
159              MovementEndedEvent => MinEventDurationMs,
160              CoverEnteredEvent => MinEventDurationMs,
161              CoverExitedEvent => MinEventDurationMs,
162              _ => MinEventDurationMs
163          };
164      }
165  
166      private static (int start, int end) ResolveEventWindow(ISimulationEvent evt)
167      {
168          int eventTime = (int)Math.Clamp(evt.EventTimeMs, int.MinValue, int.MaxValue);
169          int duration = Math.Max(InferDurationMs(evt), MinEventDurationMs);
170          int start = eventTime;
171          int end = eventTime + duration;
172  
173          switch (evt)
174          {
175              case ReloadCompleteEvent reload:
176                  start = eventTime - Math.Max(reload.ActionDurationMs, MinEventDurationMs);
177                  end = eventTime;
178                  break;
179              case ADSCompleteEvent ads:
180                  start = eventTime - Math.Max(ads.ActionDurationMs, MinEventDurationMs);
181                  end = eventTime;
182                  break;
183              case ADSTransitionUpdateEvent adsUpdate:
184                  start = eventTime - Math.Max(adsUpdate.ActionDurationMs, MinEventDurationMs);
185                  end = eventTime;
186                  break;
187              case MovementIntervalEvent movement:
188                  start = eventTime - Math.Max(movement.IntervalDurationMs, MinEventDurationMs);
189                  end = eventTime;
190                  break;
191              case SlideCompleteEvent slide:
192                  start = eventTime - Math.Max(slide.ActionDurationMs, MinEventDurationMs);
193                  end = eventTime;
194                  break;
195              case SuppressionEndedEvent suppressionEnded:
196                  // Suppression ended event represents the entire duration
197                  start = eventTime - (int)suppressionEnded.DurationMs;
198                  end = eventTime;
199                  break;
200              case MovementStartedEvent movementStarted:
201                  // Movement shows duration from start to end
202                  start = eventTime;
203                  end = (int)movementStarted.EndTimeMs;
204                  break;
205              case MovementCancelledEvent movementCancelled:
206                  // Show cancelled movement as a bar ending at cancellation time
207                  // We don't have the original start time, so just show a short marker
208                  start = eventTime - MinEventDurationMs;
209                  end = eventTime;
210                  break;
211          }
212  
213          start = Math.Max(start, 0);
214          if (end <= start)
215          {
216              end = start + MinEventDurationMs;
217          }
218  
219          return (start, end);
220      }
221  
222      private static string? ResolveActorName(Guid operatorId, Operator player, Operator enemy)
223      {
224          if (operatorId == player.Id)
225          {
226              return player.Name;
227          }
228  
229          if (operatorId == enemy.Id)
230          {
231              return enemy.Name;
232          }
233  
234          return null;
235      }
236  
237      private static string FormatEventType(ISimulationEvent evt)
238      {
239          string name = evt.GetType().Name;
240          return name.EndsWith("Event", StringComparison.Ordinal)
241              ? name[..^"Event".Length]
242              : name;
243      }
244  
245      private static (List<string> labels, List<double> durations, List<double> bases) BuildChartSeries(
246          IReadOnlyList<CombatEventTimelineEntry> entries)
247      {
248          var labels = new List<string>(entries.Count);
249          var durations = new List<double>(entries.Count);
250          var bases = new List<double>(entries.Count);
251  
252          foreach (var entry in entries)
253          {
254              int duration = entry.DurationMs <= 0 ? MinEventDurationMs : entry.DurationMs;
255              string actorLabel = string.IsNullOrWhiteSpace(entry.ActorName) ? "Unknown" : entry.ActorName!;
256              string label = entry.Detail == null
257                  ? FormattableString.Invariant($"{entry.StartTimeMs}ms | {actorLabel} | {entry.EventType}")
258                  : FormattableString.Invariant($"{entry.StartTimeMs}ms | {actorLabel} | {entry.EventType} ({entry.Detail})");
259  
260              labels.Add(label);
261              durations.Add(duration);
262              bases.Add(entry.StartTimeMs);
263          }
264  
265          return (labels, durations, bases);
266      }
267  }