Added intial plugin

This commit is contained in:
2026-05-19 15:38:16 +10:00
commit 47178f0415
21 changed files with 5701 additions and 0 deletions

273
public/app.js Normal file
View 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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();