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;
	}
}