Added intial plugin
This commit is contained in:
213
public/overview.js
Normal file
213
public/overview.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const status = document.querySelector("#status");
|
||||
const driverCount = document.querySelector("#driver-count");
|
||||
const trackName = document.querySelector("#track-name");
|
||||
const sessionType = document.querySelector("#session-type");
|
||||
const timeLeft = document.querySelector("#time-left");
|
||||
const weather = document.querySelector("#weather");
|
||||
const lastUpdate = document.querySelector("#last-update");
|
||||
const leaderboardNote = document.querySelector("#leaderboard-note");
|
||||
const leaderboardBody = document.querySelector("#leaderboard-body");
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function fmt(value, fallback = "-") {
|
||||
if (value === undefined || value === null || value === "" || Number.isNaN(value)) {
|
||||
return `<span class="empty">${fallback}</span>`;
|
||||
}
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function fmtTime(seconds) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds - minutes * 60;
|
||||
return `${minutes}:${remainder.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
function fmtSplit(split) {
|
||||
if (!split || typeof split.delta !== "number" || !Number.isFinite(split.delta)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (split.delta === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
const sign = split.delta > 0 ? "+" : "-";
|
||||
return `${sign}${Math.abs(split.delta).toFixed(3)}`;
|
||||
}
|
||||
|
||||
function sectorSplitForRow(row, session) {
|
||||
const sessionType = String(session?.sessionType || "").toLowerCase();
|
||||
const usePersonalBest = sessionType === "practice" || sessionType === "qualifying";
|
||||
return usePersonalBest
|
||||
? row?.sectorTimeSplit || row?.checkpointSplit
|
||||
: row?.checkpointSplit || row?.sectorTimeSplit;
|
||||
}
|
||||
|
||||
function positionChange(change) {
|
||||
if (!change?.direction) {
|
||||
return '<span class="position-change-placeholder" aria-hidden="true"></span>';
|
||||
}
|
||||
|
||||
const isUp = change.direction === "up";
|
||||
const label = `${isUp ? "Moved up" : "Moved down"} ${change.places ?? 1}`;
|
||||
return `
|
||||
<span class="position-change ${isUp ? "position-up" : "position-down"}" title="${escapeHtml(label)}">
|
||||
${isUp ? "▲" : "▼"}${change.places && change.places > 1 ? escapeHtml(change.places) : ""}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function nationalityBadge(nationality) {
|
||||
if (!nationality?.code) {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
|
||||
const title = `${nationality.country || nationality.code}${nationality.source === "override" ? " (name override)" : ""}`;
|
||||
const flag = flagSvg(nationality.code);
|
||||
return `<span class="nat-badge" title="${escapeHtml(`${title} - ${nationality.code}`)}">${flag || escapeHtml(nationality.code)}</span>`;
|
||||
}
|
||||
|
||||
function carShortName(row) {
|
||||
if (!row?.vehicleShortName) {
|
||||
return '<span class="empty">-</span>';
|
||||
}
|
||||
|
||||
return `<span title="${escapeHtml(row.vehicle || row.vehicleShortName)}">${escapeHtml(row.vehicleShortName)}</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(row) {
|
||||
const status = row?.status;
|
||||
const label = status?.label || (row?.active === false ? "Inactive" : "Active");
|
||||
const tone = status?.tone || (row?.active === false ? "warn" : "good");
|
||||
return `<span class="${tone === "warn" ? "warn" : "good"}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function raceFlagDetails(row) {
|
||||
const flag = row?.flag;
|
||||
if (!flag || row.highestFlag === undefined || row.highestFlag === null) {
|
||||
return `flag ${fmt(row?.highestFlag)}`;
|
||||
}
|
||||
|
||||
const label = flag.observed ? ` ${escapeHtml(flag.observed)}` : "";
|
||||
return `flag ${fmt(row.highestFlag)} c${fmt(flag.colour)} r${fmt(flag.reason)}${label}`;
|
||||
}
|
||||
|
||||
function flagSvg(code) {
|
||||
const iso2ByIso3 = {
|
||||
DEU: "DE",
|
||||
FRA: "FR",
|
||||
GBR: "GB",
|
||||
JPN: "JP",
|
||||
USA: "US"
|
||||
};
|
||||
const iso2 = iso2ByIso3[String(code).toUpperCase()];
|
||||
if (!iso2) return "";
|
||||
|
||||
const flags = {
|
||||
DE: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#000" d="M0 0h60v13.333H0z"/><path fill="#dd0000" d="M0 13.333h60v13.334H0z"/><path fill="#ffce00" d="M0 26.667h60V40H0z"/></svg>`,
|
||||
FR: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#002395" d="M0 0h20v40H0z"/><path fill="#fff" d="M20 0h20v40H20z"/><path fill="#ed2939" d="M40 0h20v40H40z"/></svg>`,
|
||||
GB: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#012169" d="M0 0h60v40H0z"/><path stroke="#fff" stroke-width="8" d="m0 0 60 40M60 0 0 40"/><path stroke="#c8102e" stroke-width="4" d="m0 0 60 40M60 0 0 40"/><path fill="#fff" d="M25 0h10v40H25zM0 15h60v10H0z"/><path fill="#c8102e" d="M27 0h6v40h-6zM0 17h60v6H0z"/></svg>`,
|
||||
JP: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#fff" d="M0 0h60v40H0z"/><circle cx="30" cy="20" r="11" fill="#bc002d"/></svg>`,
|
||||
US: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40"><path fill="#b22234" d="M0 0h60v40H0z"/><path stroke="#fff" stroke-width="3.08" d="M0 3.08h60M0 9.23h60M0 15.38h60M0 21.54h60M0 27.69h60M0 33.85h60"/><path fill="#3c3b6e" d="M0 0h24v21.54H0z"/></svg>`
|
||||
};
|
||||
|
||||
const svg = flags[iso2];
|
||||
return svg ? `<img class="flag-img" alt="${escapeHtml(iso2)} flag" src="data:image/svg+xml,${encodeURIComponent(svg)}">` : "";
|
||||
}
|
||||
|
||||
function clock(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function renderLeaderboard(leaderboard) {
|
||||
const rows = leaderboard?.rows ?? [];
|
||||
const session = leaderboard?.session ?? {};
|
||||
|
||||
driverCount.textContent = String(rows.length);
|
||||
leaderboardNote.textContent = leaderboard?.note ?? "Waiting for participant or timing packets";
|
||||
trackName.textContent = session.trackName || "-";
|
||||
sessionType.textContent = session.sessionType || "-";
|
||||
timeLeft.textContent = fmtTime(session.timeLeft);
|
||||
weather.textContent = session.weather || "-";
|
||||
lastUpdate.textContent = clock(session.lastUpdate);
|
||||
|
||||
leaderboardBody.innerHTML = rows.length
|
||||
? rows.map((row) => `
|
||||
${(() => {
|
||||
const sectorSplit = sectorSplitForRow(row, session);
|
||||
return `
|
||||
<tr class="${row.active === false ? "inactive-row" : ""}">
|
||||
<td class="pos"><span class="pos-inner"><span>${fmt(row.position)}</span>${positionChange(row.positionChange)}</span></td>
|
||||
<td>
|
||||
<strong>${fmt(row.driver)}</strong>
|
||||
<span class="subtle">slot ${fmt(row.slot)} / id ${fmt(row.id)}</span>
|
||||
</td>
|
||||
<td>${nationalityBadge(row.nationality)}</td>
|
||||
<td>${carShortName(row)}</td>
|
||||
<td>${fmt(row.lap)}</td>
|
||||
<td>${fmt(row.sector)}</td>
|
||||
<td>${fmtTime(row.currentTime)}</td>
|
||||
<td>${fmtTime(row.lastLapTime)}</td>
|
||||
<td>
|
||||
${fmtSplit(row.latestLapSplit)}
|
||||
<span class="subtle">${row.latestLapSplit?.comparedTo ? `vs ${fmt(row.latestLapSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>${fmtTime(row.fastestLapTime)}</td>
|
||||
<td>
|
||||
${fmtSplit(row.bestLapSplit)}
|
||||
<span class="subtle">${row.bestLapSplit?.comparedTo ? `vs ${fmt(row.bestLapSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>
|
||||
${fmtSplit(sectorSplit)}
|
||||
<span class="subtle">${fmt(sectorSplit?.checkpoint)}${sectorSplit?.comparedTo ? ` vs ${fmt(sectorSplit.comparedTo)}` : ""}</span>
|
||||
</td>
|
||||
<td>${fmtTime(sectorSplit?.time)}</td>
|
||||
<td>
|
||||
${statusBadge(row)}
|
||||
<span class="subtle">pit ${fmt(row.pitModeSchedule)} / ${raceFlagDetails(row)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})()}
|
||||
`).join("")
|
||||
: `<tr><td colspan="14" class="empty">No leaderboard data received yet</td></tr>`;
|
||||
}
|
||||
|
||||
fetch("/api/leaderboard")
|
||||
.then((response) => response.json())
|
||||
.then(renderLeaderboard)
|
||||
.catch(() => {
|
||||
status.textContent = "Could not load leaderboard state";
|
||||
status.className = "warn";
|
||||
});
|
||||
|
||||
const events = new EventSource("/events");
|
||||
|
||||
events.addEventListener("state", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
renderLeaderboard(payload.leaderboard);
|
||||
});
|
||||
|
||||
events.addEventListener("leaderboard", (event) => {
|
||||
const leaderboard = JSON.parse(event.data);
|
||||
status.textContent = `Receiving packets. Last update ${clock(leaderboard?.session?.lastUpdate)}`;
|
||||
status.className = "good";
|
||||
renderLeaderboard(leaderboard);
|
||||
});
|
||||
|
||||
events.onerror = () => {
|
||||
status.textContent = "Connection to local telemetry server lost";
|
||||
status.className = "warn";
|
||||
};
|
||||
Reference in New Issue
Block a user