Extract overlay and OBS rendering into module

This commit is contained in:
larssand
2026-03-25 19:32:59 +01:00
parent a550346094
commit 47e5a3dea1
2 changed files with 397 additions and 324 deletions

View File

@@ -1,3 +1,5 @@
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
const NAV_ITEMS = [
{ id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub" },
{ id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" },
@@ -6594,10 +6596,10 @@ function renderOverlay() {
const topRow = leaderboard[0] || null;
const fastestRow =
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const modeLabel = getOverlayModeLabel(overlayViewMode);
const modeLabel = getOverlayModeLabel(overlayViewMode, { t });
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
const obsConfig = overlayViewMode === "obs" ? getObsOverlayConfig() : null;
const rotatingPanels = buildOverlayPanels(active, recent);
const rotatingPanels = buildOverlayPanels(active, recent, { t, overlayEvents, normalizeStartMode, renderPositionGrid });
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
const denseOverlay = overlayViewMode === "leaderboard" || overlayViewMode === "tv";
@@ -6722,9 +6724,24 @@ function renderOverlay() {
</section>
`
: overlayViewMode === "team"
? renderTeamOverlay(leaderboard, result, sessionTiming)
? renderTeamOverlay(leaderboard, result, sessionTiming, { t, escapeHtml, formatLap, formatTeamActiveMemberLabel, getVisiblePassings, renderOverlayLeaderboard })
: overlayViewMode === "obs"
? renderObsOverlay(active, leaderboard, result, sessionTiming, branding)
? renderObsOverlay(active, leaderboard, result, sessionTiming, branding, {
t,
escapeHtml,
formatLap,
getVisiblePassings,
getSessionTypeLabel,
getStartModeLabel,
getStatusLabel,
getEventName,
getObsOverlayConfig,
normalizeStartMode,
renderPositionGrid,
getSessionGridEntries,
formatTeamActiveMemberLabel,
renderOverlayLeaderboard,
})
: overlayViewMode === "tv"
? `
<section class="overlay-board overlay-board-tv">
@@ -6738,7 +6755,7 @@ function renderOverlay() {
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard)}
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
</div>
</div>
</section>
@@ -6755,11 +6772,11 @@ function renderOverlay() {
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard)}
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
</div>
</div>
<aside class="overlay-side">
${activePanel ? renderOverlaySidePanel(activePanel) : `<section class="overlay-side-card"><p>${t("timing.no_passings")}</p></section>`}
${activePanel ? renderOverlaySidePanel(activePanel, { t, escapeHtml }) : `<section class="overlay-side-card"><p>${t("timing.no_passings")}</p></section>`}
</aside>
</section>
`
@@ -6799,201 +6816,6 @@ function renderOverlay() {
});
}
function renderObsOverlay(active, leaderboard, result, sessionTiming, branding) {
const obsConfig = getObsOverlayConfig();
const compactRows = leaderboard.slice(0, obsConfig.rows);
const readyPositionGrid = active && active.status === "ready" && normalizeStartMode(active.startMode) === "position";
const showStartGrid = obsConfig.showGrid && readyPositionGrid;
const leadRow = compactRows[0] || null;
const fastestRow =
[...compactRows].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const visiblePassings = getVisiblePassings(result);
const elapsedOrRemaining = sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: sessionTiming?.followUpActive
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
if (obsConfig.layout === "grid") {
return `
<section class="overlay-obs-layout overlay-obs-layout-grid">
<div class="overlay-obs-main">
<div class="overlay-obs-brandline">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
<div class="overlay-obs-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
<span>${t("table.laps")}: ${leadRow?.laps || 0}</span>
<span>${t("timing.total_passings")}: ${visiblePassings.length || 0}</span>
</div>
</div>
</div>
${obsConfig.showClock ? `
<div class="overlay-obs-clockblock">
<strong>${elapsedOrRemaining}</strong>
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
</div>
` : ""}
</div>
<section class="overlay-obs-feature overlay-obs-feature-gridonly">
<div class="overlay-section-head">
<h3>${showStartGrid ? t("events.start_grid") : t("overlay.leaderboard_live")}</h3>
<span class="pill">${t("overlay.mode_obs")}</span>
</div>
${showStartGrid ? renderPositionGrid(active) : renderOverlayLeaderboard(compactRows)}
</section>
</section>
`;
}
if (obsConfig.layout === "lowerthird") {
return `
<section class="overlay-obs-lowerthird">
<div class="overlay-obs-lowerthird-head">
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock">${elapsedOrRemaining}</strong>` : ""}
</div>
<div class="overlay-obs-lowerthird-rows">
${compactRows.slice(0, 6).map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-lowerthird-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
${obsConfig.showLaps ? `<span>${t("table.laps")}: ${row.laps ?? 0}</span>` : ""}
${obsConfig.showResult ? `<span>${t("table.result")}: ${escapeHtml(row.resultDisplay)}</span>` : ""}
${obsConfig.showGap ? `<span>${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</span>` : ""}
</article>
`;
}).join("")}
</div>
</section>
`;
}
const towerRowsHtml = showStartGrid
? getSessionGridEntries(active)
.slice(0, obsConfig.rows)
.map((entry, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-tower-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${entry.slot}</span>
<div class="overlay-obs-tower-driver">
<strong>${escapeHtml(entry.name)}</strong>
<span>${escapeHtml(entry.meta || "-")}</span>
</div>
</article>
`;
})
.join("")
: compactRows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
const trail = [
obsConfig.showLaps ? `<span>${row.laps ?? 0}L</span>` : "",
obsConfig.showResult ? `<span>${escapeHtml(row.resultDisplay)}</span>` : "",
obsConfig.showBest ? `<span>${formatLap(row.bestLapMs)}</span>` : "",
obsConfig.showGap ? `<span>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</span>` : "",
]
.filter(Boolean)
.join("");
return `
<article class="overlay-obs-tower-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<div class="overlay-obs-tower-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")}</span>
</div>
${trail ? `<div class="overlay-obs-tower-trail">${trail}</div>` : ""}
</article>
`;
})
.join("");
return `
<section class="overlay-obs-tower">
<div class="overlay-obs-tower-head">
<div class="overlay-obs-tower-brand">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
</div>
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock">${elapsedOrRemaining}</div>` : ""}
</div>
<div class="overlay-obs-tower-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
${showStartGrid ? `<span>${t("events.start_grid")}</span>` : ""}
${!showStartGrid && obsConfig.showFastest ? `<span>${t("overlay.fastest_lap")}: ${formatLap(fastestRow?.bestLapMs)}</span>` : ""}
</div>
<section class="overlay-obs-tower-standings">
${towerRowsHtml || `<p>${t("timing.no_laps")}</p>`}
</section>
</section>
`;
}
function buildOverlayPanels(active, recent) {
return [
{
title: t("overlay.last_passings"),
content: recent.length
? recent
.map(
(passing) => `
<div class="overlay-passing">
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
<span>${formatLap(passing.lapMs)}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("overlay.event_markers"),
content: overlayEvents.length
? overlayEvents
.map(
(item) => `
<div class="overlay-passing">
<strong>${escapeHtml(item.label)}</strong>
<span>${new Date(item.ts).toLocaleTimeString()}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("events.position_grid"),
content:
active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`,
},
];
}
function renderOverlaySidePanel(panel) {
return `
<section class="overlay-side-card overlay-rotating-card">
<div class="overlay-section-head">
<h3>${escapeHtml(panel.title)}</h3>
<span class="pill">${t("overlay.rotating_panel")}</span>
</div>
${panel.content}
</section>
`;
}
function getQuickAddState(transponder) {
const normalized = String(transponder || "").trim();
const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null;
@@ -7160,124 +6982,6 @@ function renderLeaderboard(rows) {
);
}
function renderOverlayLeaderboard(rows) {
if (!rows.length) {
return `<p>${t("timing.no_laps")}</p>`;
}
return `
<div class="overlay-race-list">
${rows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>
<div class="overlay-race-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}</span>
${row.invalidPending ? `<span class="overlay-invalid-note">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? `${formatLap(row.invalidLapMs)}` : ""}</span>` : ""}
<div class="overlay-prediction">
<div class="overlay-prediction-meta">
<label>${t("overlay.next_predicted_lap")}</label>
<span>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
</div>
<div class="overlay-prediction-track">
<div class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
</div>
</div>
</div>
<div class="overlay-race-metric">
<label>${t("table.laps")}</label>
<strong>${row.laps ?? 0}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.result")}</label>
<strong>${escapeHtml(row.resultDisplay)}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.gap")}</label>
<strong>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.ahead_gap")}</label>
<strong>${escapeHtml(row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.own_delta")}</label>
<strong>${escapeHtml(row.lapDelta || "-")}</strong>
</div>
<div class="overlay-race-best">
<label>${t("table.best_lap")}</label>
<strong>${formatLap(row.bestLapMs)}</strong>
</div>
</article>
`;
})
.join("")}
</div>
`;
}
function renderTeamOverlay(rows, result, sessionTiming) {
const topThree = rows.slice(0, 3);
return `
<section class="overlay-team-layout">
<section class="overlay-team-podium">
<div class="overlay-section-head">
<h3>${t("overlay.top_three")}</h3>
<span class="pill">${t("overlay.team_battle")}</span>
</div>
<div class="overlay-team-podium-grid">
${topThree
.map(
(row, index) => `
<article class="overlay-team-card overlay-team-card-${index + 1}">
<span class="pos-pill pos-${Math.min(index + 1, 3)}">${index + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<p>${escapeHtml(row.resultDisplay || "-")}</p>
<small>${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</small>
</article>
`
)
.join("")}
</div>
</section>
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap">
<section class="overlay-stats-row">
<article class="overlay-stat-card">
<span>${t("table.laps")}</span>
<strong>${rows[0]?.laps || 0}</strong>
<small>${escapeHtml(rows[0]?.displayName || rows[0]?.driverName || "-")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("timing.total_passings")}</span>
<strong>${getVisiblePassings(result).length || 0}</strong>
<small>${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap([...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.bestLapMs)}</strong>
<small>${escapeHtml(
[...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.displayName || "-"
)}</small>
</article>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv">
<div class="overlay-section-head">
<h3>${t("events.team_standings")}</h3>
</div>
${renderOverlayLeaderboard(rows)}
</div>
</div>
</section>
</section>
`;
}
function renderRecentPassings(session) {
if (!session) {
return `<p>${t("timing.no_session_selected")}</p>`;
@@ -7813,10 +7517,6 @@ function getSessionTypeLabel(type) {
return translated === key ? String(type || "") : translated;
}
function getOverlayModeLabel(mode) {
return t(`overlay.mode_${String(mode || "leaderboard").toLowerCase()}`);
}
function normalizeStartMode(mode) {
return ["mass", "position", "staggered"].includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "mass";
}

373
src/overlays.js Normal file
View File

@@ -0,0 +1,373 @@
export function getOverlayModeLabel(mode, { t }) {
return t(`overlay.mode_${String(mode || "leaderboard").toLowerCase()}`);
}
export function buildOverlayPanels(active, recent, { t, overlayEvents, normalizeStartMode, renderPositionGrid }) {
return [
{
title: t("overlay.last_passings"),
content: recent.length
? recent
.map(
(passing) => `
<div class="overlay-passing">
<strong>${escapeHtmlLocal(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
<span>${passing.lapMs == null ? "-" : formatLapLocal(passing.lapMs)}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("overlay.event_markers"),
content: overlayEvents.length
? overlayEvents
.map(
(item) => `
<div class="overlay-passing">
<strong>${escapeHtmlLocal(item.label)}</strong>
<span>${new Date(item.ts).toLocaleTimeString()}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("events.position_grid"),
content:
active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`,
},
];
}
export function renderOverlaySidePanel(panel, { t, escapeHtml }) {
return `
<section class="overlay-side-card overlay-rotating-card">
<div class="overlay-section-head">
<h3>${escapeHtml(panel.title)}</h3>
<span class="pill">${t("overlay.rotating_panel")}</span>
</div>
${panel.content}
</section>
`;
}
export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta }) {
if (!rows.length) {
return `<p>${t("timing.no_laps")}</p>`;
}
return `
<div class="overlay-race-list">
${rows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>
<div class="overlay-race-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}</span>
${row.invalidPending ? `<span class="overlay-invalid-note">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? `${formatLap(row.invalidLapMs)}` : ""}</span>` : ""}
<div class="overlay-prediction">
<div class="overlay-prediction-meta">
<label>${t("overlay.next_predicted_lap")}</label>
<span>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
</div>
<div class="overlay-prediction-track">
<div class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
</div>
</div>
</div>
<div class="overlay-race-metric">
<label>${t("table.laps")}</label>
<strong>${row.laps ?? 0}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.result")}</label>
<strong>${escapeHtml(row.resultDisplay)}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.gap")}</label>
<strong>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.ahead_gap")}</label>
<strong>${escapeHtml(row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.own_delta")}</label>
<strong>${escapeHtml(row.lapDelta || "-")}</strong>
</div>
<div class="overlay-race-best">
<label>${t("table.best_lap")}</label>
<strong>${formatLap(row.bestLapMs)}</strong>
</div>
</article>
`;
})
.join("")}
</div>
`;
}
export function renderTeamOverlay(rows, result, sessionTiming, deps) {
const { t, escapeHtml, formatLap, formatTeamActiveMemberLabel, getVisiblePassings } = deps;
const topThree = rows.slice(0, 3);
return `
<section class="overlay-team-layout">
<section class="overlay-team-podium">
<div class="overlay-section-head">
<h3>${t("overlay.top_three")}</h3>
<span class="pill">${t("overlay.team_battle")}</span>
</div>
<div class="overlay-team-podium-grid">
${topThree
.map(
(row, index) => `
<article class="overlay-team-card overlay-team-card-${index + 1}">
<span class="pos-pill pos-${Math.min(index + 1, 3)}">${index + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<p>${escapeHtml(row.resultDisplay || "-")}</p>
<small>${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</small>
</article>
`
)
.join("")}
</div>
</section>
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap">
<section class="overlay-stats-row">
<article class="overlay-stat-card">
<span>${t("table.laps")}</span>
<strong>${rows[0]?.laps || 0}</strong>
<small>${escapeHtml(rows[0]?.displayName || rows[0]?.driverName || "-")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("timing.total_passings")}</span>
<strong>${getVisiblePassings(result).length || 0}</strong>
<small>${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap([...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.bestLapMs)}</strong>
<small>${escapeHtml(
[...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.displayName || "-"
)}</small>
</article>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv">
<div class="overlay-section-head">
<h3>${t("events.team_standings")}</h3>
</div>
${renderOverlayLeaderboard(rows, deps)}
</div>
</div>
</section>
</section>
`;
}
export function renderObsOverlay(active, leaderboard, result, sessionTiming, branding, deps) {
const {
t,
escapeHtml,
formatLap,
getVisiblePassings,
getSessionTypeLabel,
getStartModeLabel,
getStatusLabel,
getEventName,
getObsOverlayConfig,
normalizeStartMode,
renderPositionGrid,
getSessionGridEntries,
formatTeamActiveMemberLabel,
} = deps;
const obsConfig = getObsOverlayConfig();
const compactRows = leaderboard.slice(0, obsConfig.rows);
const readyPositionGrid = active && active.status === "ready" && normalizeStartMode(active.startMode) === "position";
const showStartGrid = obsConfig.showGrid && readyPositionGrid;
const leadRow = compactRows[0] || null;
const fastestRow =
[...compactRows].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const visiblePassings = getVisiblePassings(result);
const elapsedOrRemaining = sessionTiming?.untimed
? formatElapsedClockLocal(sessionTiming?.elapsedMs ?? 0)
: sessionTiming?.followUpActive
? formatCountdownLocal(sessionTiming?.followUpRemainingMs ?? 0)
: formatCountdownLocal(sessionTiming?.remainingMs ?? 0);
if (obsConfig.layout === "grid") {
return `
<section class="overlay-obs-layout overlay-obs-layout-grid">
<div class="overlay-obs-main">
<div class="overlay-obs-brandline">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
<div class="overlay-obs-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
<span>${t("table.laps")}: ${leadRow?.laps || 0}</span>
<span>${t("timing.total_passings")}: ${visiblePassings.length || 0}</span>
</div>
</div>
</div>
${obsConfig.showClock ? `
<div class="overlay-obs-clockblock">
<strong>${elapsedOrRemaining}</strong>
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
</div>
` : ""}
</div>
<section class="overlay-obs-feature overlay-obs-feature-gridonly">
<div class="overlay-section-head">
<h3>${showStartGrid ? t("events.start_grid") : t("overlay.leaderboard_live")}</h3>
<span class="pill">${t("overlay.mode_obs")}</span>
</div>
${showStartGrid ? renderPositionGrid(active) : renderOverlayLeaderboard(compactRows, deps)}
</section>
</section>
`;
}
if (obsConfig.layout === "lowerthird") {
return `
<section class="overlay-obs-lowerthird">
<div class="overlay-obs-lowerthird-head">
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock">${elapsedOrRemaining}</strong>` : ""}
</div>
<div class="overlay-obs-lowerthird-rows">
${compactRows.slice(0, 6).map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-lowerthird-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
${obsConfig.showLaps ? `<span>${t("table.laps")}: ${row.laps ?? 0}</span>` : ""}
${obsConfig.showResult ? `<span>${t("table.result")}: ${escapeHtml(row.resultDisplay)}</span>` : ""}
${obsConfig.showGap ? `<span>${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</span>` : ""}
</article>
`;
}).join("")}
</div>
</section>
`;
}
const towerRowsHtml = showStartGrid
? getSessionGridEntries(active)
.slice(0, obsConfig.rows)
.map((entry, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-obs-tower-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${entry.slot}</span>
<div class="overlay-obs-tower-driver">
<strong>${escapeHtml(entry.name)}</strong>
<span>${escapeHtml(entry.meta || "-")}</span>
</div>
</article>
`;
})
.join("")
: compactRows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
const trail = [
obsConfig.showLaps ? `<span>${row.laps ?? 0}L</span>` : "",
obsConfig.showResult ? `<span>${escapeHtml(row.resultDisplay)}</span>` : "",
obsConfig.showBest ? `<span>${formatLap(row.bestLapMs)}</span>` : "",
obsConfig.showGap ? `<span>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</span>` : "",
]
.filter(Boolean)
.join("");
return `
<article class="overlay-obs-tower-row ${idx === 0 ? "overlay-obs-row-leader" : ""}">
<span class="pos-pill ${posClass}">${idx + 1}</span>
<div class="overlay-obs-tower-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")}</span>
</div>
${trail ? `<div class="overlay-obs-tower-trail">${trail}</div>` : ""}
</article>
`;
})
.join("");
return `
<section class="overlay-obs-tower">
<div class="overlay-obs-tower-head">
<div class="overlay-obs-tower-brand">
${branding.logoDataUrl ? `<img class="overlay-logo overlay-obs-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
</div>
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock">${elapsedOrRemaining}</div>` : ""}
</div>
<div class="overlay-obs-tower-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span>${escapeHtml(getStartModeLabel(active.startMode))}</span>
${showStartGrid ? `<span>${t("events.start_grid")}</span>` : ""}
${!showStartGrid && obsConfig.showFastest ? `<span>${t("overlay.fastest_lap")}: ${formatLap(fastestRow?.bestLapMs)}</span>` : ""}
</div>
<section class="overlay-obs-tower-standings">
${towerRowsHtml || `<p>${t("timing.no_laps")}</p>`}
</section>
</section>
`;
}
function escapeHtmlLocal(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function padClock(value) {
return String(value).padStart(2, "0");
}
function formatCountdownLocal(ms) {
const totalMs = Math.max(0, Number(ms) || 0);
const totalSeconds = Math.floor(totalMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${padClock(minutes)}:${padClock(seconds)}`;
}
function formatElapsedClockLocal(ms) {
const totalMs = Math.max(0, Number(ms) || 0);
const totalSeconds = Math.floor(totalMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${padClock(minutes)}:${padClock(seconds)}`;
}
function formatLapLocal(ms) {
if (!Number.isFinite(ms)) {
return "-";
}
const totalMs = Math.max(0, Math.round(ms));
const minutes = Math.floor(totalMs / 60000);
const seconds = Math.floor((totalMs % 60000) / 1000);
const millis = totalMs % 1000;
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}