feat: add player hover stats panel
This commit is contained in:
80
ai/tasks/done/TASK-143-player-table-hover-stats-panel.md
Normal file
80
ai/tasks/done/TASK-143-player-table-hover-stats-panel.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
id: TASK-143
|
||||||
|
title: Player table hover stats panel
|
||||||
|
status: done
|
||||||
|
type: frontend
|
||||||
|
team: Frontend Senior
|
||||||
|
supporting_teams: [Experto en interfaz]
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-143 - Player table hover stats panel
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the internal historical match detail player table more compact by moving expanded per-player statistics into an accessible hover/focus/click details panel.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The detail page player table currently includes detailed columns for weapons, most killed and death by. The desired scoreboard-like UX keeps the table focused on player/team/core combat metrics and reveals expanded statistics per row.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Inspect the current match detail renderer and scoreboard detail CSS.
|
||||||
|
2. Reduce the visible table columns to Jugador, Equipo, K, D, TK, K/D and KPM.
|
||||||
|
3. Add accessible row hover/focus/click expanded panels with weapons, most killed, death by and direct matchups.
|
||||||
|
4. Preserve team styling, scoreboard link behavior and forbidden hidden sections.
|
||||||
|
5. Validate syntax, frontend build/container and rendered behavior.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/orchestrator/frontend-senior.md`
|
||||||
|
- `frontend/historico-partida.html`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/css/historico-scoreboard-detail.css`
|
||||||
|
- `frontend/assets/css/historico.css`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- `frontend/historico-partida.html`
|
||||||
|
- `frontend/assets/js/historico-partida.js`
|
||||||
|
- `frontend/assets/css/historico-scoreboard-detail.css`
|
||||||
|
- `ai/tasks/done/TASK-143-player-table-hover-stats-panel.md`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not modify backend unless absolutely necessary.
|
||||||
|
- Do not modify recent match card layout.
|
||||||
|
- Do not reintroduce timeline/events/confidence/source/base or Elo/MVP blocks.
|
||||||
|
- Preserve Aliados/Eje/No disponible visual distinction.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `docker compose build --no-cache frontend`
|
||||||
|
- `docker compose up -d frontend`
|
||||||
|
- Browser verification on `historico-partida.html`
|
||||||
|
- Browser smoke verification on `historico.html`
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented. The match detail player table now shows only Jugador, Equipo, K, D, TK, K/D and KPM. Expanded player statistics are rendered in per-player detail panels that open on row hover, keyboard focus/Enter and touch/click via an accessible info control. The panel includes the player/team summary, weapons, most killed, death by and direct matchup balance derived from most_killed and death_by.
|
||||||
|
|
||||||
|
Validation passed:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/historico-partida.js`
|
||||||
|
- `node --check frontend/assets/js/historico.js`
|
||||||
|
- `node --check frontend/assets/js/historico-recent-live.js`
|
||||||
|
- `docker compose build --no-cache frontend`
|
||||||
|
- `docker compose up -d frontend`
|
||||||
|
- Browser verification on `historico-partida.html` for `comunidad-hispana-02:1779178461:1779183861:carentanwarfare`
|
||||||
|
- Browser smoke verification on `historico.html?nocache=player-table-hover`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Browser plugin runtime tools were not exposed by tool discovery in this session, so rendered validation used local Chrome/Selenium fallback.
|
||||||
@@ -34,6 +34,244 @@
|
|||||||
background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%);
|
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 {
|
.historical-player-team-cell {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -71,6 +309,21 @@
|
|||||||
color: var(--muted);
|
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 {
|
.historical-scoreboard-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
|||||||
@@ -213,15 +213,39 @@ function renderPlayerSection(item, nodes) {
|
|||||||
|
|
||||||
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
|
nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`;
|
||||||
nodes.playersState.hidden = true;
|
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;
|
nodes.playersTableShell.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerRow(player) {
|
function renderPlayerRows(player, item, index) {
|
||||||
const team = getTeamSideDisplay(player.team || player.team_side);
|
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 `
|
return `
|
||||||
<tr class="historical-player-row historical-player-row--${team.key}">
|
<tr
|
||||||
<td>${escapeHtml(player.player_name || player.name || "Jugador no identificado")}</td>
|
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">
|
<td class="historical-player-team-cell">
|
||||||
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
|
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
|
||||||
${escapeHtml(team.label)}
|
${escapeHtml(team.label)}
|
||||||
@@ -231,13 +255,194 @@ function renderPlayerRow(player) {
|
|||||||
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
|
||||||
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
|
||||||
<td>${escapeHtml(formatKdRatio(player))}</td>
|
<td>${escapeHtml(formatKdRatio(player))}</td>
|
||||||
<td>${escapeHtml(formatNamedCounts(player.top_weapons))}</td>
|
<td>${escapeHtml(kpm)}</td>
|
||||||
<td>${escapeHtml(formatNamedCounts(player.most_killed))}</td>
|
</tr>
|
||||||
<td>${escapeHtml(formatNamedCounts(player.death_by))}</td>
|
<tr
|
||||||
|
class="historical-player-detail-row"
|
||||||
|
id="${escapeHtml(panelId)}"
|
||||||
|
aria-labelledby="${escapeHtml(rowId)}"
|
||||||
|
>
|
||||||
|
<td colspan="7">
|
||||||
|
${renderPlayerStatsPanel(player, item, { team, playerName, kpm })}
|
||||||
|
</td>
|
||||||
</tr>
|
</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) {
|
function renderActions(item, actionsNode) {
|
||||||
const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url);
|
const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url);
|
||||||
if (!matchUrl) {
|
if (!matchUrl) {
|
||||||
@@ -411,6 +616,19 @@ function formatKdRatio(player) {
|
|||||||
return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2);
|
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) {
|
function formatNamedCounts(items) {
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
return "No disponible";
|
return "No disponible";
|
||||||
@@ -425,6 +643,10 @@ function formatNamedCounts(items) {
|
|||||||
.join(" / ");
|
.join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasNamedCounts(items) {
|
||||||
|
return Array.isArray(items) && items.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
const parsedValue = Number(value);
|
const parsedValue = Number(value);
|
||||||
if (!Number.isFinite(parsedValue)) {
|
if (!Number.isFinite(parsedValue)) {
|
||||||
@@ -433,6 +655,14 @@ function formatNumber(value) {
|
|||||||
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
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) {
|
function formatDecimal(value, fractionDigits = 1) {
|
||||||
const parsedValue = Number(value);
|
const parsedValue = Number(value);
|
||||||
if (!Number.isFinite(parsedValue)) {
|
if (!Number.isFinite(parsedValue)) {
|
||||||
|
|||||||
@@ -97,9 +97,7 @@
|
|||||||
<th>D</th>
|
<th>D</th>
|
||||||
<th>TK</th>
|
<th>TK</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
<th>Armas</th>
|
<th>KPM</th>
|
||||||
<th>Mas abatido</th>
|
|
||||||
<th>Muere por</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="match-detail-players-body"></tbody>
|
<tbody id="match-detail-players-body"></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user