Game/ReplayReactionTimeline.cs
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>Bound reaction markers to the timeline's visible resolution, rather than one UI
/// element per player. Equal-width slices make nearby reactions readable as a density plot.</summary>
public static class ReplayReactionTimeline
{
public readonly record struct Marker( float Fraction, int Count, int FirstFrame, int LastFrame )
{
public int Height => 4 + Math.Min( 4, (int)Math.Log2( Math.Max( 1, Count ) ) ) * 3;
// Land just past fade-in so a paused marker click actually displays the reaction.
public int SeekFrame( int length ) => Math.Clamp( FirstFrame + 6, 0, Math.Max( 0, length - 1 ) );
}
public static IReadOnlyList<Marker> Build( IReadOnlyList<KeyValuePair<long, ReplayReaction>> entries, int length, int slices )
{
if ( length <= 0 || entries is null || entries.Count == 0 ) return Array.Empty<Marker>();
slices = Math.Clamp( slices, 1, 128 );
var counts = new int[slices];
var first = new int[slices];
var last = new int[slices];
foreach ( var (_, reaction) in entries )
{
if ( reaction is null || reaction.Removed || reaction.Frame < 0 || reaction.Frame >= length ) continue;
int slice = (int)((long)reaction.Frame * slices / length);
if ( counts[slice]++ == 0 ) first[slice] = last[slice] = reaction.Frame;
else
{
first[slice] = Math.Min( first[slice], reaction.Frame );
last[slice] = Math.Max( last[slice], reaction.Frame );
}
}
var markers = new List<Marker>();
for ( int i = 0; i < slices; i++ )
if ( counts[i] > 0 ) markers.Add( new Marker(
counts[i] == 1 ? first[i] / (float)length : (i + 0.5f) / slices, counts[i], first[i], last[i] ) );
return markers;
}
public static Marker? Nearest( IReadOnlyList<Marker> markers, float fraction, float tolerance )
{
Marker? nearest = null;
foreach ( var marker in markers )
{
float distance = Math.Abs( marker.Fraction - fraction );
if ( distance > tolerance ) continue;
nearest = marker;
tolerance = distance;
}
return nearest;
}
}