mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Merge pull request #63 from AmanTahiliani/feat/issue-20-combined-position-graph-track-map-scrubb
Combined position-graph + track-map scrubber (#20)
This commit is contained in:
259
internal/web/replay.go
Normal file
259
internal/web/replay.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReplayIntervalMS = 5000
|
||||
maxReplayFrames = 3000
|
||||
replayFetchConcurrency = 4
|
||||
)
|
||||
|
||||
type replayDataClient interface {
|
||||
GetDriversForSession(sessionKey int) ([]models.Driver, error)
|
||||
GetLocation(sessionKey, driverNumber int) ([]models.Location, error)
|
||||
}
|
||||
|
||||
type replayFramesResponse struct {
|
||||
SessionKey int `json:"session_key"`
|
||||
Interval int `json:"interval_ms"`
|
||||
StartTime string `json:"start_time"`
|
||||
Frames []replayFrame `json:"frames"`
|
||||
}
|
||||
|
||||
type replayFrame struct {
|
||||
T int64 `json:"t"`
|
||||
Cars map[string]replayCar `json:"cars"`
|
||||
}
|
||||
|
||||
type replayCar struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
||||
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
|
||||
if err != nil || sessionKey == 0 {
|
||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
intervalMS := defaultReplayIntervalMS
|
||||
rawInterval := r.URL.Query().Get("interval_ms")
|
||||
if rawInterval != "" {
|
||||
parsed, err := strconv.Atoi(rawInterval)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid interval_ms", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if parsed > intervalMS {
|
||||
intervalMS = parsed
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func assembleReplayFrames(ctx context.Context, client replayDataClient, sessionKey, intervalMS int) (replayFramesResponse, error) {
|
||||
if intervalMS < defaultReplayIntervalMS {
|
||||
intervalMS = defaultReplayIntervalMS
|
||||
}
|
||||
|
||||
resp := replayFramesResponse{
|
||||
SessionKey: sessionKey,
|
||||
Interval: intervalMS,
|
||||
Frames: []replayFrame{},
|
||||
}
|
||||
|
||||
drivers, err := client.GetDriversForSession(sessionKey)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
driverNumbers := uniqueDriverNumbers(drivers)
|
||||
if len(driverNumbers) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
series, err := fetchReplayLocationSeries(ctx, client, sessionKey, driverNumbers)
|
||||
if err != nil && len(series) == 0 {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
start, ok := earliestReplayLocationTime(series)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
resp.StartTime = start.Format(time.RFC3339Nano)
|
||||
resp.Frames = snapReplayFrames(series, start, intervalMS)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func uniqueDriverNumbers(drivers []models.Driver) []int {
|
||||
seen := make(map[int]bool, len(drivers))
|
||||
numbers := make([]int, 0, len(drivers))
|
||||
for _, driver := range drivers {
|
||||
if driver.DriverNumber <= 0 || seen[driver.DriverNumber] {
|
||||
continue
|
||||
}
|
||||
seen[driver.DriverNumber] = true
|
||||
numbers = append(numbers, driver.DriverNumber)
|
||||
}
|
||||
sort.Ints(numbers)
|
||||
return numbers
|
||||
}
|
||||
|
||||
func fetchReplayLocationSeries(ctx context.Context, client replayDataClient, sessionKey int, driverNumbers []int) (map[int][]models.Location, error) {
|
||||
type result struct {
|
||||
driver int
|
||||
locs []models.Location
|
||||
err error
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, replayFetchConcurrency)
|
||||
results := make(chan result, len(driverNumbers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, driverNumber := range driverNumbers {
|
||||
driverNumber := driverNumber
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
results <- result{driver: driverNumber, err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
|
||||
locs, err := client.GetLocation(sessionKey, driverNumber)
|
||||
results <- result{driver: driverNumber, locs: locs, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
series := make(map[int][]models.Location, len(driverNumbers))
|
||||
var joined error
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
joined = errors.Join(joined, result.err)
|
||||
continue
|
||||
}
|
||||
if len(result.locs) > 0 {
|
||||
series[result.driver] = result.locs
|
||||
}
|
||||
}
|
||||
return series, joined
|
||||
}
|
||||
|
||||
func earliestReplayLocationTime(series map[int][]models.Location) (time.Time, bool) {
|
||||
var start time.Time
|
||||
for _, locs := range series {
|
||||
for _, loc := range locs {
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start.IsZero() || t.Before(start) {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return start, true
|
||||
}
|
||||
|
||||
func snapReplayFrames(series map[int][]models.Location, start time.Time, intervalMS int) []replayFrame {
|
||||
type accumulator struct {
|
||||
t int64
|
||||
cars map[string]replayCar
|
||||
nearest map[string]int64
|
||||
}
|
||||
|
||||
interval := int64(intervalMS)
|
||||
framesByIndex := make(map[int]*accumulator)
|
||||
|
||||
for driverNumber, locs := range series {
|
||||
driverKey := strconv.Itoa(driverNumber)
|
||||
for _, loc := range locs {
|
||||
if !isFiniteFloat(loc.X) || !isFiniteFloat(loc.Y) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, loc.Date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
offset := t.Sub(start).Milliseconds()
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
index := int((offset + interval/2) / interval)
|
||||
if index < 0 || index >= maxReplayFrames {
|
||||
continue
|
||||
}
|
||||
frameT := int64(index) * interval
|
||||
distance := absInt64(offset - frameT)
|
||||
|
||||
acc, ok := framesByIndex[index]
|
||||
if !ok {
|
||||
acc = &accumulator{
|
||||
t: frameT,
|
||||
cars: make(map[string]replayCar),
|
||||
nearest: make(map[string]int64),
|
||||
}
|
||||
framesByIndex[index] = acc
|
||||
}
|
||||
if prev, ok := acc.nearest[driverKey]; ok && prev <= distance {
|
||||
continue
|
||||
}
|
||||
acc.nearest[driverKey] = distance
|
||||
acc.cars[driverKey] = replayCar{X: loc.X, Y: loc.Y}
|
||||
}
|
||||
}
|
||||
|
||||
indexes := make([]int, 0, len(framesByIndex))
|
||||
for index, acc := range framesByIndex {
|
||||
if len(acc.cars) > 0 {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
|
||||
frames := make([]replayFrame, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
acc := framesByIndex[index]
|
||||
frames = append(frames, replayFrame{T: acc.t, Cars: acc.cars})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func isFiniteFloat(v float64) bool {
|
||||
return !math.IsNaN(v) && !math.IsInf(v, 0)
|
||||
}
|
||||
181
internal/web/replay_test.go
Normal file
181
internal/web/replay_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
type fakeReplayClient struct {
|
||||
drivers []models.Driver
|
||||
locs map[int][]models.Location
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
inFlight int
|
||||
maxInFlight int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.drivers, nil
|
||||
}
|
||||
|
||||
func (f *fakeReplayClient) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
|
||||
f.mu.Lock()
|
||||
f.inFlight++
|
||||
if f.inFlight > f.maxInFlight {
|
||||
f.maxInFlight = f.inFlight
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
if f.delay > 0 {
|
||||
time.Sleep(f.delay)
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.inFlight--
|
||||
f.mu.Unlock()
|
||||
|
||||
return f.locs[driverNumber], nil
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesSnapsNearestSamplesAndOmitsEmptyDrivers(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{
|
||||
{DriverNumber: 1},
|
||||
{DriverNumber: 4},
|
||||
{DriverNumber: 16},
|
||||
},
|
||||
locs: map[int][]models.Location{
|
||||
1: {
|
||||
{Date: start.Add(1 * time.Second).Format(time.RFC3339Nano), X: 10, Y: 20},
|
||||
{Date: start.Add(4 * time.Second).Format(time.RFC3339Nano), X: 40, Y: 80},
|
||||
{Date: start.Add(6 * time.Second).Format(time.RFC3339Nano), X: 60, Y: 120},
|
||||
},
|
||||
4: {
|
||||
{Date: start.Add(5 * time.Second).Format(time.RFC3339Nano), X: 100, Y: 200},
|
||||
},
|
||||
16: {},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if resp.SessionKey != 99 || resp.Interval != 5000 {
|
||||
t.Fatalf("response metadata = %+v", resp)
|
||||
}
|
||||
if resp.StartTime != start.Add(1*time.Second).Format(time.RFC3339Nano) {
|
||||
t.Fatalf("start_time = %q", resp.StartTime)
|
||||
}
|
||||
if len(resp.Frames) != 2 {
|
||||
t.Fatalf("frames len = %d, want 2: %+v", len(resp.Frames), resp.Frames)
|
||||
}
|
||||
if _, ok := resp.Frames[0].Cars["16"]; ok {
|
||||
t.Fatalf("empty driver included in frame: %+v", resp.Frames[0].Cars)
|
||||
}
|
||||
if got := resp.Frames[0].Cars["1"]; got.X != 10 || got.Y != 20 {
|
||||
t.Fatalf("frame 0 car 1 = %+v, want first nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["1"]; got.X != 60 || got.Y != 120 {
|
||||
t.Fatalf("frame 1 car 1 = %+v, want later nearest sample", got)
|
||||
}
|
||||
if got := resp.Frames[1].Cars["4"]; got.X != 100 || got.Y != 200 {
|
||||
t.Fatalf("frame 1 car 4 = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesCapsFrameCount(t *testing.T) {
|
||||
start := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
locs := make([]models.Location, maxReplayFrames+250)
|
||||
for i := range locs {
|
||||
locs[i] = models.Location{
|
||||
Date: start.Add(time.Duration(i*defaultReplayIntervalMS) * time.Millisecond).Format(time.RFC3339Nano),
|
||||
X: float64(i),
|
||||
Y: float64(i * 2),
|
||||
}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: []models.Driver{{DriverNumber: 1}},
|
||||
locs: map[int][]models.Location{1: locs},
|
||||
}
|
||||
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if len(resp.Frames) > maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want <= %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
if len(resp.Frames) != maxReplayFrames {
|
||||
t.Fatalf("frames len = %d, want hard cap %d", len(resp.Frames), maxReplayFrames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleReplayFramesBoundsLocationFanOut(t *testing.T) {
|
||||
drivers := make([]models.Driver, 10)
|
||||
locs := make(map[int][]models.Location, len(drivers))
|
||||
now := time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC)
|
||||
for i := range drivers {
|
||||
number := i + 1
|
||||
drivers[i] = models.Driver{DriverNumber: number}
|
||||
locs[number] = []models.Location{{Date: now.Format(time.RFC3339Nano), X: float64(number), Y: float64(number)}}
|
||||
}
|
||||
client := &fakeReplayClient{
|
||||
drivers: drivers,
|
||||
locs: locs,
|
||||
delay: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
if _, err := assembleReplayFrames(context.Background(), client, 99, defaultReplayIntervalMS); err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
if client.maxInFlight > replayFetchConcurrency {
|
||||
t.Fatalf("max in-flight location calls = %d, want <= %d", client.maxInFlight, replayFetchConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplayFramesValidatesParamsAndFloorsInterval(t *testing.T) {
|
||||
srv := testServer(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing session_key status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/replay/frames?session_key=1&interval_ms=nope", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleReplayFrames(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid interval status = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
client := &fakeReplayClient{drivers: []models.Driver{{DriverNumber: 1}}, locs: map[int][]models.Location{
|
||||
1: {{Date: time.Date(2025, 5, 25, 13, 0, 0, 0, time.UTC).Format(time.RFC3339Nano), X: 1, Y: 2}},
|
||||
}}
|
||||
resp, err := assembleReplayFrames(context.Background(), client, 99, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("assembleReplayFrames() error = %v", err)
|
||||
}
|
||||
body, err := json.Marshal(resp)
|
||||
if err != nil || len(body) == 0 {
|
||||
t.Fatalf("marshal response = %d bytes, %v", len(body), err)
|
||||
}
|
||||
if resp.Interval != defaultReplayIntervalMS {
|
||||
t.Fatalf("interval = %d, want floor %d", resp.Interval, defaultReplayIntervalMS)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
||||
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
||||
mux.HandleFunc("/api/v1/replay/frames", s.handleReplayFrames)
|
||||
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
||||
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
||||
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
||||
|
||||
Reference in New Issue
Block a user