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 `
${emptyText}
`; } return `
${rows.map(([key, value]) => ` `).join("")}
Field Value
${escapeHtml(key)} ${fmt(value)}
`; } 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 `

${escapeHtml(packet.name)}

${packet.size} bytes | packet ${packet.base.packetNumber} | ${timeOnly(packet.receivedAt)}
${renderPacketContent(packet)}
`; }).join("") : `

Packet Data

${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();