Extract overlay and OBS rendering into module
This commit is contained in:
348
src/app.js
348
src/app.js
@@ -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
373
src/overlays.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user