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 '-';
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 `
`;
}
return `
| Field |
Value |
${rows.map(([key, value]) => `
| ${escapeHtml(key)} |
${fmt(value)} |
`).join("")}
`;
}
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 `
${escapeHtml(name)}
${items.map((item, index) => `
${escapeHtml(groupedItemTitle(name, item, index))}
#${index}
${renderKeyValueTable(item)}
`).join("")}
`;
}
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) => `
| ${escapeHtml(packet.name)} |
${packet.base.packetNumber} |
${packet.base.packetVersion} |
${packet.size} |
${timeOnly(packet.receivedAt)} |
${escapeHtml(packet.source)} |
`).join("")
: `| No packets received yet |
`;
}
function renderPacketSections() {
const packets = Object.values(state.latest).sort(packetSort);
packetSections.innerHTML = packets.length
? packets.map((packet) => {
const type = String(packet.base.packetType);
return `
${renderPacketContent(packet)}
`;
}).join("")
: `
${renderKeyValueTable({}, "Waiting for packets from Project CARS")}
`;
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) => `
| ${timeOnly(packet.receivedAt)} |
${escapeHtml(packet.name)} |
${packet.base.packetNumber} |
${packet.size} |
`).join("")
: `| No recent packets |
`;
}
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();