mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
merge phase-2 — keep both DriversView props (drivers for H2H, season for profile links)
This commit is contained in:
82
frontend/src/lib/chapters.ts
Normal file
82
frontend/src/lib/chapters.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Chapter } from '../types'
|
||||
|
||||
export function chapterKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return 'GO'
|
||||
case 'safety_car':
|
||||
return 'SC'
|
||||
case 'virtual_safety_car':
|
||||
return 'VSC'
|
||||
case 'red_flag':
|
||||
return 'RF'
|
||||
case 'pit_phase':
|
||||
return 'PIT'
|
||||
case 'decisive_swing':
|
||||
return '▲'
|
||||
case 'finish':
|
||||
return 'FIN'
|
||||
default:
|
||||
return kind.slice(0, 3).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
export function chapterLapRange(chapter: Chapter): string {
|
||||
if (chapter.start_lap === chapter.end_lap) {
|
||||
return `L${chapter.start_lap}`
|
||||
}
|
||||
return `L${chapter.start_lap}–L${chapter.end_lap}`
|
||||
}
|
||||
|
||||
/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */
|
||||
export function chapterStartScrub(
|
||||
chapter: Chapter,
|
||||
tMin: number,
|
||||
tRange: number,
|
||||
): number | null {
|
||||
if (!chapter.start_time || tRange <= 0) return null
|
||||
const ms = new Date(chapter.start_time).getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||
}
|
||||
|
||||
/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */
|
||||
export function chapterEndScrub(
|
||||
chapter: Chapter,
|
||||
tMin: number,
|
||||
tRange: number,
|
||||
): number | null {
|
||||
const raw = chapter.end_time ?? chapter.start_time
|
||||
if (!raw || tRange <= 0) return null
|
||||
const ms = new Date(raw).getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||
}
|
||||
|
||||
/** Index of the chapter containing the current scrub position, if any. */
|
||||
export function activeChapterIndex(
|
||||
chapters: Chapter[],
|
||||
scrubTime: number | null,
|
||||
tMin: number,
|
||||
tRange: number,
|
||||
): number | null {
|
||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||
const chartMs = tMin + scrubTime * tRange
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const ch = chapters[i]
|
||||
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
||||
const endRaw = ch.end_time ?? ch.start_time
|
||||
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
||||
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Duration in ms each chapter should play during a ~90s tour. */
|
||||
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
|
||||
if (chapters.length === 0) return []
|
||||
const perChapter = totalMs / chapters.length
|
||||
return chapters.map(() => perChapter)
|
||||
}
|
||||
293
frontend/src/lib/digest.ts
Normal file
293
frontend/src/lib/digest.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
|
||||
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||
|
||||
export interface GPWindow {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
/** Inclusive lower bound: end of previous GP, or open for season opener. */
|
||||
start: Date | null
|
||||
/** Exclusive upper bound: start of next GP, or open after the final round. */
|
||||
end: Date | null
|
||||
}
|
||||
|
||||
export interface DigestTag {
|
||||
kind: 'driver' | 'team'
|
||||
key: string
|
||||
label: string
|
||||
colour: string
|
||||
}
|
||||
|
||||
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
|
||||
|
||||
export const RECENT_BUCKET_KEY = '__recent__'
|
||||
|
||||
interface MatchCandidate {
|
||||
start: number
|
||||
end: number
|
||||
tag: DigestTag
|
||||
}
|
||||
|
||||
interface TagPattern {
|
||||
pattern: string
|
||||
tag: DigestTag
|
||||
}
|
||||
|
||||
function sortMeetings(meetings: Meeting[]): Meeting[] {
|
||||
return [...meetings].sort((a, b) => {
|
||||
const left = meetingStartTime(a)?.getTime() ?? 0
|
||||
const right = meetingStartTime(b)?.getTime() ?? 0
|
||||
if (left !== right) return left - right
|
||||
return a.meeting_name.localeCompare(b.meeting_name)
|
||||
})
|
||||
}
|
||||
|
||||
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
|
||||
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
|
||||
const sorted = sortMeetings(meetings)
|
||||
return sorted.map((meeting, index) => ({
|
||||
meeting_key: meeting.meeting_key,
|
||||
meeting_name: meeting.meeting_name,
|
||||
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
|
||||
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
|
||||
}))
|
||||
}
|
||||
|
||||
function itemPublishedAt(item: NewsItem): Date | null {
|
||||
if (!item.published_at) return null
|
||||
const parsed = Date.parse(item.published_at)
|
||||
return Number.isNaN(parsed) ? null : new Date(parsed)
|
||||
}
|
||||
|
||||
function itemInWindow(published: Date, window: GPWindow): boolean {
|
||||
if (window.start && published < window.start) return false
|
||||
if (window.end && published >= window.end) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
|
||||
const matches = windows.filter((window) => itemInWindow(published, window))
|
||||
if (matches.length === 0) return null
|
||||
return matches.reduce((best, window) => {
|
||||
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||
return windowStart >= bestStart ? window : best
|
||||
})
|
||||
}
|
||||
|
||||
export interface GroupedDigest {
|
||||
windows: Array<{ window: GPWindow; items: NewsItem[] }>
|
||||
recent: NewsItem[]
|
||||
}
|
||||
|
||||
/** Bucket news items into GP windows; undated items land in `recent`. */
|
||||
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
|
||||
const buckets = new Map<number, NewsItem[]>()
|
||||
const recent: NewsItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const published = itemPublishedAt(item)
|
||||
if (!published) {
|
||||
recent.push(item)
|
||||
continue
|
||||
}
|
||||
const match = windowForDate(windows, published)
|
||||
if (!match) {
|
||||
recent.push(item)
|
||||
continue
|
||||
}
|
||||
const list = buckets.get(match.meeting_key) ?? []
|
||||
list.push(item)
|
||||
buckets.set(match.meeting_key, list)
|
||||
}
|
||||
|
||||
const grouped = windows
|
||||
.map((window) => ({
|
||||
window,
|
||||
items: buckets.get(window.meeting_key) ?? [],
|
||||
}))
|
||||
.filter((entry) => entry.items.length > 0)
|
||||
|
||||
return { windows: grouped, recent }
|
||||
}
|
||||
|
||||
function driverLastName(fullName: string): string | null {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length < 2) return null
|
||||
return parts[parts.length - 1] ?? null
|
||||
}
|
||||
|
||||
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
|
||||
const patterns: TagPattern[] = []
|
||||
|
||||
for (const team of teams) {
|
||||
if (!team.team_name.trim()) continue
|
||||
patterns.push({
|
||||
pattern: team.team_name.toLowerCase(),
|
||||
tag: {
|
||||
kind: 'team',
|
||||
key: `team:${team.team_name}`,
|
||||
label: team.team_name,
|
||||
colour: team.team_colour,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const driver of drivers) {
|
||||
const colour = driver.team_colour
|
||||
const base = {
|
||||
kind: 'driver' as const,
|
||||
colour,
|
||||
}
|
||||
if (driver.full_name.trim()) {
|
||||
patterns.push({
|
||||
pattern: driver.full_name.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
const lastName = driverLastName(driver.full_name)
|
||||
if (lastName) {
|
||||
patterns.push({
|
||||
pattern: lastName.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (driver.name_acronym.trim()) {
|
||||
patterns.push({
|
||||
pattern: driver.name_acronym.toLowerCase(),
|
||||
tag: {
|
||||
...base,
|
||||
key: `driver:${driver.name_acronym}`,
|
||||
label: driver.name_acronym,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
|
||||
return patterns
|
||||
}
|
||||
|
||||
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||
return a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag items via case-insensitive substring match on title + summary.
|
||||
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
|
||||
*
|
||||
* Known limitations: common-word last names, team names inside other words,
|
||||
* and driver TLAs that appear in unrelated acronyms can false-positive.
|
||||
*/
|
||||
export function tagItems(
|
||||
items: NewsItem[],
|
||||
drivers: ChampHubDriver[],
|
||||
teams: ChampHubTeam[],
|
||||
): TaggedNewsItem[] {
|
||||
const patterns = buildTagPatterns(drivers, teams)
|
||||
|
||||
return items.map((item) => {
|
||||
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
|
||||
const accepted: MatchCandidate[] = []
|
||||
|
||||
for (const { pattern, tag } of patterns) {
|
||||
if (!pattern) continue
|
||||
let from = 0
|
||||
while (from <= haystack.length - pattern.length) {
|
||||
const index = haystack.indexOf(pattern, from)
|
||||
if (index === -1) break
|
||||
const candidate: MatchCandidate = {
|
||||
start: index,
|
||||
end: index + pattern.length,
|
||||
tag,
|
||||
}
|
||||
if (!accepted.some((match) => overlaps(match, candidate))) {
|
||||
accepted.push(candidate)
|
||||
}
|
||||
from = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
const tagsByKey = new Map<string, DigestTag>()
|
||||
for (const match of accepted) {
|
||||
tagsByKey.set(match.tag.key, match.tag)
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function activeDigestWindow(
|
||||
windows: GPWindow[],
|
||||
meetings: Meeting[],
|
||||
now: Date,
|
||||
): GPWindow | null {
|
||||
const next = nextUpcomingMeeting(meetings, now)
|
||||
if (next) {
|
||||
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
|
||||
}
|
||||
const recent = mostRecentPastMeeting(meetings, now)
|
||||
if (recent) {
|
||||
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
|
||||
}
|
||||
return windows[0] ?? null
|
||||
}
|
||||
|
||||
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
|
||||
const last = mostRecentPastMeeting(meetings, now)
|
||||
if (last) return last.meeting_name
|
||||
const next = nextUpcomingMeeting(meetings, now)
|
||||
if (next) return `Before ${next.meeting_name}`
|
||||
return 'Pre-season'
|
||||
}
|
||||
|
||||
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
|
||||
if (!window) return []
|
||||
return items.filter((item) => {
|
||||
const published = itemPublishedAt(item)
|
||||
return published != null && itemInWindow(published, window)
|
||||
})
|
||||
}
|
||||
|
||||
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
|
||||
const counts = new Map<string, { tag: DigestTag; count: number }>()
|
||||
for (const item of items) {
|
||||
for (const tag of item.tags) {
|
||||
const current = counts.get(tag.key)
|
||||
if (current) current.count += 1
|
||||
else counts.set(tag.key, { tag, count: 1 })
|
||||
}
|
||||
}
|
||||
return [...counts.values()]
|
||||
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
|
||||
.slice(0, limit)
|
||||
.map((entry) => entry.tag)
|
||||
}
|
||||
|
||||
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
|
||||
if (!tagKey) return items
|
||||
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
|
||||
}
|
||||
|
||||
export function tagColour(colour: string): string {
|
||||
if (!colour) return 'var(--text-3)'
|
||||
return colour.startsWith('#') ? colour : `#${colour}`
|
||||
}
|
||||
|
||||
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
|
||||
return [...buckets].sort((a, b) => {
|
||||
const left = a.window.start?.getTime() ?? 0
|
||||
const right = b.window.start?.getTime() ?? 0
|
||||
return right - left
|
||||
})
|
||||
}
|
||||
69
frontend/src/lib/driverProfile.ts
Normal file
69
frontend/src/lib/driverProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DriverSummaryRound } from '../types'
|
||||
|
||||
/** One round mapped for the grid-vs-finish chart and results table. */
|
||||
export interface RoundDelta {
|
||||
round: number // 1-based round index within the completed season
|
||||
label: string // short GP label, e.g. "Bahrain"
|
||||
grid: number | null
|
||||
finish: number | null
|
||||
/** grid − finish: positive = places gained on Sunday, null when unknowable. */
|
||||
delta: number | null
|
||||
points: number
|
||||
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
|
||||
}
|
||||
|
||||
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
|
||||
export function shortGpLabel(meetingName: string): string {
|
||||
const short = meetingName
|
||||
.replace(/\s*grand prix\s*/i, ' ')
|
||||
.replace(/\s+gp\s*$/i, ' ')
|
||||
.trim()
|
||||
return short || meetingName
|
||||
}
|
||||
|
||||
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
|
||||
if (r.dns) return 'dns'
|
||||
if (r.dsq) return 'dsq'
|
||||
if (r.dnf) return 'dnf'
|
||||
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
|
||||
return 'classified'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
|
||||
* (driver absent, pit-lane start, …) and become null; a delta is only computed
|
||||
* when both ends are known and the driver actually started the race.
|
||||
*/
|
||||
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
|
||||
return rounds.map((r, i) => {
|
||||
const status = roundStatus(r)
|
||||
const grid = r.grid_position > 0 ? r.grid_position : null
|
||||
const finish = r.race_position > 0 ? r.race_position : null
|
||||
const delta =
|
||||
grid != null && finish != null && status !== 'dns' && status !== 'absent'
|
||||
? grid - finish
|
||||
: null
|
||||
return {
|
||||
round: i + 1,
|
||||
label: shortGpLabel(r.meeting_name),
|
||||
grid,
|
||||
finish,
|
||||
delta,
|
||||
points: r.points,
|
||||
status,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 'P4' for known positions, '—' when unknown. */
|
||||
export function formatPosition(pos: number | null): string {
|
||||
return pos != null && pos > 0 ? `P${pos}` : '—'
|
||||
}
|
||||
|
||||
/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */
|
||||
export function formatDelta(delta: number | null): string {
|
||||
if (delta == null) return '—'
|
||||
if (delta > 0) return `+${delta}`
|
||||
if (delta < 0) return `−${Math.abs(delta)}`
|
||||
return '='
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
||||
return name.slice(0, 3).toUpperCase() || '—'
|
||||
}
|
||||
|
||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
||||
export function countryFlag(
|
||||
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||
): string {
|
||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||
const code = meeting?.country_code?.toUpperCase()
|
||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||
|
||||
123
frontend/src/lib/preview.ts
Normal file
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||
import {
|
||||
currentAndNextSession,
|
||||
nextUpcomingMeeting,
|
||||
sessionStartTime,
|
||||
sortSessionsByStart,
|
||||
} from './schedule'
|
||||
|
||||
export const RACE_WIN_POINTS = 25
|
||||
export const SPRINT_WIN_POINTS = 8
|
||||
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||
|
||||
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||
const active = meetings.filter((m) => !m.is_cancelled)
|
||||
return nextUpcomingMeeting(active, now)
|
||||
}
|
||||
|
||||
export function findPriorYearMeetingByCircuit(
|
||||
priorYearMeetings: Meeting[],
|
||||
circuitKey: number | undefined,
|
||||
): Meeting | null {
|
||||
if (!circuitKey) return null
|
||||
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||
}
|
||||
|
||||
export function findRaceSession(sessions: Session[]): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
return (
|
||||
sorted.find((s) => {
|
||||
const type = s.session_type?.toLowerCase() ?? ''
|
||||
return type.includes('race') && !type.includes('sprint')
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||
return sessions.some((s) => {
|
||||
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||
})
|
||||
}
|
||||
|
||||
export interface PodiumPlace {
|
||||
position: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export interface PolePosition {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
}
|
||||
|
||||
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||
return results
|
||||
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((r) => ({
|
||||
position: r.position,
|
||||
name_acronym: r.name_acronym,
|
||||
full_name: r.full_name,
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||
const pole = grid.find((g) => g.position === 1)
|
||||
if (!pole) return null
|
||||
return {
|
||||
name_acronym: pole.name_acronym,
|
||||
full_name: pole.full_name,
|
||||
team_name: pole.team_name,
|
||||
team_colour: pole.team_colour,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TitleFightDriver {
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_colour: string
|
||||
points: number
|
||||
position: number
|
||||
gapToLeader: number
|
||||
gapAfterRaceWin: number
|
||||
gapAfterSprintWeekendMax: number
|
||||
}
|
||||
|
||||
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||
if (hub.drivers.length === 0) return []
|
||||
const leader = hub.drivers[0]
|
||||
return hub.drivers.slice(0, 3).map((d) => ({
|
||||
name_acronym: d.name_acronym,
|
||||
full_name: d.full_name,
|
||||
team_colour: d.team_colour,
|
||||
points: d.points,
|
||||
position: d.position,
|
||||
gapToLeader: leader.points - d.points,
|
||||
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||
}))
|
||||
}
|
||||
|
||||
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
const { next } = currentAndNextSession(sorted, now)
|
||||
if (next) return next
|
||||
for (const session of sorted) {
|
||||
const start = sessionStartTime(session)
|
||||
if (start && start > now) return session
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatPointsGap(gap: number): string {
|
||||
if (gap === 0) return 'LEADER'
|
||||
const sign = gap > 0 ? '+' : ''
|
||||
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||
}
|
||||
75
frontend/src/lib/replay.ts
Normal file
75
frontend/src/lib/replay.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
|
||||
import { canvasToSvg, normalizeRawPoint } from './trackmap'
|
||||
|
||||
export interface ReplayFramePair {
|
||||
previous: ReplayFrame | null
|
||||
next: ReplayFrame | null
|
||||
}
|
||||
|
||||
export function lookupReplayFramePair(
|
||||
frames: ReadonlyArray<ReplayFrame>,
|
||||
tMs: number,
|
||||
): ReplayFramePair {
|
||||
if (frames.length === 0) return { previous: null, next: null }
|
||||
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
|
||||
const last = frames[frames.length - 1]
|
||||
if (tMs >= last.t) return { previous: last, next: last }
|
||||
|
||||
let lo = 0
|
||||
let hi = frames.length - 1
|
||||
while (lo <= hi) {
|
||||
const mid = Math.floor((lo + hi) / 2)
|
||||
const frame = frames[mid]
|
||||
if (frame.t === tMs) return { previous: frame, next: frame }
|
||||
if (frame.t < tMs) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
|
||||
}
|
||||
|
||||
export function interpolateReplayCars(
|
||||
frames: ReadonlyArray<ReplayFrame>,
|
||||
tMs: number,
|
||||
): Record<string, ReplayCarPosition> {
|
||||
const { previous, next } = lookupReplayFramePair(frames, tMs)
|
||||
if (!previous && !next) return {}
|
||||
if (!previous) return next?.cars ?? {}
|
||||
if (!next || previous.t === next.t) return previous.cars
|
||||
|
||||
const span = next.t - previous.t
|
||||
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
|
||||
const cars: Record<string, ReplayCarPosition> = {}
|
||||
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
|
||||
|
||||
for (const number of numbers) {
|
||||
const before = previous.cars[number]
|
||||
const after = next.cars[number]
|
||||
if (before && after) {
|
||||
cars[number] = {
|
||||
x: before.x + (after.x - before.x) * fraction,
|
||||
y: before.y + (after.y - before.y) * fraction,
|
||||
}
|
||||
} else if (before) {
|
||||
cars[number] = before
|
||||
} else if (after) {
|
||||
cars[number] = after
|
||||
}
|
||||
}
|
||||
return cars
|
||||
}
|
||||
|
||||
export function replayCarToSvg(
|
||||
car: ReplayCarPosition,
|
||||
bounds: TrackBounds,
|
||||
): ReplayCarPosition {
|
||||
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
Reference in New Issue
Block a user