Added intial plugin
This commit is contained in:
273
public/app.js
Normal file
273
public/app.js
Normal file
@@ -0,0 +1,273 @@
|
||||
const state = {
|
||||
latest: {},
|
||||
recent: [],
|
||||
packetCount: 0
|
||||
};
|
||||
|
||||
const packetCount = document.querySelector("#packet-count");
|
||||
const status = document.querySelector("#status");
|
||||
const latestBody = document.querySelector("#latest-body");
|
||||
const packetSections = document.querySelector("#packet-sections");
|
||||
const recentBody = document.querySelector("#recent-body");
|
||||
|
||||
const packetOrder = ["0", "1", "2", "3", "4", "7", "8", "5", "6"];
|
||||
const openSections = new Set(["0", "2", "3"]);
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
if (value === undefined || value === null || value === "") return '<span class="empty">-</span>';
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function timeOnly(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function packetSort(a, b) {
|
||||
const aIndex = packetOrder.indexOf(String(a.base.packetType));
|
||||
const bIndex = packetOrder.indexOf(String(b.base.packetType));
|
||||
return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
|
||||
}
|
||||
|
||||
function flatten(value, prefix = "") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return [[prefix, "[]"]];
|
||||
if (value.every((item) => item === null || typeof item !== "object")) {
|
||||
return [[prefix, value.join(", ")]];
|
||||
}
|
||||
return value.flatMap((item, index) => flatten(item, `${prefix}[${index}]`));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return [[prefix, "{}"]];
|
||||
return entries.flatMap(([key, nested]) => flatten(nested, prefix ? `${prefix}.${key}` : key));
|
||||
}
|
||||
|
||||
return [[prefix, value]];
|
||||
}
|
||||
|
||||
function renderKeyValueTable(data, emptyText = "No data received yet") {
|
||||
const rows = flatten(data).filter(([key]) => key);
|
||||
if (!rows.length) {
|
||||
return `<div class="table-wrap"><table><tbody><tr><td class="empty">${emptyText}</td></tr></tbody></table></div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(([key, value]) => `
|
||||
<tr>
|
||||
<th>${escapeHtml(key)}</th>
|
||||
<td>${fmt(value)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function participantNameMap() {
|
||||
const packet = state.latest["2"];
|
||||
const participants = packet?.data?.participants ?? [];
|
||||
return new Map(participants.map((participant) => [participant.index, participant.name]));
|
||||
}
|
||||
|
||||
function vehicleNameMap() {
|
||||
const packet = state.latest["8"];
|
||||
const vehicles = packet?.data?.vehicles ?? [];
|
||||
return new Map(vehicles.map((vehicle) => [vehicle.index, vehicle.name]));
|
||||
}
|
||||
|
||||
function groupedItemTitle(groupName, item, index) {
|
||||
const participantNames = participantNameMap();
|
||||
const vehicleNames = vehicleNameMap();
|
||||
|
||||
if (groupName === "participants") {
|
||||
const participantIndex = item.mpParticipantIndex ?? item.index;
|
||||
const name = participantNames.get(participantIndex) || item.name || `Participant ${index + 1}`;
|
||||
const position = item.racePosition ? `P${item.racePosition} | ` : "";
|
||||
const vehicle = vehicleNames.get(participantIndex);
|
||||
return `${position}${name}${vehicle ? ` | ${vehicle}` : ""}`;
|
||||
}
|
||||
|
||||
if (groupName === "vehicles") {
|
||||
return item.name || `Vehicle ${index + 1}`;
|
||||
}
|
||||
|
||||
if (groupName === "classes") {
|
||||
return item.name || `Class ${index + 1}`;
|
||||
}
|
||||
|
||||
return `${groupName.slice(0, 1).toUpperCase()}${groupName.slice(1)} ${index + 1}`;
|
||||
}
|
||||
|
||||
function splitGroupedArrays(data) {
|
||||
const summary = {};
|
||||
const groups = [];
|
||||
|
||||
for (const [key, value] of Object.entries(data ?? {})) {
|
||||
if (Array.isArray(value) && value.some((item) => item && typeof item === "object")) {
|
||||
groups.push([key, value]);
|
||||
} else {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { summary, groups };
|
||||
}
|
||||
|
||||
function renderGroupedArray(name, items) {
|
||||
if (!items.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `
|
||||
<section class="nested-groups">
|
||||
<h3>${escapeHtml(name)}</h3>
|
||||
${items.map((item, index) => `
|
||||
<details class="subpanel" ${index < 3 ? "open" : ""}>
|
||||
<summary>
|
||||
<strong>${escapeHtml(groupedItemTitle(name, item, index))}</strong>
|
||||
<span>#${index}</span>
|
||||
</summary>
|
||||
${renderKeyValueTable(item)}
|
||||
</details>
|
||||
`).join("")}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPacketContent(packet) {
|
||||
const { summary, groups } = splitGroupedArrays(packet.data);
|
||||
const packetSummary = {
|
||||
receivedAt: packet.receivedAt,
|
||||
source: packet.source,
|
||||
size: packet.size,
|
||||
base: packet.base,
|
||||
data: summary
|
||||
};
|
||||
|
||||
return `
|
||||
${renderKeyValueTable(packetSummary)}
|
||||
${groups.map(([name, items]) => renderGroupedArray(name, items)).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLatest() {
|
||||
const packets = Object.values(state.latest).sort(packetSort);
|
||||
latestBody.innerHTML = packets.length
|
||||
? packets.map((packet) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(packet.name)}</td>
|
||||
<td>${packet.base.packetNumber}</td>
|
||||
<td>${packet.base.packetVersion}</td>
|
||||
<td>${packet.size}</td>
|
||||
<td>${timeOnly(packet.receivedAt)}</td>
|
||||
<td>${escapeHtml(packet.source)}</td>
|
||||
</tr>
|
||||
`).join("")
|
||||
: `<tr><td colspan="6" class="empty">No packets received yet</td></tr>`;
|
||||
}
|
||||
|
||||
function renderPacketSections() {
|
||||
const packets = Object.values(state.latest).sort(packetSort);
|
||||
|
||||
packetSections.innerHTML = packets.length
|
||||
? packets.map((packet) => {
|
||||
const type = String(packet.base.packetType);
|
||||
return `
|
||||
<details class="panel" data-packet-type="${type}" ${openSections.has(type) ? "open" : ""}>
|
||||
<summary class="panel-header">
|
||||
<h2>${escapeHtml(packet.name)}</h2>
|
||||
<span>${packet.size} bytes | packet ${packet.base.packetNumber} | ${timeOnly(packet.receivedAt)}</span>
|
||||
</summary>
|
||||
${renderPacketContent(packet)}
|
||||
</details>
|
||||
`;
|
||||
}).join("")
|
||||
: `
|
||||
<details class="panel" open>
|
||||
<summary class="panel-header">
|
||||
<h2>Packet Data</h2>
|
||||
</summary>
|
||||
${renderKeyValueTable({}, "Waiting for packets from Project CARS")}
|
||||
</details>
|
||||
`;
|
||||
|
||||
packetSections.querySelectorAll("details[data-packet-type]").forEach((details) => {
|
||||
details.addEventListener("toggle", () => {
|
||||
const type = details.dataset.packetType;
|
||||
if (!type) return;
|
||||
if (details.open) {
|
||||
openSections.add(type);
|
||||
} else {
|
||||
openSections.delete(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
recentBody.innerHTML = state.recent.length
|
||||
? state.recent.slice(0, 30).map((packet) => `
|
||||
<tr>
|
||||
<td>${timeOnly(packet.receivedAt)}</td>
|
||||
<td>${escapeHtml(packet.name)}</td>
|
||||
<td>${packet.base.packetNumber}</td>
|
||||
<td>${packet.size}</td>
|
||||
</tr>
|
||||
`).join("")
|
||||
: `<tr><td colspan="4" class="empty">No recent packets</td></tr>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
packetCount.textContent = String(state.packetCount);
|
||||
renderLatest();
|
||||
renderPacketSections();
|
||||
renderRecent();
|
||||
}
|
||||
|
||||
const events = new EventSource("/events");
|
||||
|
||||
events.addEventListener("state", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
state.latest = payload.latest ?? {};
|
||||
state.recent = payload.recent ?? [];
|
||||
state.packetCount = state.recent.length;
|
||||
render();
|
||||
});
|
||||
|
||||
events.addEventListener("packet", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
state.latest = payload.state.latest ?? {};
|
||||
state.recent = payload.state.recent ?? [];
|
||||
state.packetCount += 1;
|
||||
status.textContent = `Receiving packets. Last update ${timeOnly(payload.packet.receivedAt)}`;
|
||||
status.className = "good";
|
||||
render();
|
||||
});
|
||||
|
||||
events.onerror = () => {
|
||||
status.textContent = "Connection to local telemetry server lost";
|
||||
status.className = "warn";
|
||||
};
|
||||
|
||||
render();
|
||||
72
public/index.html
Normal file
72
public/index.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project CARS UDP Telemetry</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Project CARS UDP Telemetry</h1>
|
||||
<p id="status">Waiting for UDP packets on port 5606</p>
|
||||
</div>
|
||||
<nav class="page-nav" aria-label="Pages">
|
||||
<a href="/">Packet Data</a>
|
||||
<a href="/overview.html">Race Overview</a>
|
||||
<a href="/swagger.html">API Docs</a>
|
||||
</nav>
|
||||
<div class="stat">
|
||||
<span>Packets</span>
|
||||
<strong id="packet-count">0</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<details class="panel" open>
|
||||
<summary class="panel-header">
|
||||
<h2>Latest Packets</h2>
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Packet #</th>
|
||||
<th>Version</th>
|
||||
<th>Size</th>
|
||||
<th>Received</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="latest-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="packet-sections" class="packet-sections"></div>
|
||||
|
||||
<details class="panel">
|
||||
<summary class="panel-header">
|
||||
<h2>Recent Packets</h2>
|
||||
</summary>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Packet #</th>
|
||||
<th>Bytes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
440
public/openapi.json
Normal file
440
public/openapi.json
Normal file
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Project CARS UDP Telemetry API",
|
||||
"version": "1.0.0",
|
||||
"description": "Local API for decoded Project CARS UDP telemetry. The game sends binary UDP packets; this app decodes them into JSON, serves current state snapshots, exposes a server-sent events stream, and calculates a race overview leaderboard."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "Default local development server"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "State",
|
||||
"description": "Decoded packet state snapshots"
|
||||
},
|
||||
{
|
||||
"name": "Leaderboard",
|
||||
"description": "Server-calculated race overview"
|
||||
},
|
||||
{
|
||||
"name": "Events",
|
||||
"description": "Live telemetry stream. The current server implementation uses Server-Sent Events at /events; it does not expose a WebSocket upgrade endpoint."
|
||||
},
|
||||
{
|
||||
"name": "Ingest",
|
||||
"description": "Telemetry frame ingestion"
|
||||
},
|
||||
{
|
||||
"name": "Pages",
|
||||
"description": "Static browser pages served by the local app"
|
||||
},
|
||||
{
|
||||
"name": "Schemas",
|
||||
"description": "Reusable JSON schemas"
|
||||
}
|
||||
],
|
||||
"x-websocket-routes": [],
|
||||
"x-streaming-routes": [
|
||||
{
|
||||
"protocol": "sse",
|
||||
"path": "/events",
|
||||
"events": ["state", "packet", "leaderboard"]
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open packet viewer page",
|
||||
"description": "Serves the main packet inspection UI from public/index.html.",
|
||||
"operationId": "getPacketViewerPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML packet viewer page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/overview.html": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open race overview page",
|
||||
"description": "Serves the leaderboard/race overview UI.",
|
||||
"operationId": "getOverviewPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML race overview page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/swagger.html": {
|
||||
"get": {
|
||||
"tags": ["Pages"],
|
||||
"summary": "Open Swagger documentation page",
|
||||
"description": "Serves the browser documentation UI for this OpenAPI document.",
|
||||
"operationId": "getSwaggerPage",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML Swagger page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/state": {
|
||||
"get": {
|
||||
"tags": ["State"],
|
||||
"summary": "Get latest decoded UDP packet state",
|
||||
"description": "Returns the latest merged packet by UDP packet type plus the most recent parsed packets, newest first.",
|
||||
"operationId": "getState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current decoded packet state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/leaderboard": {
|
||||
"get": {
|
||||
"tags": ["Leaderboard"],
|
||||
"summary": "Get calculated leaderboard overview",
|
||||
"description": "Returns participant rows merged from UDP packets, including timing, status, nationality override, car name, lap splits, sector splits, pit state, and decoded race flag data.",
|
||||
"operationId": "getLeaderboard",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current leaderboard overview",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/leaderboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ingest/shared-memory": {
|
||||
"post": {
|
||||
"tags": ["Ingest"],
|
||||
"summary": "Ingest shared-memory bridge packets",
|
||||
"description": "Receives packet-like JSON frames from scripts/shared_memory_bridge.py and merges them into the same state used by the UDP parser and leaderboard.",
|
||||
"operationId": "ingestSharedMemory",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/sharedMemoryIngestRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Frame accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/ingestAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid frame",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/errorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/events": {
|
||||
"get": {
|
||||
"tags": ["Events"],
|
||||
"summary": "Open live telemetry SSE stream",
|
||||
"description": "Server-Sent Events stream. This is the app's live route; there is no WebSocket upgrade route in the current server. On connection it sends a `state` event. For each parsed UDP packet it sends a `packet` event containing the packet, state and leaderboard, followed by a `leaderboard` event containing just the leaderboard.",
|
||||
"operationId": "streamEvents",
|
||||
"x-streaming-protocol": "sse",
|
||||
"x-sse-events": {
|
||||
"state": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/stateEvent"
|
||||
},
|
||||
"example": {
|
||||
"latest": {
|
||||
"3": {
|
||||
"receivedAt": "2026-05-18T06:31:12.812Z",
|
||||
"source": "192.168.1.55:5606",
|
||||
"base": {
|
||||
"packetNumber": 1210,
|
||||
"categoryPacketNumber": 309,
|
||||
"partialPacketIndex": 0,
|
||||
"partialPacketNumber": 1,
|
||||
"packetType": 3,
|
||||
"packetVersion": 1
|
||||
},
|
||||
"name": "Timings",
|
||||
"size": 1064,
|
||||
"data": {
|
||||
"numParticipants": 2,
|
||||
"eventTimeRemaining": 1275.442,
|
||||
"participants": [
|
||||
{
|
||||
"slot": 0,
|
||||
"racePosition": 1,
|
||||
"active": true,
|
||||
"currentLap": 5,
|
||||
"currentTime": 402.733,
|
||||
"mpParticipantIndex": 1001
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recent": [],
|
||||
"leaderboard": {
|
||||
"rows": [
|
||||
{
|
||||
"id": 1001,
|
||||
"slot": 0,
|
||||
"position": 1,
|
||||
"status": {
|
||||
"code": "racing",
|
||||
"label": "Racing",
|
||||
"tone": "good"
|
||||
},
|
||||
"driver": "Alex Taylor",
|
||||
"lap": 5,
|
||||
"currentTime": 402.733
|
||||
}
|
||||
],
|
||||
"note": "1 participant merged from UDP packets",
|
||||
"session": {
|
||||
"trackName": "Bathurst - Mount Panorama",
|
||||
"sessionType": "Race",
|
||||
"timeLeft": 1275.442,
|
||||
"weather": "22 C air / 31 C track / rain 0",
|
||||
"lastUpdate": "2026-05-18T06:31:12.812Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packet": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/packetEvent"
|
||||
},
|
||||
"example": {
|
||||
"packet": {
|
||||
"receivedAt": "2026-05-18T06:31:13.020Z",
|
||||
"source": "192.168.1.55:5606",
|
||||
"base": {
|
||||
"packetNumber": 1211,
|
||||
"categoryPacketNumber": 310,
|
||||
"partialPacketIndex": 0,
|
||||
"partialPacketNumber": 1,
|
||||
"packetType": 3,
|
||||
"packetVersion": 1
|
||||
},
|
||||
"name": "Timings",
|
||||
"size": 1064,
|
||||
"data": {
|
||||
"numParticipants": 2,
|
||||
"eventTimeRemaining": 1274.991,
|
||||
"participants": [
|
||||
{
|
||||
"slot": 0,
|
||||
"racePosition": 1,
|
||||
"active": true,
|
||||
"sector": 2,
|
||||
"currentLap": 5,
|
||||
"currentTime": 403.184,
|
||||
"currentSectorTime": 32.333,
|
||||
"mpParticipantIndex": 1001
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"latest": {},
|
||||
"recent": []
|
||||
},
|
||||
"leaderboard": {
|
||||
"rows": [],
|
||||
"note": "Waiting for participant or timing packets",
|
||||
"session": {
|
||||
"trackName": "-",
|
||||
"sessionType": "-",
|
||||
"weather": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaderboard": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json#/$defs/leaderboard"
|
||||
},
|
||||
"example": {
|
||||
"rows": [
|
||||
{
|
||||
"id": 1001,
|
||||
"slot": 0,
|
||||
"sortPosition": 1,
|
||||
"position": 1,
|
||||
"active": true,
|
||||
"status": {
|
||||
"code": "racing",
|
||||
"label": "Racing",
|
||||
"tone": "good"
|
||||
},
|
||||
"driver": "Alex Taylor",
|
||||
"nationality": {
|
||||
"code": "GBR",
|
||||
"country": "United Kingdom",
|
||||
"source": "udp"
|
||||
},
|
||||
"vehicle": "Formula A",
|
||||
"lap": 5,
|
||||
"sector": 2,
|
||||
"currentTime": 403.184,
|
||||
"lastLapTime": 83.214,
|
||||
"fastestLapTime": 82.901
|
||||
}
|
||||
],
|
||||
"note": "1 participant merged from UDP packets",
|
||||
"session": {
|
||||
"trackName": "Bathurst - Mount Panorama",
|
||||
"sessionType": "Race",
|
||||
"timeLeft": 1274.991,
|
||||
"weather": "22 C air / 31 C track / rain 0",
|
||||
"lastUpdate": "2026-05-18T06:31:13.020Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SSE stream of telemetry updates",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Server-Sent Events stream. Event names currently include `state`, `packet`, and `leaderboard`; payload schemas are documented in `x-sse-events`."
|
||||
},
|
||||
"examples": {
|
||||
"state": {
|
||||
"summary": "State event after timing and participant packets",
|
||||
"value": "event: state\ndata: {\"latest\":{\"2\":{\"receivedAt\":\"2026-05-18T06:31:12.420Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1201,\"categoryPacketNumber\":42,\"partialPacketIndex\":1,\"partialPacketNumber\":2,\"packetType\":2,\"packetVersion\":1},\"name\":\"Participants\",\"size\":1136,\"data\":{\"participantsChangedTimestamp\":88342,\"participants\":[{\"slot\":0,\"localSlot\":0,\"partialPacketIndex\":1,\"name\":\"Alex Taylor\",\"nationality\":85,\"index\":1001},{\"slot\":1,\"localSlot\":1,\"partialPacketIndex\":1,\"name\":\"Sam Rivera\",\"nationality\":226,\"index\":1002}]}},\"3\":{\"receivedAt\":\"2026-05-18T06:31:12.812Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1210,\"categoryPacketNumber\":309,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"participantsChangedTimestamp\":88342,\"eventTimeRemaining\":1275.442,\"splitTimeAhead\":0,\"splitTimeBehind\":1.248,\"splitTime\":1.248,\"localParticipantIndex\":0,\"participants\":[{\"slot\":0,\"worldPosition\":[124,0,842],\"orientation\":[0,1024,0],\"currentLapDistance\":2840,\"racePosition\":1,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":0,\"pitModeSchedule\":0,\"carIndex\":45,\"raceState\":2,\"currentLap\":5,\"currentTime\":402.733,\"currentSectorTime\":31.882,\"mpParticipantIndex\":1001},{\"slot\":1,\"worldPosition\":[118,0,806],\"orientation\":[0,1008,0],\"currentLapDistance\":2790,\"racePosition\":2,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":2,\"pitModeSchedule\":0,\"carIndex\":47,\"raceState\":2,\"currentLap\":5,\"currentTime\":403.981,\"currentSectorTime\":33.130,\"mpParticipantIndex\":1002}]}}},\"recent\":[{\"receivedAt\":\"2026-05-18T06:31:12.812Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1210,\"categoryPacketNumber\":309,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1275.442,\"participants\":[]}}],\"leaderboard\":{\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"nationality\":{\"code\":\"GBR\",\"country\":\"United Kingdom\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":402.733,\"currentSectorTime\":31.882,\"lastLapTime\":83.214,\"fastestLapTime\":82.901,\"pitModeSchedule\":0,\"highestFlag\":0,\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":45},{\"id\":1002,\"slot\":1,\"sortPosition\":2,\"position\":2,\"active\":true,\"status\":{\"code\":\"blue_flag\",\"label\":\"Blue flag\",\"tone\":\"warn\"},\"driver\":\"Sam Rivera\",\"nationality\":{\"code\":\"USA\",\"country\":\"United States\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":403.981,\"currentSectorTime\":33.13,\"lastLapTime\":84.012,\"fastestLapTime\":83.441,\"pitModeSchedule\":0,\"highestFlag\":2,\"flag\":{\"raw\":2,\"colour\":2,\"reason\":0,\"observed\":\"blue\"},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":47}],\"note\":\"2 participants merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1275.442,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:12.812Z\"}}}\n\n"
|
||||
},
|
||||
"packet": {
|
||||
"summary": "Packet event with state and leaderboard",
|
||||
"value": "event: packet\ndata: {\"packet\":{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"participantsChangedTimestamp\":88342,\"eventTimeRemaining\":1274.991,\"splitTimeAhead\":0,\"splitTimeBehind\":1.192,\"splitTime\":1.192,\"localParticipantIndex\":0,\"participants\":[{\"slot\":0,\"worldPosition\":[128,0,851],\"orientation\":[0,1026,0],\"currentLapDistance\":2875,\"racePosition\":1,\"active\":true,\"sector\":2,\"sectorRaw\":2,\"sectorExtraPrecision\":0,\"highestFlag\":0,\"pitModeSchedule\":0,\"carIndex\":45,\"raceState\":2,\"currentLap\":5,\"currentTime\":403.184,\"currentSectorTime\":32.333,\"mpParticipantIndex\":1001}] }},\"state\":{\"latest\":{\"3\":{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1274.991,\"participants\":[]}}},\"recent\":[{\"receivedAt\":\"2026-05-18T06:31:13.020Z\",\"source\":\"192.168.1.55:5606\",\"base\":{\"packetNumber\":1211,\"categoryPacketNumber\":310,\"partialPacketIndex\":0,\"partialPacketNumber\":1,\"packetType\":3,\"packetVersion\":1},\"name\":\"Timings\",\"size\":1064,\"data\":{\"numParticipants\":2,\"eventTimeRemaining\":1274.991,\"participants\":[]}}]},\"leaderboard\":{\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"lap\":5,\"sector\":2,\"currentTime\":403.184,\"currentSectorTime\":32.333}],\"note\":\"1 participant merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1274.991,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:13.020Z\"}}}\n\n"
|
||||
},
|
||||
"leaderboard": {
|
||||
"summary": "Leaderboard update event",
|
||||
"value": "event: leaderboard\ndata: {\"rows\":[{\"id\":1001,\"slot\":0,\"sortPosition\":1,\"position\":1,\"active\":true,\"status\":{\"code\":\"racing\",\"label\":\"Racing\",\"tone\":\"good\"},\"driver\":\"Alex Taylor\",\"nationality\":{\"code\":\"GBR\",\"country\":\"United Kingdom\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":403.184,\"currentSectorTime\":32.333,\"checkpointSplit\":{\"source\":\"checkpoint\",\"basis\":\"raceOrder\",\"checkpoint\":\"L5 S2\",\"time\":403.184,\"delta\":0},\"lastLapTime\":83.214,\"latestLapSplit\":null,\"fastestLapTime\":82.901,\"bestLapSplit\":null,\"fastestSectors\":[27.101,28.334,27.466],\"pitModeSchedule\":0,\"pit\":{\"raw\":0,\"pitMode\":0,\"pitSchedule\":0},\"highestFlag\":0,\"flag\":{\"raw\":0,\"colour\":0,\"reason\":0},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":45},{\"id\":1002,\"slot\":1,\"sortPosition\":2,\"position\":2,\"active\":true,\"status\":{\"code\":\"blue_flag\",\"label\":\"Blue flag\",\"tone\":\"warn\"},\"driver\":\"Sam Rivera\",\"nationality\":{\"code\":\"USA\",\"country\":\"United States\",\"source\":\"udp\"},\"vehicle\":\"Formula A\",\"vehicleShortName\":\"FORM\",\"lap\":5,\"sector\":2,\"currentTime\":404.376,\"currentSectorTime\":33.525,\"checkpointSplit\":{\"source\":\"checkpoint\",\"basis\":\"raceOrder\",\"checkpoint\":\"L5 S2\",\"comparedTo\":\"P1\",\"time\":404.376,\"delta\":1.192},\"lastLapTime\":84.012,\"latestLapSplit\":{\"comparedTo\":\"P1\",\"time\":84.012,\"referenceTime\":83.214,\"delta\":0.798},\"fastestLapTime\":83.441,\"bestLapSplit\":{\"comparedTo\":\"P1\",\"time\":83.441,\"referenceTime\":82.901,\"delta\":0.54},\"fastestSectors\":[27.488,28.619,27.334],\"pitModeSchedule\":0,\"pit\":{\"raw\":0,\"pitMode\":0,\"pitSchedule\":0},\"highestFlag\":2,\"flag\":{\"raw\":2,\"colour\":2,\"reason\":0,\"observed\":\"blue\"},\"raceState\":2,\"raceStateCode\":2,\"invalidatedLap\":false,\"carIndex\":47}],\"note\":\"2 participants merged from UDP packets\",\"session\":{\"trackName\":\"Bathurst - Mount Panorama\",\"sessionType\":\"Race\",\"timeLeft\":1274.991,\"weather\":\"22 C air / 31 C track / rain 0\",\"lastUpdate\":\"2026-05-18T06:31:13.020Z\"}}\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get this OpenAPI document from the static public route",
|
||||
"operationId": "getOpenApiSpec",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI 3.1 document",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/openapi.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get this OpenAPI document from the API alias",
|
||||
"description": "Serves the same public/openapi.json document as /openapi.json.",
|
||||
"operationId": "getOpenApiSpecApiAlias",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI 3.1 document",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schemas/udp-packets.schema.json": {
|
||||
"get": {
|
||||
"tags": ["Schemas"],
|
||||
"summary": "Get decoded UDP packet JSON Schema",
|
||||
"description": "JSON Schema used by the OpenAPI document for decoded packet state and leaderboard responses.",
|
||||
"operationId": "getUdpPacketSchema",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "JSON Schema for decoded UDP packets",
|
||||
"content": {
|
||||
"application/schema+json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "/schemas/udp-packets.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
public/overview.html
Normal file
83
public/overview.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Race Overview</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Race Overview</h1>
|
||||
<p id="status">Waiting for UDP packets on port 5606</p>
|
||||
</div>
|
||||
<nav class="page-nav" aria-label="Pages">
|
||||
<a href="/">Packet Data</a>
|
||||
<a href="/overview.html">Race Overview</a>
|
||||
<a href="/swagger.html">API Docs</a>
|
||||
</nav>
|
||||
<div class="stat">
|
||||
<span>Drivers</span>
|
||||
<strong id="driver-count">0</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="overview-strip">
|
||||
<div>
|
||||
<span>Track</span>
|
||||
<strong id="track-name">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Session</span>
|
||||
<strong id="session-type">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Time Left</span>
|
||||
<strong id="time-left">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Weather</span>
|
||||
<strong id="weather">-</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last Update</span>
|
||||
<strong id="last-update">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Leaderboard</h2>
|
||||
<span id="leaderboard-note">Waiting for timing packets</span>
|
||||
</div>
|
||||
<div class="table-wrap leaderboard-wrap">
|
||||
<table class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos</th>
|
||||
<th>Driver</th>
|
||||
<th>Nat</th>
|
||||
<th>Car</th>
|
||||
<th>Lap</th>
|
||||
<th>Sector</th>
|
||||
<th>Current</th>
|
||||
<th>Last Lap</th>
|
||||
<th>Last Split</th>
|
||||
<th>Best Lap</th>
|
||||
<th>Best Split</th>
|
||||
<th>Sector Split</th>
|
||||
<th>Checkpoint</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leaderboard-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/overview.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
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";
|
||||
};
|
||||
429
public/styles.css
Normal file
429
public/styles.css
Normal file
@@ -0,0 +1,429 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #101316;
|
||||
color: #eef3f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #101316;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 24px clamp(16px, 4vw, 48px);
|
||||
border-bottom: 1px solid #273039;
|
||||
background: #171c20;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #32404a;
|
||||
border-radius: 8px;
|
||||
color: #dce8ed;
|
||||
background: #11171b;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-nav a:hover {
|
||||
border-color: #5a6b75;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 3vw, 2.3rem);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p,
|
||||
td,
|
||||
th {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-top: 6px;
|
||||
color: #9fb0ba;
|
||||
}
|
||||
|
||||
.stat {
|
||||
min-width: 110px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #32404a;
|
||||
border-radius: 8px;
|
||||
background: #11171b;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stat span {
|
||||
display: block;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 18px clamp(12px, 3vw, 32px) 36px;
|
||||
}
|
||||
|
||||
.overview-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-strip div {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #171c20;
|
||||
}
|
||||
|
||||
.overview-strip span,
|
||||
.subtle {
|
||||
display: block;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.overview-strip strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #171c20;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
details.panel summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
details.panel summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details.panel summary::before {
|
||||
content: ">";
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.82rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
details.panel[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #273039;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
margin-left: auto;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.packet-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.nested-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid #273039;
|
||||
}
|
||||
|
||||
.nested-groups h3 {
|
||||
margin: 0;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subpanel {
|
||||
border: 1px solid #273039;
|
||||
border-radius: 8px;
|
||||
background: #11171b;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subpanel summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
background: #1b2227;
|
||||
}
|
||||
|
||||
.subpanel summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpanel summary::before {
|
||||
content: ">";
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #a8bbc4;
|
||||
font-size: 0.76rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.subpanel[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.subpanel strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subpanel span {
|
||||
margin-left: auto;
|
||||
color: #9fb0ba;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.subpanel .table-wrap {
|
||||
border-top: 1px solid #273039;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.leaderboard thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #1e252a;
|
||||
}
|
||||
|
||||
.leaderboard tbody tr:nth-child(even) {
|
||||
background: #141a1e;
|
||||
}
|
||||
|
||||
.leaderboard td strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.leaderboard .pos {
|
||||
min-width: 52px;
|
||||
color: #eef3f5;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pos-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.position-change-placeholder {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.position-up {
|
||||
color: #68d391;
|
||||
background: rgba(104, 211, 145, 0.12);
|
||||
}
|
||||
|
||||
.position-down {
|
||||
color: #ff7b7b;
|
||||
background: rgba(255, 123, 123, 0.12);
|
||||
}
|
||||
|
||||
.nat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
border: 1px solid #3d4b55;
|
||||
border-radius: 6px;
|
||||
color: #dce8ed;
|
||||
background: #11171b;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flag-img {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.inactive-row {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #273039;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #a8bbc4;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
width: 38%;
|
||||
color: #c5d3da;
|
||||
font-size: 0.86rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #eef3f5;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #7f9099;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: #f6c177;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.overview-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
width: 100%;
|
||||
margin-left: 30px;
|
||||
}
|
||||
}
|
||||
33
public/swagger.html
Normal file
33
public/swagger.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Project CARS Telemetry API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user