836 lines
28 KiB
JavaScript
836 lines
28 KiB
JavaScript
export function getSessionTypeLabel(type, { t }) {
|
|
const key = `session.${String(type || "").toLowerCase()}`;
|
|
const translated = t(key);
|
|
return translated === key ? String(type || "") : translated;
|
|
}
|
|
|
|
|
|
|
|
export function getStatusLabel(status, { t }) {
|
|
const key = `status.${String(status || "").toLowerCase()}`;
|
|
const translated = t(key);
|
|
return translated === key ? String(status || "") : translated;
|
|
}
|
|
|
|
|
|
|
|
export function isUntimedSession(session) {
|
|
return String(session?.type || "").toLowerCase() === "open_practice";
|
|
}
|
|
|
|
|
|
|
|
export function getActiveSession({ state }) {
|
|
return state.sessions.find((s) => s.id === state.activeSessionId) || null;
|
|
}
|
|
|
|
|
|
|
|
export function getSessionTargetMs(session, { isUntimedSession }) {
|
|
if (isUntimedSession(session)) {
|
|
return null;
|
|
}
|
|
return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000;
|
|
}
|
|
|
|
|
|
|
|
export function getSessionLapWindow(session, { state }) {
|
|
const event = state.events.find((item) => item.id === session?.eventId);
|
|
if (event?.mode !== "race") {
|
|
return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY };
|
|
}
|
|
const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0);
|
|
const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000);
|
|
const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY;
|
|
return { minLapMs, maxLapMs };
|
|
}
|
|
|
|
|
|
|
|
export function isCountedPassing(passing) {
|
|
return passing?.validLap !== false;
|
|
}
|
|
|
|
|
|
|
|
export function getVisiblePassings(result, { isCountedPassing }) {
|
|
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
|
|
}
|
|
|
|
|
|
|
|
export function getPassingValidationLabel(passing, { t }) {
|
|
if (passing?.validLap === false) {
|
|
if (passing.invalidReason === "below_min") {
|
|
return t("timing.invalid_short");
|
|
}
|
|
if (passing.invalidReason === "manual_invalid") {
|
|
return t("timing.invalid_manual");
|
|
}
|
|
return t("timing.invalid_long");
|
|
}
|
|
return t("timing.valid_passing");
|
|
}
|
|
|
|
|
|
|
|
export function getSessionTiming(session, nowTs = Date.now(), { getSessionTargetMs }) {
|
|
const targetMs = getSessionTargetMs(session);
|
|
const startedAt = Number(session?.startedAt || 0);
|
|
const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0;
|
|
const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000;
|
|
const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0;
|
|
const followUpActive = Boolean(followUpStartedAt && followUpMs > 0);
|
|
const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0;
|
|
return {
|
|
targetMs,
|
|
elapsedMs,
|
|
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
|
|
untimed: targetMs === null,
|
|
followUpActive,
|
|
followUpRemainingMs,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function ensureSessionResult(sessionId, { state }) {
|
|
if (!state.resultsBySession[sessionId]) {
|
|
state.resultsBySession[sessionId] = {
|
|
passings: [],
|
|
competitors: {},
|
|
adjustments: [],
|
|
};
|
|
}
|
|
if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) {
|
|
state.resultsBySession[sessionId].adjustments = [];
|
|
}
|
|
return state.resultsBySession[sessionId];
|
|
}
|
|
|
|
|
|
|
|
export function buildLeaderboard(session, { ensureSessionResult, getSessionTargetMs, getCompetitorPassings, isCountedPassing, getCompetitorElapsedMs, getCompetitorSeedMetric, getSessionLapWindow, formatSeedMetric, formatRaceClock, formatLeaderboardGap, formatLapDelta, formatLap, t }) {
|
|
const result = ensureSessionResult(session.id);
|
|
const sessionType = String(session.type || "").toLowerCase();
|
|
const targetMs = getSessionTargetMs(session);
|
|
const useTargetTieBreak = session.status === "finished";
|
|
const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0;
|
|
const isFreePractice = sessionType === "free_practice";
|
|
const isOpenPractice = sessionType === "open_practice";
|
|
const isRollingPractice = isFreePractice || isOpenPractice;
|
|
const { maxLapMs } = getSessionLapWindow(session);
|
|
const nowTs = Date.now();
|
|
const rows = Object.values(result.competitors).map((row) => {
|
|
const allPassings = getCompetitorPassings(session, row, { includeInvalid: true });
|
|
const passings = allPassings.filter((passing) => isCountedPassing(passing));
|
|
const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null;
|
|
const latestPassing = passings.length ? passings[passings.length - 1] : null;
|
|
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
|
|
const rawElapsedMs = lastPassingTs
|
|
? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs))
|
|
: getCompetitorElapsedMs(session, row);
|
|
const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0;
|
|
const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0;
|
|
const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs);
|
|
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
|
|
const seedMetric = getCompetitorSeedMetric(session, row);
|
|
const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null;
|
|
const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0;
|
|
const bestLapMs = Number(row.bestLapMs || 0) || 0;
|
|
const lapDeltaMs =
|
|
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
|
|
const predictionBaseMs =
|
|
lastLapMs > 0
|
|
? lastLapMs
|
|
: bestLapMs > 0
|
|
? bestLapMs
|
|
: null;
|
|
const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0;
|
|
const predictionStaleMs = predictionBaseMs
|
|
? Math.max(
|
|
predictionBaseMs * 2,
|
|
Number.isFinite(maxLapMs) && maxLapMs > 0 ? maxLapMs : 0,
|
|
15000
|
|
)
|
|
: 0;
|
|
const predictionActive = Boolean(predictionBaseMs && lastPassingTs && currentLapElapsedMs <= predictionStaleMs);
|
|
const predictedRemainingMs = predictionActive ? predictionBaseMs - currentLapElapsedMs : null;
|
|
const predictedProgress = predictionActive ? currentLapElapsedMs / predictionBaseMs : 0;
|
|
const predictionTone =
|
|
!predictionActive || predictedProgress <= 0.85
|
|
? "good"
|
|
: predictedProgress <= 1
|
|
? "warn"
|
|
: "late";
|
|
const invalidPending = latestAnyPassing?.validLap === false;
|
|
return {
|
|
...row,
|
|
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
|
|
lastLapMs,
|
|
bestLapMs,
|
|
lastTimestamp: lastPassingTs || row.lastTimestamp,
|
|
totalElapsedMs,
|
|
manualLapAdjustment,
|
|
manualTimeAdjustmentMs,
|
|
distanceToTargetMs,
|
|
seedMetric,
|
|
previousLapMs,
|
|
lapDeltaMs,
|
|
predictedRemainingMs,
|
|
predictedProgress,
|
|
predictionTone,
|
|
invalidPending,
|
|
invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "",
|
|
invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0,
|
|
comparisonMs:
|
|
isRollingPractice
|
|
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
|
|
: useSeedRanking && seedMetric
|
|
? seedMetric.comparableMs
|
|
: totalElapsedMs,
|
|
resultDisplay:
|
|
isRollingPractice
|
|
? formatLap(bestLapMs || lastLapMs)
|
|
: useSeedRanking && seedMetric
|
|
? formatSeedMetric(seedMetric)
|
|
: `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`,
|
|
};
|
|
});
|
|
|
|
rows.sort((a, b) => {
|
|
if (isRollingPractice) {
|
|
if (a.comparisonMs !== b.comparisonMs) {
|
|
return a.comparisonMs - b.comparisonMs;
|
|
}
|
|
if (b.laps !== a.laps) {
|
|
return b.laps - a.laps;
|
|
}
|
|
return (b.lastTimestamp || 0) - (a.lastTimestamp || 0);
|
|
}
|
|
if (useSeedRanking) {
|
|
if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) {
|
|
return a.seedMetric.comparableMs - b.seedMetric.comparableMs;
|
|
}
|
|
if (a.seedMetric && !b.seedMetric) {
|
|
return -1;
|
|
}
|
|
if (!a.seedMetric && b.seedMetric) {
|
|
return 1;
|
|
}
|
|
if (b.laps !== a.laps) {
|
|
return b.laps - a.laps;
|
|
}
|
|
return a.totalElapsedMs - b.totalElapsedMs;
|
|
}
|
|
|
|
if (b.laps !== a.laps) {
|
|
return b.laps - a.laps;
|
|
}
|
|
if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) {
|
|
return a.distanceToTargetMs - b.distanceToTargetMs;
|
|
}
|
|
return a.totalElapsedMs - b.totalElapsedMs;
|
|
});
|
|
|
|
const leader = rows[0];
|
|
return rows.map((row, index) => {
|
|
if (!leader) {
|
|
return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" };
|
|
}
|
|
return {
|
|
...row,
|
|
gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
|
|
leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
|
|
gapAhead: formatLeaderboardGap(row, rows[index - 1], {
|
|
useSeedRanking,
|
|
useTargetTieBreak,
|
|
isFreePractice: isRollingPractice,
|
|
selfLabel: t("status.leader"),
|
|
}),
|
|
lapDelta: formatLapDelta(row.lapDeltaMs),
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
|
|
export function formatLapDelta(deltaMs, { formatLap }) {
|
|
if (!deltaMs && deltaMs !== 0) {
|
|
return "-";
|
|
}
|
|
const sign = deltaMs <= 0 ? "-" : "+";
|
|
return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`;
|
|
}
|
|
|
|
|
|
|
|
export function formatLeaderboardGap(row, referenceRow, options = {}, { t }) {
|
|
if (!referenceRow) {
|
|
return "-";
|
|
}
|
|
if (row.key === referenceRow.key) {
|
|
if (options.isFreePractice) {
|
|
return t("status.free_practice");
|
|
}
|
|
return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader"));
|
|
}
|
|
if (options.isFreePractice) {
|
|
if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) {
|
|
return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`;
|
|
}
|
|
return "-";
|
|
}
|
|
if (options.useSeedRanking) {
|
|
if (referenceRow.seedMetric && row.seedMetric) {
|
|
const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs);
|
|
return `+${(seedGap / 1000).toFixed(3)}s`;
|
|
}
|
|
return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`;
|
|
}
|
|
const lapDiff = (referenceRow.laps || 0) - (row.laps || 0);
|
|
if (lapDiff > 0) {
|
|
return `+${lapDiff}L`;
|
|
}
|
|
const referenceValue = options.useTargetTieBreak ? referenceRow.distanceToTargetMs : referenceRow.totalElapsedMs;
|
|
const rowValue = options.useTargetTieBreak ? row.distanceToTargetMs : row.totalElapsedMs;
|
|
const timeGap = Math.max(0, rowValue - referenceValue);
|
|
return `+${(timeGap / 1000).toFixed(3)}s`;
|
|
}
|
|
|
|
|
|
|
|
export function getCompetitorElapsedMs(session, row) {
|
|
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
|
|
if (!startTs || !row?.lastTimestamp) {
|
|
return 0;
|
|
}
|
|
return Math.max(0, row.lastTimestamp - startTs);
|
|
}
|
|
|
|
|
|
|
|
export function getCompetitorPassings(session, row, options = {}, { ensureSessionResult, isCountedPassing }) {
|
|
const result = ensureSessionResult(session.id);
|
|
return result.passings
|
|
.filter((passing) => {
|
|
if (!options.includeInvalid && !isCountedPassing(passing)) {
|
|
return false;
|
|
}
|
|
if (passing.competitorKey) {
|
|
return passing.competitorKey === row.key;
|
|
}
|
|
return (
|
|
String(passing.transponder || "") === String(row.transponder || "") &&
|
|
String(passing.driverId || "") === String(row.driverId || "") &&
|
|
String(passing.carId || "") === String(row.carId || "")
|
|
);
|
|
})
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
}
|
|
|
|
|
|
|
|
export function getCompetitorSeedMetric(session, row, { getCompetitorPassings }) {
|
|
const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0);
|
|
if (lapCount <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase())
|
|
? String(session.seedMethod).toLowerCase()
|
|
: "best_sum";
|
|
const laps = getCompetitorPassings(session, row)
|
|
.map((passing) => Number(passing.lapMs || 0))
|
|
.filter((lapMs) => lapMs > 500);
|
|
|
|
if (laps.length < lapCount) {
|
|
return null;
|
|
}
|
|
|
|
let selected = [];
|
|
if (method === "consecutive") {
|
|
let bestWindow = null;
|
|
for (let index = 0; index <= laps.length - lapCount; index += 1) {
|
|
const window = laps.slice(index, index + lapCount);
|
|
const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0);
|
|
if (!bestWindow || totalMs < bestWindow.totalMs) {
|
|
bestWindow = { laps: window, totalMs };
|
|
}
|
|
}
|
|
if (!bestWindow) {
|
|
return null;
|
|
}
|
|
selected = bestWindow.laps;
|
|
} else {
|
|
selected = [...laps].sort((a, b) => a - b).slice(0, lapCount);
|
|
}
|
|
const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0);
|
|
const averageMs = totalMs / lapCount;
|
|
return {
|
|
lapCount,
|
|
method,
|
|
totalMs,
|
|
averageMs,
|
|
comparableMs: method === "average" ? averageMs : totalMs,
|
|
laps: selected,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
export function getSessionEntrants(session, { state, getEventDrivers }) {
|
|
const event = state.events.find((item) => item.id === session.eventId);
|
|
const eventDrivers = event ? getEventDrivers(event) : state.drivers;
|
|
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
|
|
return eventDrivers;
|
|
}
|
|
return eventDrivers.filter((driver) => session.driverIds.includes(driver.id));
|
|
}
|
|
|
|
|
|
|
|
export function buildPracticeStandings(event, { getSessionsForEvent, buildLeaderboard, getCompetitorSeedMetric, formatSeedMetric, formatRaceClock }) {
|
|
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice");
|
|
const competitorMap = new Map();
|
|
|
|
sessions.forEach((session) => {
|
|
buildLeaderboard(session).forEach((row) => {
|
|
const key = row.driverId || row.key;
|
|
const seedMetric = getCompetitorSeedMetric(session, row);
|
|
const comparableMs = seedMetric?.comparableMs ?? row.bestLapMs ?? row.totalElapsedMs;
|
|
const current = competitorMap.get(key);
|
|
if (!current || comparableMs < current.comparableMs) {
|
|
competitorMap.set(key, {
|
|
key,
|
|
driverId: row.driverId,
|
|
driverName: row.driverName,
|
|
comparableMs,
|
|
resultDisplay: seedMetric ? formatSeedMetric(seedMetric) : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`,
|
|
sourceSessionName: session.name,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return [...competitorMap.values()].sort((a, b) => a.comparableMs - b.comparableMs).map((row, index) => ({
|
|
...row,
|
|
rank: index + 1,
|
|
score: row.resultDisplay,
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
export function getQualifyingPointsValue(place, fieldSize, tableType) {
|
|
const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase())
|
|
? String(tableType).toLowerCase()
|
|
: "rank_low";
|
|
if (normalized === "field_desc") {
|
|
return Math.max(1, Number(fieldSize || 0) - place + 1);
|
|
}
|
|
if (normalized === "ifmar") {
|
|
const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
return scale[place - 1] ?? 0;
|
|
}
|
|
return place;
|
|
}
|
|
|
|
|
|
|
|
export function isHighPointsTable(tableType) {
|
|
return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase());
|
|
}
|
|
|
|
|
|
|
|
export function compareNumberSet(left, right, highWins = false) {
|
|
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
const leftValue = left[i] ?? (highWins ? -Infinity : Infinity);
|
|
const rightValue = right[i] ?? (highWins ? -Infinity : Infinity);
|
|
if (leftValue !== rightValue) {
|
|
return highWins ? rightValue - leftValue : leftValue - rightValue;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
|
|
|
|
export function buildQualifyingTieBreakNote(row, tieBreak, { t, formatLap }) {
|
|
if (tieBreak === "best_lap") {
|
|
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")} • ${formatLap(row.bestSingleLapMs)}`;
|
|
}
|
|
if (tieBreak === "best_round") {
|
|
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")} • ${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`;
|
|
}
|
|
return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")} • ${(row.ranks || []).join(" / ") || "-"}`;
|
|
}
|
|
|
|
|
|
|
|
export function hasQualifyingPrimaryTie(left, right, scoringMode) {
|
|
if (!left || !right) {
|
|
return false;
|
|
}
|
|
if (scoringMode === "points") {
|
|
return left.totalScore === right.totalScore;
|
|
}
|
|
return left.bestRank === right.bestRank;
|
|
}
|
|
|
|
|
|
|
|
export function buildQualifyingStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, isHighPointsTable, getQualifyingPointsValue, compareNumberSet, buildQualifyingTieBreakNote, formatLap, formatRaceClock, t, hasQualifyingPrimaryTie }) {
|
|
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification");
|
|
const scoringMode = event.raceConfig?.qualifyingScoring || "points";
|
|
const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1);
|
|
const pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low";
|
|
const highPointsWin = isHighPointsTable(pointsTable);
|
|
const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds";
|
|
const competitorMap = new Map();
|
|
|
|
sessions.forEach((session) => {
|
|
const rows = buildLeaderboard(session);
|
|
const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0);
|
|
rows.forEach((row, index) => {
|
|
const key = row.driverId || row.key;
|
|
if (!competitorMap.has(key)) {
|
|
competitorMap.set(key, {
|
|
key,
|
|
driverId: row.driverId,
|
|
driverName: row.driverName,
|
|
points: [],
|
|
ranks: [],
|
|
roundMetrics: [],
|
|
bestLaps: [],
|
|
});
|
|
}
|
|
const entry = competitorMap.get(key);
|
|
entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable));
|
|
entry.ranks.push(index + 1);
|
|
entry.roundMetrics.push(row.comparisonMs);
|
|
if (Number.isFinite(row.bestLapMs)) {
|
|
entry.bestLaps.push(row.bestLapMs);
|
|
}
|
|
if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) {
|
|
entry.bestRoundMetricValue = row.comparisonMs;
|
|
entry.bestRoundDisplay = row.resultDisplay;
|
|
}
|
|
entry.bestResultDisplay = row.resultDisplay;
|
|
entry.lastSessionName = session.name;
|
|
entry.sessionCount = (entry.sessionCount || 0) + 1;
|
|
entry.maxFieldSize = entrantCount;
|
|
});
|
|
});
|
|
|
|
const rows = [...competitorMap.values()].map((entry) => {
|
|
const sortedPoints = [...entry.points].sort((a, b) => (highPointsWin ? b - a : a - b));
|
|
const counted = sortedPoints.slice(0, countedRounds);
|
|
const totalScore = counted.reduce((sum, value) => sum + value, 0);
|
|
const bestRank = Math.min(...entry.ranks);
|
|
const bestRoundMetric = Math.min(...entry.roundMetrics);
|
|
const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER;
|
|
return {
|
|
key: entry.key,
|
|
driverId: entry.driverId,
|
|
driverName: entry.driverName,
|
|
ranks: [...entry.ranks].sort((a, b) => a - b),
|
|
countedPoints: counted,
|
|
totalScore,
|
|
bestRank,
|
|
bestRoundMetric,
|
|
bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric),
|
|
bestSingleLapMs,
|
|
score:
|
|
scoringMode === "points"
|
|
? `${totalScore} (${counted.join("+")})`
|
|
: `${bestRank} / ${formatRaceClock(bestRoundMetric)}`,
|
|
};
|
|
});
|
|
|
|
rows.sort((a, b) => {
|
|
if (scoringMode === "points") {
|
|
if (a.totalScore !== b.totalScore) {
|
|
return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore;
|
|
}
|
|
if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) {
|
|
return a.bestSingleLapMs - b.bestSingleLapMs;
|
|
}
|
|
if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) {
|
|
return a.bestRoundMetric - b.bestRoundMetric;
|
|
}
|
|
const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin);
|
|
if (pointDiff !== 0) {
|
|
return pointDiff;
|
|
}
|
|
return a.bestRoundMetric - b.bestRoundMetric;
|
|
}
|
|
|
|
if (a.bestRank !== b.bestRank) {
|
|
return a.bestRank - b.bestRank;
|
|
}
|
|
if (tieBreak === "rounds") {
|
|
const rankDiff = compareNumberSet(a.ranks, b.ranks, false);
|
|
if (rankDiff !== 0) {
|
|
return rankDiff;
|
|
}
|
|
}
|
|
if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) {
|
|
return a.bestSingleLapMs - b.bestSingleLapMs;
|
|
}
|
|
return a.bestRoundMetric - b.bestRoundMetric;
|
|
});
|
|
|
|
rows.forEach((row, index) => {
|
|
row.tieBreakWonAgainst = "";
|
|
row.tieBreakLostAgainst = "";
|
|
if (index === 0) {
|
|
return;
|
|
}
|
|
const previous = rows[index - 1];
|
|
if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) {
|
|
return;
|
|
}
|
|
previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver");
|
|
row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver");
|
|
});
|
|
|
|
return rows.map((row, index) => ({
|
|
...row,
|
|
rank: index + 1,
|
|
scoreNote: [
|
|
buildQualifyingTieBreakNote(row, tieBreak),
|
|
row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "",
|
|
row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" • "),
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
export function formatTeamActiveMemberLabel(rowOrPassing) {
|
|
if (!rowOrPassing) {
|
|
return "-";
|
|
}
|
|
const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean);
|
|
return parts.join(" • ") || rowOrPassing.subLabel || "-";
|
|
}
|
|
|
|
|
|
|
|
export function buildTeamRaceStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionSortWeight }) {
|
|
return getSessionsForEvent(event.id)
|
|
.filter((session) => session.type === "team_race")
|
|
.sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name)))
|
|
.map((session) => ({
|
|
session,
|
|
rows: buildLeaderboard(session),
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
export function buildTeamStintLog(session, row, { getCompetitorPassings, getSessionLapWindow, formatTeamActiveMemberLabel }) {
|
|
const passings = getCompetitorPassings(session, row);
|
|
if (!passings.length) {
|
|
return [];
|
|
}
|
|
|
|
const { maxLapMs } = getSessionLapWindow(session);
|
|
const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY;
|
|
const stints = [];
|
|
let current = null;
|
|
|
|
passings.forEach((passing) => {
|
|
const memberLabel = formatTeamActiveMemberLabel(passing);
|
|
const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`;
|
|
const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs;
|
|
if (!current || current.memberKey !== memberKey || gapBreak) {
|
|
current = {
|
|
memberKey,
|
|
memberLabel,
|
|
driverName: passing.driverName || "-",
|
|
carName: passing.carName || "-",
|
|
startTs: passing.timestamp,
|
|
endTs: passing.timestamp,
|
|
laps: 1,
|
|
};
|
|
stints.push(current);
|
|
return;
|
|
}
|
|
current.endTs = passing.timestamp;
|
|
current.laps += 1;
|
|
});
|
|
|
|
return stints.map((stint, index) => ({
|
|
...stint,
|
|
index: index + 1,
|
|
durationMs: Math.max(0, stint.endTs - stint.startTs),
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
export function getSessionGridEntries(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder }) {
|
|
if (!session) {
|
|
return [];
|
|
}
|
|
|
|
if (session.mode === "track") {
|
|
return (session.assignments || []).map((assignment, index) => {
|
|
const driver = state.drivers.find((item) => item.id === assignment.driverId);
|
|
const car = state.cars.find((item) => item.id === assignment.carId);
|
|
return {
|
|
slot: index + 1,
|
|
name: driver?.name || t("common.unknown_driver"),
|
|
meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"),
|
|
};
|
|
});
|
|
}
|
|
|
|
if (session.type === "team_race") {
|
|
const event = state.events.find((item) => item.id === session.eventId);
|
|
return getEventTeams(event).map((team, index) => ({
|
|
slot: index + 1,
|
|
name: team.name,
|
|
meta:
|
|
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") ||
|
|
team.carIds
|
|
.map((carId) => {
|
|
const car = state.cars.find((item) => item.id === carId);
|
|
return car ? `${car.name} (${car.transponder || "-"})` : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(", ") ||
|
|
"-",
|
|
}));
|
|
}
|
|
|
|
return getSessionGridOrder(session).map((driverId, index) => {
|
|
const driver = state.drivers.find((item) => item.id === driverId);
|
|
return {
|
|
slot: index + 1,
|
|
name: driver?.name || t("common.unknown_driver"),
|
|
meta: driver?.transponder || "-",
|
|
};
|
|
});
|
|
}
|
|
|
|
|
|
|
|
export function getSessionGridOrder(session) {
|
|
if (!session) {
|
|
return [];
|
|
}
|
|
if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) {
|
|
return session.manualGridIds;
|
|
}
|
|
return Array.isArray(session.driverIds) ? session.driverIds : [];
|
|
}
|
|
|
|
|
|
|
|
export function ensureSessionDriverOrder(session, { getSessionEntrants }) {
|
|
if (!session || session.mode !== "race") {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
|
|
session.driverIds = getSessionEntrants(session)
|
|
.map((driver) => driver.id)
|
|
.filter(Boolean);
|
|
}
|
|
if (!Array.isArray(session.manualGridIds) || !session.manualGridIds.length) {
|
|
session.manualGridIds = [...session.driverIds];
|
|
}
|
|
return session.manualGridIds;
|
|
}
|
|
|
|
|
|
|
|
export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard }) {
|
|
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final");
|
|
const groupedByMain = new Map();
|
|
const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1);
|
|
|
|
sessions.forEach((session) => {
|
|
const rows = buildLeaderboard(session);
|
|
const mainMatch = String(session.name || "").match(/^([A-Z])/i);
|
|
const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A";
|
|
if (!groupedByMain.has(mainKey)) {
|
|
groupedByMain.set(mainKey, new Map());
|
|
}
|
|
const competitorMap = groupedByMain.get(mainKey);
|
|
rows.forEach((row, index) => {
|
|
const key = row.driverId || row.key;
|
|
if (!competitorMap.has(key)) {
|
|
competitorMap.set(key, {
|
|
key,
|
|
mainKey,
|
|
driverId: row.driverId,
|
|
driverName: row.driverName,
|
|
legRanks: [],
|
|
bestElapsedMs: [],
|
|
});
|
|
}
|
|
const entry = competitorMap.get(key);
|
|
entry.legRanks.push(index + 1);
|
|
entry.bestElapsedMs.push(row.totalElapsedMs);
|
|
});
|
|
});
|
|
|
|
const mains = [...groupedByMain.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const rows = [];
|
|
mains.forEach(([mainKey, competitorMap], mainIndex) => {
|
|
const mainRows = [...competitorMap.values()].map((entry) => {
|
|
const sortedRanks = [...entry.legRanks].sort((a, b) => a - b);
|
|
const countedRanks = sortedRanks.slice(0, countedFinalLegs);
|
|
return {
|
|
mainKey,
|
|
driverId: entry.driverId,
|
|
driverName: entry.driverName,
|
|
finalScore: countedRanks.reduce((sum, value) => sum + value, 0),
|
|
legRanks: sortedRanks,
|
|
bestElapsedMs: Math.min(...entry.bestElapsedMs),
|
|
};
|
|
});
|
|
|
|
mainRows.sort((a, b) => {
|
|
if (a.finalScore !== b.finalScore) {
|
|
return a.finalScore - b.finalScore;
|
|
}
|
|
for (let i = 0; i < Math.max(a.legRanks.length, b.legRanks.length); i += 1) {
|
|
const left = a.legRanks[i] ?? 999;
|
|
const right = b.legRanks[i] ?? 999;
|
|
if (left !== right) {
|
|
return left - right;
|
|
}
|
|
}
|
|
return a.bestElapsedMs - b.bestElapsedMs;
|
|
});
|
|
|
|
mainRows.forEach((row, rowIndex) => {
|
|
rows.push({
|
|
rank: `${mainKey}${rowIndex + 1}`,
|
|
driverId: row.driverId,
|
|
driverName: row.driverName,
|
|
score: `${row.finalScore} (${row.legRanks.join("+")})`,
|
|
orderingGroup: mainIndex,
|
|
orderingIndex: rowIndex,
|
|
});
|
|
});
|
|
});
|
|
|
|
return rows.sort((a, b) => {
|
|
if (a.orderingGroup !== b.orderingGroup) {
|
|
return a.orderingGroup - b.orderingGroup;
|
|
}
|
|
return a.orderingIndex - b.orderingIndex;
|
|
});
|
|
}
|
|
|
|
|