421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
import { AppError } from "../security/errors";
|
|
import { isOpenF1Configured } from "./auth";
|
|
import { asRows, latestBy, num, openF1Get, rec, str } from "./client";
|
|
import { loadCircuitMap, projectCars, type CircuitMap } from "./circuit";
|
|
import { getF1News, type F1NewsItem } from "./news";
|
|
|
|
export type F1LiveDriver = {
|
|
position: number;
|
|
driverNumber: number;
|
|
acronym: string;
|
|
fullName: string;
|
|
teamName: string;
|
|
teamColor: string;
|
|
headshotUrl: string | null;
|
|
gap: string;
|
|
interval: string;
|
|
lastLap: string;
|
|
bestLap: string;
|
|
tyre: string;
|
|
tyreAge: number | null;
|
|
laps: number | null;
|
|
};
|
|
|
|
export type F1LiveCar = {
|
|
driverNumber: number;
|
|
acronym: string;
|
|
teamColor: string;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
export type F1LiveTelemetry = {
|
|
driverNumber: number;
|
|
acronym: string;
|
|
speed: number | null;
|
|
throttle: number | null;
|
|
brake: number | null;
|
|
rpm: number | null;
|
|
gear: number | null;
|
|
drs: boolean;
|
|
};
|
|
|
|
export type F1LiveDashboard = {
|
|
live: boolean;
|
|
source: string;
|
|
session: {
|
|
sessionKey: number | null;
|
|
meetingKey: number | null;
|
|
meetingName: string | null;
|
|
sessionName: string | null;
|
|
sessionType: string | null;
|
|
circuitName: string | null;
|
|
countryName: string | null;
|
|
countryCode: string | null;
|
|
location: string | null;
|
|
dateStart: string | null;
|
|
dateEnd: string | null;
|
|
year: number | null;
|
|
currentLap: number | null;
|
|
};
|
|
weather: {
|
|
airTemperature: number | null;
|
|
trackTemperature: number | null;
|
|
humidity: number | null;
|
|
windSpeed: number | null;
|
|
windDirection: number | null;
|
|
rainfall: boolean;
|
|
} | null;
|
|
timing: F1LiveDriver[];
|
|
map: {
|
|
name: string | null;
|
|
path: Array<{ x: number; y: number }>;
|
|
corners: CircuitMap["corners"];
|
|
cars: F1LiveCar[];
|
|
} | null;
|
|
telemetry: F1LiveTelemetry | null;
|
|
raceControl: Array<{
|
|
date: string | null;
|
|
category: string | null;
|
|
flag: string | null;
|
|
message: string;
|
|
driverNumber: number | null;
|
|
}>;
|
|
schedule: Array<{
|
|
sessionName: string;
|
|
dateStart: string | null;
|
|
dateEnd: string | null;
|
|
status: "live" | "done" | "upcoming";
|
|
}>;
|
|
news: F1NewsItem[];
|
|
};
|
|
|
|
const TYRE: Record<string, string> = {
|
|
SOFT: "S",
|
|
MEDIUM: "M",
|
|
HARD: "H",
|
|
INTERMEDIATE: "I",
|
|
WET: "W",
|
|
SUPERSOFT: "SS",
|
|
ULTRASOFT: "US",
|
|
};
|
|
|
|
let snapCache: { at: number; data: F1LiveDashboard } | null = null;
|
|
let inflight: Promise<F1LiveDashboard> | null = null;
|
|
const OF1_MS = 8_000;
|
|
|
|
function formatLap(v: unknown): string {
|
|
const n = num(v);
|
|
if (n == null || n <= 0) return "—";
|
|
const m = Math.floor(n / 60);
|
|
const s = n - m * 60;
|
|
return `${m}:${s.toFixed(3).padStart(6, "0")}`;
|
|
}
|
|
|
|
function formatGap(v: unknown, isLeader: boolean): string {
|
|
if (isLeader) return "—";
|
|
if (typeof v === "string") {
|
|
const t = v.trim();
|
|
if (!t) return "—";
|
|
if (/lap/i.test(t)) return t.replace(/^(\+)?/, "+");
|
|
const n = Number(t);
|
|
if (Number.isFinite(n)) return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
|
|
return t;
|
|
}
|
|
const n = num(v);
|
|
if (n == null) return "—";
|
|
return n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3);
|
|
}
|
|
|
|
function tyreLetter(compound: unknown): string {
|
|
const c = str(compound)?.toUpperCase() || "";
|
|
return TYRE[c] || (c ? c.slice(0, 1) : "—");
|
|
}
|
|
|
|
function isLiveWindow(start: string | null, end: string | null, now = Date.now()): boolean {
|
|
if (!start) return false;
|
|
const a = Date.parse(start);
|
|
const b = end ? Date.parse(end) : a + 3 * 3600_000;
|
|
if (!Number.isFinite(a)) return false;
|
|
const pad = 20 * 60_000;
|
|
return now >= a - pad && now <= (Number.isFinite(b) ? b + pad : a + 4 * 3600_000);
|
|
}
|
|
|
|
async function settled<T>(p: Promise<T>, fallback: T): Promise<T> {
|
|
try {
|
|
return await p;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
|
|
if (!(await isOpenF1Configured())) {
|
|
throw new AppError("NOT_CONFIGURED", "OpenF1 is niet geconfigureerd", 503);
|
|
}
|
|
const now = Date.now();
|
|
const ttl = snapCache?.data.live ? 2_000 : 10_000;
|
|
if (snapCache && now - snapCache.at < ttl) return snapCache.data;
|
|
if (snapCache && now - snapCache.at < 45_000) {
|
|
if (!inflight) {
|
|
inflight = buildDashboard().finally(() => {
|
|
inflight = null;
|
|
});
|
|
}
|
|
return snapCache.data;
|
|
}
|
|
if (inflight) return inflight;
|
|
inflight = buildDashboard().finally(() => {
|
|
inflight = null;
|
|
});
|
|
return inflight;
|
|
}
|
|
|
|
async function buildDashboard(): Promise<F1LiveDashboard> {
|
|
try {
|
|
return await buildDashboardInner();
|
|
} catch (err) {
|
|
if (snapCache) return snapCache.data;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function buildDashboardInner(): Promise<F1LiveDashboard> {
|
|
const sessions = asRows(await openF1Get("sessions", { session_key: "latest" }, OF1_MS));
|
|
const session = rec(sessions[0]);
|
|
const sessionKey = num(session.session_key);
|
|
const meetingKey = num(session.meeting_key);
|
|
if (sessionKey == null) {
|
|
throw new AppError("NOT_FOUND", "Geen OpenF1-sessie gevonden", 404);
|
|
}
|
|
|
|
const dateStart = str(session.date_start);
|
|
const dateEnd = str(session.date_end);
|
|
const live = isLiveWindow(dateStart, dateEnd);
|
|
const locEnd = dateEnd ? Date.parse(dateEnd) : Date.now();
|
|
const locFrom = new Date((live ? Date.now() : locEnd) - (live ? 12_000 : 90_000)).toISOString();
|
|
const locTo = live ? undefined : new Date(locEnd + 2_000).toISOString();
|
|
|
|
const year = num(session.year) ?? new Date().getUTCFullYear();
|
|
const circuitKey = num(session.circuit_key);
|
|
|
|
const [
|
|
driverRows,
|
|
meetingRows,
|
|
positionRows,
|
|
intervalRows,
|
|
lapRows,
|
|
stintRows,
|
|
weatherRows,
|
|
raceControlRows,
|
|
locationRows,
|
|
scheduleRows,
|
|
circuitPack,
|
|
] = await Promise.all([
|
|
settled(openF1Get("drivers", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("meetings", { meeting_key: meetingKey ?? "latest" }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("position", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("intervals", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("laps", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("stints", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("weather", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(openF1Get("race_control", { session_key: sessionKey }, OF1_MS).then(asRows), []),
|
|
settled(
|
|
openF1Get(
|
|
"location",
|
|
{
|
|
session_key: sessionKey,
|
|
"date>=": locFrom,
|
|
...(locTo ? { "date<": locTo } : {}),
|
|
},
|
|
OF1_MS
|
|
).then(asRows),
|
|
[]
|
|
),
|
|
meetingKey
|
|
? settled(openF1Get("sessions", { meeting_key: meetingKey }, OF1_MS).then(asRows), [])
|
|
: Promise.resolve([]),
|
|
circuitKey ? loadCircuitMap(circuitKey, year) : Promise.resolve(null),
|
|
]);
|
|
const news = snapCache?.data.news ?? [];
|
|
void getF1News(12)
|
|
.then((items) => {
|
|
if (snapCache) snapCache.data = { ...snapCache.data, news: items };
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
const meeting = rec(meetingRows[0]);
|
|
const drivers = driverRows.map(rec);
|
|
const lastPos = latestBy(positionRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
|
|
const lastInt = latestBy(intervalRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
|
|
const lastStint = latestBy(stintRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => String(num(r.stint_number) ?? 0));
|
|
const lastLoc = latestBy(locationRows.map(rec), (r) => num(r.driver_number) ?? 0, (r) => str(r.date) || "");
|
|
const lastWeather = weatherRows.length ? rec(weatherRows[weatherRows.length - 1]) : null;
|
|
|
|
const lapsByDriver = new Map<number, Record<string, unknown>[]>();
|
|
for (const row of lapRows.map(rec)) {
|
|
const dn = num(row.driver_number);
|
|
if (dn == null) continue;
|
|
const list = lapsByDriver.get(dn) || [];
|
|
list.push(row);
|
|
lapsByDriver.set(dn, list);
|
|
}
|
|
|
|
const posMap = new Map(lastPos.map((r) => [num(r.driver_number) ?? -1, r]));
|
|
const intMap = new Map(lastInt.map((r) => [num(r.driver_number) ?? -1, r]));
|
|
const stintMap = new Map(lastStint.map((r) => [num(r.driver_number) ?? -1, r]));
|
|
|
|
const timing: F1LiveDriver[] = drivers
|
|
.map((d) => {
|
|
const n = num(d.driver_number) ?? 0;
|
|
const pos = num(posMap.get(n)?.position) ?? 99;
|
|
const laps = lapsByDriver.get(n) || [];
|
|
const last = laps.length ? laps.reduce((a, b) => ((num(a.lap_number) ?? 0) >= (num(b.lap_number) ?? 0) ? a : b)) : null;
|
|
const best = laps
|
|
.map((l) => num(l.lap_duration))
|
|
.filter((v): v is number => v != null && v > 0)
|
|
.sort((a, b) => a - b)[0];
|
|
const iv = intMap.get(n);
|
|
const st = stintMap.get(n);
|
|
const isLeader = pos === 1;
|
|
return {
|
|
position: pos,
|
|
driverNumber: n,
|
|
acronym: str(d.name_acronym) || `#${n}`,
|
|
fullName: [str(d.first_name), str(d.last_name)].filter(Boolean).join(" ") || str(d.full_name) || "",
|
|
teamName: str(d.team_name) || "",
|
|
teamColor: str(d.team_colour) || "888888",
|
|
headshotUrl: str(d.headshot_url),
|
|
gap: formatGap(iv?.gap_to_leader, isLeader),
|
|
interval: formatGap(iv?.interval, isLeader),
|
|
lastLap: formatLap(last?.lap_duration),
|
|
bestLap: formatLap(best),
|
|
tyre: tyreLetter(st?.compound),
|
|
tyreAge: num(st?.tyre_age_at_start) != null && last?.lap_number != null
|
|
? (num(st?.tyre_age_at_start) || 0) + Math.max(0, (num(last.lap_number) || 1) - (num(st?.lap_start) || 1))
|
|
: num(st?.tyre_age_at_start),
|
|
laps: num(last?.lap_number),
|
|
};
|
|
})
|
|
.sort((a, b) => a.position - b.position);
|
|
|
|
const currentLap = timing.reduce((m, d) => Math.max(m, d.laps ?? 0), 0) || null;
|
|
const leader = timing[0] || null;
|
|
|
|
let telemetry: F1LiveTelemetry | null = null;
|
|
if (leader) {
|
|
const carRows = await settled(
|
|
openF1Get("car_data", {
|
|
session_key: sessionKey,
|
|
driver_number: leader.driverNumber,
|
|
"date>=": locFrom,
|
|
...(locTo ? { "date<": locTo } : {}),
|
|
}).then(asRows),
|
|
[]
|
|
);
|
|
const lastCar = carRows.length ? rec(carRows[carRows.length - 1]) : null;
|
|
if (lastCar) {
|
|
const drs = num(lastCar.drs) ?? 0;
|
|
telemetry = {
|
|
driverNumber: leader.driverNumber,
|
|
acronym: leader.acronym,
|
|
speed: num(lastCar.speed),
|
|
throttle: num(lastCar.throttle),
|
|
brake: num(lastCar.brake),
|
|
rpm: num(lastCar.rpm),
|
|
gear: num(lastCar.n_gear),
|
|
drs: drs >= 10,
|
|
};
|
|
}
|
|
}
|
|
|
|
const locByNum = new Map(lastLoc.map((r) => [num(r.driver_number) ?? -1, r]));
|
|
const carsRaw = timing
|
|
.map((d) => {
|
|
const loc = locByNum.get(d.driverNumber);
|
|
const x = num(loc?.x);
|
|
const y = num(loc?.y);
|
|
if (x == null || y == null) return null;
|
|
return { driverNumber: d.driverNumber, x, y };
|
|
})
|
|
.filter((c): c is { driverNumber: number; x: number; y: number } => c != null);
|
|
|
|
const projected = circuitPack ? projectCars(carsRaw, circuitPack.raw) : [];
|
|
const projMap = new Map(projected.map((c) => [c.driverNumber, c]));
|
|
const cars: F1LiveCar[] = timing
|
|
.map((d) => {
|
|
const p = projMap.get(d.driverNumber);
|
|
if (!p) return null;
|
|
return { driverNumber: d.driverNumber, acronym: d.acronym, teamColor: d.teamColor, x: p.x, y: p.y };
|
|
})
|
|
.filter((c): c is F1LiveCar => c != null);
|
|
|
|
const now = Date.now();
|
|
const schedule = scheduleRows
|
|
.map(rec)
|
|
.sort((a, b) => (str(a.date_start) || "").localeCompare(str(b.date_start) || ""))
|
|
.map((s) => {
|
|
const name = str(s.session_name) || str(s.session_type) || "Sessie";
|
|
const start = str(s.date_start);
|
|
const end = str(s.date_end);
|
|
const status: "live" | "done" | "upcoming" = isLiveWindow(start, end, now)
|
|
? "live"
|
|
: start && Date.parse(start) > now
|
|
? "upcoming"
|
|
: "done";
|
|
return { sessionName: name, dateStart: start, dateEnd: end, status };
|
|
});
|
|
|
|
const raceControl = raceControlRows
|
|
.map(rec)
|
|
.slice(-12)
|
|
.reverse()
|
|
.map((r) => ({
|
|
date: str(r.date),
|
|
category: str(r.category),
|
|
flag: str(r.flag),
|
|
message: str(r.message) || "",
|
|
driverNumber: num(r.driver_number),
|
|
}))
|
|
.filter((r) => r.message);
|
|
|
|
const data: F1LiveDashboard = {
|
|
live,
|
|
source: "live",
|
|
session: {
|
|
sessionKey,
|
|
meetingKey,
|
|
meetingName: str(meeting.meeting_name) || str(meeting.meeting_official_name),
|
|
sessionName: str(session.session_name),
|
|
sessionType: str(session.session_type),
|
|
circuitName: str(session.circuit_short_name) || str(meeting.circuit_short_name),
|
|
countryName: str(session.country_name) || str(meeting.country_name),
|
|
countryCode: str(session.country_code) || str(meeting.country_code),
|
|
location: str(session.location) || str(meeting.location),
|
|
dateStart,
|
|
dateEnd,
|
|
year,
|
|
currentLap,
|
|
},
|
|
weather: lastWeather
|
|
? {
|
|
airTemperature: num(lastWeather.air_temperature),
|
|
trackTemperature: num(lastWeather.track_temperature),
|
|
humidity: num(lastWeather.humidity),
|
|
windSpeed: num(lastWeather.wind_speed),
|
|
windDirection: num(lastWeather.wind_direction),
|
|
rainfall: (num(lastWeather.rainfall) ?? 0) > 0,
|
|
}
|
|
: null,
|
|
timing,
|
|
map: circuitPack
|
|
? { name: circuitPack.map.name, path: circuitPack.map.path, corners: circuitPack.map.corners, cars }
|
|
: null,
|
|
telemetry,
|
|
raceControl,
|
|
schedule,
|
|
news,
|
|
};
|
|
snapCache = { at: Date.now(), data };
|
|
return data;
|
|
}
|