274 lines
7.9 KiB
JavaScript
274 lines
7.9 KiB
JavaScript
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();
|