feat: add player hover stats panel
This commit is contained in:
@@ -34,6 +34,244 @@
|
||||
background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%);
|
||||
}
|
||||
|
||||
.historical-table--players {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.historical-player-row {
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.historical-player-row:hover td,
|
||||
.historical-player-row:focus td,
|
||||
.historical-player-row:focus-within td,
|
||||
.historical-player-row.is-expanded td {
|
||||
background: rgba(210, 182, 118, 0.06);
|
||||
}
|
||||
|
||||
.historical-player-row:focus-visible td,
|
||||
.historical-player-row__details-button:focus-visible {
|
||||
outline: 2px solid rgba(210, 182, 118, 0.78);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.historical-player-row__details-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.historical-player-row__details-button span:first-child {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.historical-player-row__details-button span:last-child {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(210, 182, 118, 0.28);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-player-detail-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historical-player-row:hover + .historical-player-detail-row,
|
||||
.historical-player-row:focus + .historical-player-detail-row,
|
||||
.historical-player-row:focus-within + .historical-player-detail-row,
|
||||
.historical-player-detail-row:hover,
|
||||
.historical-player-detail-row:focus-within,
|
||||
.historical-player-detail-row.is-open {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.historical-player-detail-row td {
|
||||
padding: 0 12px 16px;
|
||||
background: rgba(7, 9, 7, 0.76);
|
||||
border-bottom-color: rgba(210, 182, 118, 0.16);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(210, 182, 118, 0.18);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(19, 24, 17, 0.96), rgba(10, 13, 10, 0.98)),
|
||||
rgba(10, 13, 10, 0.96);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__header p,
|
||||
.historical-player-stats-panel__section p,
|
||||
.historical-player-stats-panel__empty {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__header p {
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__header h4,
|
||||
.historical-player-stats-panel__section h5 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__header h4 {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(70px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__summary article {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(159, 168, 141, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(13, 17, 12, 0.68);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__summary span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__summary strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
padding: 13px;
|
||||
border: 1px solid rgba(159, 168, 141, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(13, 17, 12, 0.52);
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section h5 {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section ol {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section li span {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--text-soft);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__section li strong {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.historical-player-matchups {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.historical-player-matchups [role="row"] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(72px, auto));
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(159, 168, 141, 0.1);
|
||||
}
|
||||
|
||||
.historical-player-matchups [role="row"]:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.historical-player-matchups [role="columnheader"] {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-player-matchups [role="cell"] {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.historical-player-matchups strong[role="cell"] {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.historical-player-team-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -71,6 +309,21 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.historical-player-stats-panel__header,
|
||||
.historical-player-stats-panel__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.historical-player-stats-panel__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.historical-player-matchups [role="row"] {
|
||||
min-width: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
.historical-scoreboard-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
|
||||
@@ -213,15 +213,39 @@ function renderPlayerSection(item, nodes) {
|
||||
|
||||
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
|
||||
nodes.playersState.hidden = true;
|
||||
nodes.playersBody.innerHTML = players.map((player) => renderPlayerRow(player)).join("");
|
||||
nodes.playersBody.innerHTML = players
|
||||
.map((player, index) => renderPlayerRows(player, item, index))
|
||||
.join("");
|
||||
bindPlayerDetailRows(nodes.playersBody);
|
||||
nodes.playersTableShell.hidden = false;
|
||||
}
|
||||
|
||||
function renderPlayerRow(player) {
|
||||
function renderPlayerRows(player, item, index) {
|
||||
const team = getTeamSideDisplay(player.team || player.team_side);
|
||||
const rowId = `match-player-row-${index}`;
|
||||
const panelId = `match-player-panel-${index}`;
|
||||
const playerName = player.player_name || player.name || "Jugador no identificado";
|
||||
const kpm = formatKpm(player.kills, item.duration_seconds);
|
||||
return `
|
||||
<tr class="historical-player-row historical-player-row--${team.key}">
|
||||
<td>${escapeHtml(player.player_name || player.name || "Jugador no identificado")}</td>
|
||||
<tr
|
||||
class="historical-player-row historical-player-row--${team.key}"
|
||||
id="${escapeHtml(rowId)}"
|
||||
tabindex="0"
|
||||
aria-controls="${escapeHtml(panelId)}"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<td>
|
||||
<button
|
||||
class="historical-player-row__details-button"
|
||||
type="button"
|
||||
aria-controls="${escapeHtml(panelId)}"
|
||||
aria-expanded="false"
|
||||
aria-label="Ver estadisticas ampliadas de ${escapeHtml(playerName)}"
|
||||
>
|
||||
<span>${escapeHtml(playerName)}</span>
|
||||
<span aria-hidden="true">i</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="historical-player-team-cell">
|
||||
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
|
||||
${escapeHtml(team.label)}
|
||||
@@ -231,13 +255,194 @@ function renderPlayerRow(player) {
|
||||
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
||||
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
||||
<td>${escapeHtml(formatKdRatio(player))}</td>
|
||||
<td>${escapeHtml(formatNamedCounts(player.top_weapons))}</td>
|
||||
<td>${escapeHtml(formatNamedCounts(player.most_killed))}</td>
|
||||
<td>${escapeHtml(formatNamedCounts(player.death_by))}</td>
|
||||
<td>${escapeHtml(kpm)}</td>
|
||||
</tr>
|
||||
<tr
|
||||
class="historical-player-detail-row"
|
||||
id="${escapeHtml(panelId)}"
|
||||
aria-labelledby="${escapeHtml(rowId)}"
|
||||
>
|
||||
<td colspan="7">
|
||||
${renderPlayerStatsPanel(player, item, { team, playerName, kpm })}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindPlayerDetailRows(playersBody) {
|
||||
playersBody.querySelectorAll(".historical-player-row").forEach((row) => {
|
||||
const button = row.querySelector(".historical-player-row__details-button");
|
||||
const detailRow = row.nextElementSibling;
|
||||
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
|
||||
return;
|
||||
}
|
||||
const setExpanded = (expanded) => {
|
||||
row.classList.toggle("is-expanded", expanded);
|
||||
detailRow.classList.toggle("is-open", expanded);
|
||||
row.setAttribute("aria-expanded", String(expanded));
|
||||
button.setAttribute("aria-expanded", String(expanded));
|
||||
};
|
||||
const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open"));
|
||||
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("a")) {
|
||||
return;
|
||||
}
|
||||
toggleExpanded();
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleExpanded();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlayerStatsPanel(player, item, context) {
|
||||
const matchups = buildPlayerDirectMatchups(player);
|
||||
const hasExpandedStats =
|
||||
hasNamedCounts(player.top_weapons) ||
|
||||
hasNamedCounts(player.most_killed) ||
|
||||
hasNamedCounts(player.death_by) ||
|
||||
matchups.length > 0;
|
||||
|
||||
return `
|
||||
<section class="historical-player-stats-panel" aria-label="Estadisticas ampliadas de ${escapeHtml(context.playerName)}">
|
||||
<div class="historical-player-stats-panel__header">
|
||||
<div>
|
||||
<p>${escapeHtml(context.team.label)}</p>
|
||||
<h4>${escapeHtml(context.playerName)}</h4>
|
||||
</div>
|
||||
<div class="historical-player-stats-panel__summary">
|
||||
${renderPlayerStatChip("K", formatOptionalNumber(player.kills))}
|
||||
${renderPlayerStatChip("D", formatOptionalNumber(player.deaths))}
|
||||
${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))}
|
||||
${renderPlayerStatChip("K/D", formatKdRatio(player))}
|
||||
${renderPlayerStatChip("KPM", context.kpm)}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
hasExpandedStats
|
||||
? `
|
||||
<div class="historical-player-stats-panel__grid">
|
||||
${renderNamedCountSection("Armas", player.top_weapons)}
|
||||
${renderNamedCountSection("Mas abatido", player.most_killed)}
|
||||
${renderNamedCountSection("Muere por", player.death_by)}
|
||||
${renderDirectMatchupsSection(matchups)}
|
||||
</div>
|
||||
`
|
||||
: `<p class="historical-player-stats-panel__empty">Sin estadisticas ampliadas disponibles.</p>`
|
||||
}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPlayerStatChip(label, value) {
|
||||
return `
|
||||
<article>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNamedCountSection(title, items) {
|
||||
if (!hasNamedCounts(items)) {
|
||||
return `
|
||||
<article class="historical-player-stats-panel__section">
|
||||
<h5>${escapeHtml(title)}</h5>
|
||||
<p>No disponible</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<article class="historical-player-stats-panel__section">
|
||||
<h5>${escapeHtml(title)}</h5>
|
||||
<ol>
|
||||
${items
|
||||
.map((stat) => {
|
||||
const name = stat.name || stat.label || "Sin nombre";
|
||||
const count = stat.count ?? stat.total ?? 0;
|
||||
return `<li><span>${escapeHtml(name)}</span><strong>${escapeHtml(formatNumber(count))}</strong></li>`;
|
||||
})
|
||||
.join("")}
|
||||
</ol>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDirectMatchupsSection(matchups) {
|
||||
if (!matchups.length) {
|
||||
return `
|
||||
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
|
||||
<h5>Duelo directo</h5>
|
||||
<p>No disponible</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
|
||||
<h5>Duelo directo</h5>
|
||||
<div class="historical-player-matchups" role="table" aria-label="Duelos directos">
|
||||
<div role="row">
|
||||
<span role="columnheader">Rival</span>
|
||||
<span role="columnheader">Abatidos</span>
|
||||
<span role="columnheader">Muertes</span>
|
||||
<span role="columnheader">Balance</span>
|
||||
</div>
|
||||
${matchups
|
||||
.map(
|
||||
(matchup) => `
|
||||
<div role="row">
|
||||
<span role="cell">${escapeHtml(matchup.name)}</span>
|
||||
<strong role="cell">${escapeHtml(formatNumber(matchup.kills))}</strong>
|
||||
<strong role="cell">${escapeHtml(formatNumber(matchup.deaths))}</strong>
|
||||
<strong role="cell">${escapeHtml(formatSignedNumber(matchup.balance))}</strong>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildPlayerDirectMatchups(player) {
|
||||
const byName = new Map();
|
||||
const addStats = (items, key) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const name = item.name || item.label;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const normalizedName = String(name);
|
||||
const current = byName.get(normalizedName) || {
|
||||
name: normalizedName,
|
||||
kills: 0,
|
||||
deaths: 0,
|
||||
};
|
||||
current[key] += Number(item.count ?? item.total ?? 0) || 0;
|
||||
byName.set(normalizedName, current);
|
||||
});
|
||||
};
|
||||
|
||||
addStats(player.most_killed, "kills");
|
||||
addStats(player.death_by, "deaths");
|
||||
return [...byName.values()]
|
||||
.map((matchup) => ({
|
||||
...matchup,
|
||||
balance: matchup.kills - matchup.deaths,
|
||||
involvement: matchup.kills + matchup.deaths,
|
||||
}))
|
||||
.sort((a, b) => b.involvement - a.involvement || a.name.localeCompare(b.name, "es"))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function renderActions(item, actionsNode) {
|
||||
const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url);
|
||||
if (!matchUrl) {
|
||||
@@ -411,6 +616,19 @@ function formatKdRatio(player) {
|
||||
return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2);
|
||||
}
|
||||
|
||||
function formatKpm(kills, durationSeconds) {
|
||||
const parsedKills = Number(kills);
|
||||
const parsedDurationSeconds = Number(durationSeconds);
|
||||
if (
|
||||
!Number.isFinite(parsedKills) ||
|
||||
!Number.isFinite(parsedDurationSeconds) ||
|
||||
parsedDurationSeconds <= 0
|
||||
) {
|
||||
return formatDecimal(0, 2);
|
||||
}
|
||||
return formatDecimal(parsedKills / (parsedDurationSeconds / 60), 2);
|
||||
}
|
||||
|
||||
function formatNamedCounts(items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return "No disponible";
|
||||
@@ -425,6 +643,10 @@ function formatNamedCounts(items) {
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function hasNamedCounts(items) {
|
||||
return Array.isArray(items) && items.length > 0;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
@@ -433,6 +655,14 @@ function formatNumber(value) {
|
||||
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
||||
}
|
||||
|
||||
function formatSignedNumber(value) {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue === 0) {
|
||||
return "0";
|
||||
}
|
||||
return `${parsedValue > 0 ? "+" : ""}${formatNumber(parsedValue)}`;
|
||||
}
|
||||
|
||||
function formatDecimal(value, fractionDigits = 1) {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
|
||||
Reference in New Issue
Block a user