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();
|
||||
Reference in New Issue
Block a user