Load annual ranking by default and add KPM columns

This commit is contained in:
devRaGonSa
2026-06-09 16:29:25 +02:00
parent 88964d6126
commit ca08740619
14 changed files with 456 additions and 88 deletions

View File

@@ -267,6 +267,7 @@
display: inline-flex;
min-height: 32px;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid rgba(210, 182, 118, 0.34);
border-radius: 999px;
@@ -278,6 +279,16 @@
text-transform: uppercase;
}
.historical-player-profile-link__brand {
width: auto;
height: auto;
max-width: 20px;
max-height: 20px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.28));
}
.historical-player-profile-links a:hover,
.historical-player-profile-links a:focus-visible {
border-color: rgba(210, 182, 118, 0.62);

View File

@@ -477,6 +477,14 @@ h2 {
box-shadow: 0 18px 36px rgba(110, 127, 72, 0.45);
}
.discord-button:disabled {
cursor: not-allowed;
opacity: 0.58;
filter: saturate(0.7);
box-shadow: none;
transform: none;
}
.content {
width: 100%;
margin-top: -28px;
@@ -883,14 +891,14 @@ h2 {
.clan-card__brand {
display: grid;
grid-template-columns: minmax(92px, 112px) minmax(0, 1fr);
grid-template-columns: minmax(124px, 154px) minmax(0, 1fr);
align-items: center;
gap: 18px;
gap: 20px;
}
.clan-card__logo {
min-height: 92px;
padding: 12px;
min-height: 124px;
padding: 14px;
display: grid;
place-items: center;
border: 1px dashed rgba(183, 201, 125, 0.3);
@@ -898,33 +906,45 @@ h2 {
background: linear-gradient(180deg, rgba(19, 24, 16, 0.82), rgba(10, 13, 9, 0.66));
}
.clan-card__logo--standard {
min-height: 128px;
}
.clan-card__logo--wide {
padding-inline: 8px;
padding-inline: 10px;
}
.clan-card__logo--shield {
padding-inline: 18px;
padding-inline: 20px;
}
.clan-card--bxb .clan-card__brand {
grid-template-columns: minmax(112px, 138px) minmax(0, 1fr);
grid-template-columns: minmax(146px, 184px) minmax(0, 1fr);
}
.clan-card__logo--bxb {
min-height: 108px;
padding: 10px;
min-height: 148px;
padding: 12px;
}
.clan-card__logo img {
width: auto;
height: auto;
max-width: 100%;
max-height: 84px;
max-height: 108px;
object-fit: contain;
}
.clan-card__logo--wide img {
max-height: 112px;
}
.clan-card__logo--shield img {
max-height: 116px;
}
.clan-card__logo--bxb img {
max-height: 100px;
max-height: 132px;
}
.clan-card__logo-placeholder {
@@ -1111,6 +1131,30 @@ h2 {
gap: 16px;
}
.stats-annual-season {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin: 18px 0 14px;
padding: 0.8rem 1rem;
border: 1px solid rgba(183, 201, 125, 0.24);
border-radius: 14px;
background: linear-gradient(180deg, rgba(30, 36, 27, 0.78), rgba(12, 15, 11, 0.9));
}
.stats-annual-season__label {
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stats-annual-season__value {
color: var(--accent-strong);
font-size: 1.12rem;
letter-spacing: 0.08em;
}
.stats-annual-meta {
display: grid;
gap: 12px;
@@ -1497,6 +1541,11 @@ h2 {
font-weight: 700;
}
.ranking-table th[hidden],
.ranking-table td[hidden] {
display: none;
}
.ranking-player {
display: grid;
gap: 4px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -40,6 +40,25 @@ document.addEventListener("DOMContentLoaded", () => {
void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes });
});
const EXTERNAL_PROFILE_BRANDS = Object.freeze({
hellor: Object.freeze({
label: "Hellor",
logoSrc: "./assets/img/brands/hllor.webp",
}),
hll_records: Object.freeze({
label: "HLL Records",
logoSrc: "./assets/img/brands/hllrecords.png",
}),
helo: Object.freeze({
label: "Helo",
logoSrc: "./assets/img/brands/helo-system.png",
}),
steam: Object.freeze({
label: "Steam",
logoSrc: "",
}),
});
async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) {
try {
const payload = await fetchJson(
@@ -513,13 +532,17 @@ function renderPlayerStatsPanel(player, item, context) {
function renderExternalProfilesSection(player) {
const links = [
["steam", "Steam"],
["hellor", "Hellor"],
["hll_records", "HLL Records"],
["helo", "Helo"],
"steam",
"hellor",
"hll_records",
"helo",
]
.map(([key, label]) => [label, player.external_profile_links?.[key]])
.filter(([, href]) => typeof href === "string" && href.trim());
.map((key) => ({
key,
brand: EXTERNAL_PROFILE_BRANDS[key] || { label: key, logoSrc: "" },
href: player.external_profile_links?.[key],
}))
.filter((entry) => typeof entry.href === "string" && entry.href.trim());
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
@@ -530,9 +553,14 @@ function renderExternalProfilesSection(player) {
<div class="historical-player-profile-links">
${links
.map(
([label, href]) => `
({ brand, href }) => `
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
${escapeHtml(label)}
${
brand.logoSrc
? `<img class="historical-player-profile-link__brand" src="${escapeHtml(brand.logoSrc)}" alt="" aria-hidden="true" decoding="async" loading="lazy" />`
: ""
}
<span>${escapeHtml(brand.label)}</span>
</a>
`,
)

View File

@@ -613,14 +613,21 @@ function hydrateWeeklyLeaderboard(
bodyNode.innerHTML = items
.map(
(item) => `
(item) => {
const kills = resolveHistoricalKills(item, metricConfig);
const matches = Number(item.matches_considered);
const kpm = formatHistoricalKpm(item?.kills_per_match, kills, matches);
return `
<tr>
<td class="historical-table__position">#${escapeHtml(item.ranking_position)}</td>
<td>${escapeHtml(item.player?.name || "Jugador no identificado")}</td>
<td>${escapeHtml(formatNumber(item.metric_value))}</td>
<td>${escapeHtml(formatNumber(item.matches_considered))}</td>
<td>${escapeHtml(kpm)}</td>
</tr>
`,
`;
},
)
.join("");
stateNode.hidden = true;
@@ -1667,6 +1674,37 @@ function formatTopMaps(topMaps) {
.join(" / ");
}
function resolveHistoricalKills(item, metricConfig) {
const directKills = Number(item?.kills);
if (Number.isFinite(directKills)) {
return directKills;
}
if (metricConfig?.key === "kills") {
const metricValue = Number(item?.metric_value);
if (Number.isFinite(metricValue)) {
return metricValue;
}
}
return Number.NaN;
}
function formatHistoricalKpm(rawKillsPerMatch, rawKills, rawMatches) {
const directValue = Number(rawKillsPerMatch);
if (Number.isFinite(directValue)) {
return formatDecimal(directValue, 2);
}
const kills = Number(rawKills);
const matches = Number(rawMatches);
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
return "-";
}
return formatDecimal(kills / matches, 2);
}
function formatDateOnly(timestamp) {
if (!timestamp) {
return "Fecha no disponible";

View File

@@ -20,7 +20,7 @@ const COMMUNITY_CLANS = Object.freeze([
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/lcm.png",
logoAlt: "Logo de LCM",
logoClassName: "",
logoClassName: "clan-card__logo--standard",
discordUrl: "https://discord.gg/9F9S353QZv",
discordLabel: "Abrir Discord",
},
@@ -53,7 +53,7 @@ const COMMUNITY_CLANS = Object.freeze([
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/h9h.png",
logoAlt: "",
logoClassName: "",
logoClassName: "clan-card__logo--standard",
discordUrl: "https://discord.gg/tYnXK7MQjB",
discordLabel: "Abrir Discord",
placeholderLabel: "H9H",
@@ -77,7 +77,7 @@ const COMMUNITY_CLANS = Object.freeze([
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/7dv.png",
logoAlt: "Logo de 7dv",
logoClassName: "",
logoClassName: "clan-card__logo--standard",
discordUrl: "https://discord.gg/3sxNQZwrg6",
discordLabel: "Abrir Discord",
},

View File

@@ -13,6 +13,7 @@
const tableNode = document.getElementById("ranking-table");
const tableBodyNode = document.getElementById("ranking-table-body");
const metricHeadingNode = document.getElementById("ranking-metric-heading");
const kpmHeadingNode = document.getElementById("ranking-kpm-heading");
const emptyNode = document.getElementById("ranking-empty");
const filterNoteNode = document.getElementById("ranking-filter-note");
@@ -338,7 +339,7 @@
if (statusCode === 400 && normalizedMessage.includes("metric")) {
setRankingState("warning", "La m\u00e9trica solicitada no est\u00e1 soportada.");
renderEmptyState(
"Usa kills, deaths, teamkills, partidas jugadas, K/D o kills por partida.",
"Usa kills, deaths, teamkills, partidas jugadas, K/D o KPM.",
);
return;
}
@@ -414,6 +415,7 @@
if (tableBodyNode) {
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
}
syncKpmColumn(metric);
if (tableNode) {
tableNode.hidden = false;
}
@@ -455,6 +457,9 @@
}
function renderRow(item, metric) {
const kpm = formatKpm(item.kills_per_match, item.kills, item.matches_considered);
const hideKpmColumn = metric === "kills_per_match";
return `
<tr>
<td>#${safeInt(item.ranking_position, 0)}</td>
@@ -470,11 +475,28 @@
<td>${safeInt(item.teamkills, 0)}</td>
<td>${safeInt(item.matches_considered, 0)}</td>
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
<td>${safeDecimal(item.kills_per_match, 2, "0.00")}</td>
${hideKpmColumn ? "" : `<td>${kpm}</td>`}
</tr>
`;
}
function syncKpmColumn(metric) {
if (!tableNode || !kpmHeadingNode) {
return;
}
const kpmColumnIndex = kpmHeadingNode.cellIndex + 1;
const hideKpmColumn = metric === "kills_per_match";
kpmHeadingNode.hidden = hideKpmColumn;
tableNode.querySelectorAll("tbody tr").forEach((row) => {
const cell = row.children[kpmColumnIndex - 1];
if (cell) {
cell.hidden = hideKpmColumn;
}
});
}
function labelForWindow(data) {
if (String(data.timeframe || "") === "annual") {
return `A\u00f1o ${safeInt(data.year, currentYear)}`;
@@ -509,7 +531,7 @@
teamkills: "Teamkills",
matches_considered: "Partidas jugadas",
kd_ratio: "K/D",
kills_per_match: "Kills por partida",
kills_per_match: "KPM",
};
return labels[metric] || "Kills";
}
@@ -540,6 +562,21 @@
});
}
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
const directValue = Number(rawKillsPerMatch);
if (Number.isFinite(directValue)) {
return safeDecimal(directValue, 2, "0.00");
}
const kills = Number(rawKills);
const matches = Number(rawMatches);
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
return "-";
}
return safeDecimal(kills / matches, 2, "0.00");
}
function safeParseJson(response) {
if (!response) {
return null;

View File

@@ -2,6 +2,7 @@
const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const searchForm = document.getElementById("stats-search-form");
const searchInput = document.getElementById("stats-search-input");
const searchSubmitButton = document.getElementById("stats-search-submit");
const searchStateNode = document.getElementById("stats-search-state");
const searchHelpNode = document.getElementById("stats-search-help");
const resultListNode = document.getElementById("stats-result-list");
@@ -12,12 +13,11 @@
const summaryGrid = document.getElementById("stats-summary-grid");
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
const annualForm = document.getElementById("stats-annual-form");
const annualYearInput = document.getElementById("stats-annual-year");
const annualStateNode = document.getElementById("stats-annual-state");
const annualContentNode = document.getElementById("stats-annual-content");
const annualDefaultYear = new Date().getUTCFullYear();
const annualDefaultYear = 2026;
const minimumSearchQueryLength = 4;
const annualMetric = "kills";
const annualMetricSupportedOnly = "kills";
const annualLimit = 20;
@@ -28,7 +28,7 @@
"Backend no disponible. No se pueden buscar jugadores ni cargar su perfil.",
backendUnavailableForAnnual:
"Backend no disponible. No se puede consultar el ranking anual.",
searchNoInput: "Escribe un nombre o ID para buscar.",
searchNoInput: "Introduce al menos 4 caracteres para buscar un jugador.",
searchLoading: "Buscando jugadores...",
searchEmpty: "Sin resultados. Prueba con otro texto o ID.",
searchError:
@@ -37,7 +37,7 @@
searchReadySuffix:
" jugador(es). Selecciona uno para ver sus estad\u00edsticas.",
searchShortQueryHelp:
"Usa al menos 1 car\u00e1cter para iniciar la b\u00fasqueda.",
"Introduce al menos 4 caracteres para buscar un jugador.",
profileLoading: "Cargando estad\u00edsticas personales...",
profileNoStats:
"Jugador sin estad\u00edsticas suficientes para mostrar datos personales.",
@@ -74,29 +74,24 @@
if (searchHelpNode) {
searchHelpNode.textContent =
"Usa el buscador para encontrar un jugador. Al seleccionar uno ver\u00e1s resumen semanal y mensual. " +
"El ranking anual se consulta por separado con el a\u00f1o indicado.";
"Usa al menos 4 caracteres para encontrar un jugador. Al seleccionar uno ver\u00e1s resumen semanal y mensual. " +
"El ranking anual visible corresponde a la temporada 2026.";
}
if (annualYearInput) {
annualYearInput.value = String(annualDefaultYear);
}
clearProfilePanel(false);
syncSearchFormState();
refreshBackendHealth();
if (annualForm && annualYearInput) {
annualForm.addEventListener("submit", (event) => {
event.preventDefault();
void loadAnnualRanking();
});
}
if (searchForm && searchInput) {
searchInput.addEventListener("input", () => {
syncSearchFormState();
});
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const query = (searchInput.value || "").trim();
if (!query) {
setSearchState("error", messages.searchNoInput);
if (!isValidSearchQuery(query)) {
syncSearchFormState();
return;
}
void searchPlayers(query);
@@ -161,7 +156,7 @@
isBackendOnline = true;
setAnnualState(
"neutral",
"Backend disponible. Selecciona un a\u00f1o para cargar el ranking anual.",
"Backend disponible. Cargando ranking anual 2026.",
);
void loadAnnualRanking();
} catch (error) {
@@ -174,7 +169,7 @@
if (!resultListNode) {
return;
}
if (!query) {
if (!isValidSearchQuery(query)) {
setSearchState("error", messages.searchShortQueryHelp);
return;
}
@@ -227,7 +222,7 @@
}
async function loadAnnualRanking() {
if (!annualForm || !annualYearInput || !annualContentNode) {
if (!annualContentNode) {
return;
}
if (!isBackendOnline) {
@@ -235,11 +230,7 @@
return;
}
const year = resolveAnnualYear();
if (year === null) {
setAnnualState("error", "El a\u00f1o ingresado no es v\u00e1lido.");
return;
}
const year = annualDefaultYear;
setAnnualState("loading", messages.annualLoading);
annualContentNode.innerHTML = "";
@@ -296,14 +287,6 @@
);
}
function resolveAnnualYear() {
const normalized = Number.parseInt(String(annualYearInput?.value || "").trim(), 10);
if (!Number.isFinite(normalized) || normalized <= 0) {
return null;
}
return normalized;
}
function renderAnnualRanking(data) {
if (!annualContentNode || !annualStateNode) {
return;
@@ -369,8 +352,9 @@
const rank = safeInt(item.ranking_position, 0);
const playerId = escapeHtml(String(item.player_id || ""));
const playerName = escapeHtml(String(item.player_name || "Jugador sin nombre"));
const metricValue = safeInt(item.metric_value, 0);
const kills = safeInt(firstFiniteValue(item.kills, item.metric_value), 0);
const matches = safeInt(item.matches_considered, 0);
const kpm = formatKpm(item.kills_per_match, kills, matches);
const deaths = safeInt(item.deaths, 0);
const teamkills = safeInt(item.teamkills, 0);
const kd = safeDecimal(item.kd_ratio, 2, "0.00");
@@ -384,8 +368,9 @@
<span class="stats-annual-player__id">ID ${playerId}</span>
</div>
</td>
<td class="stats-annual-metric">${metricValue}</td>
<td class="stats-annual-metric">${kills}</td>
<td>${matches}</td>
<td>${kpm}</td>
<td>${deaths}</td>
<td>${teamkills}</td>
<td>${kd}</td>
@@ -401,8 +386,9 @@
<tr>
<th>Posici\u00f3n</th>
<th>Jugador</th>
<th>Valor / Kills</th>
<th>Kills</th>
<th>Partidas</th>
<th>KPM</th>
<th>Muertes</th>
<th>Teamkills</th>
<th>K/D</th>
@@ -891,6 +877,31 @@
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function firstFiniteValue(...values) {
for (const value of values) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return Number.NaN;
}
function formatKpm(rawKillsPerMatch, rawKills, rawMatches) {
const directValue = safeParseNumber(rawKillsPerMatch);
if (Number.isFinite(directValue)) {
return safeDecimal(directValue, 2, "0.00");
}
const kills = safeParseNumber(rawKills);
const matches = safeParseNumber(rawMatches);
if (!Number.isFinite(kills) || !Number.isFinite(matches) || matches <= 0) {
return "-";
}
return safeDecimal(kills / matches, 2, "0.00");
}
function safeParseJson(response) {
if (!response) {
return null;
@@ -920,6 +931,35 @@
return Array.isArray(items) ? items : [];
}
function isValidSearchQuery(query) {
return String(query || "").trim().length >= minimumSearchQueryLength;
}
function syncSearchFormState() {
const query = String(searchInput?.value || "").trim();
const isValid = isValidSearchQuery(query);
if (searchSubmitButton) {
searchSubmitButton.disabled = !isValid;
}
if (!searchStateNode) {
return;
}
if (!query) {
setSearchState(
"neutral",
"Estado inicial: introduce al menos 4 caracteres para buscar un jugador.",
);
return;
}
if (!isValid) {
setSearchState("warning", messages.searchShortQueryHelp);
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")

View File

@@ -199,6 +199,7 @@
<th>Jugador</th>
<th id="weekly-leaderboard-value-heading">Kills</th>
<th>Partidas</th>
<th>KPM</th>
</tr>
</thead>
<tbody id="weekly-leaderboard-body"></tbody>

View File

@@ -90,7 +90,7 @@
<option value="teamkills">Teamkills</option>
<option value="matches_considered">Partidas jugadas</option>
<option value="kd_ratio">K/D</option>
<option value="kills_per_match">Kills por partida</option>
<option value="kills_per_match">KPM</option>
</select>
</label>
<label class="stats-search-form__label" for="ranking-limit">
@@ -152,7 +152,7 @@
<th>Teamkills</th>
<th>Partidas</th>
<th>K/D</th>
<th>Kills/partida</th>
<th id="ranking-kpm-heading">KPM</th>
</tr>
</thead>
<tbody id="ranking-table-body"></tbody>

View File

@@ -62,7 +62,7 @@
<h2>Buscar por nombre o ID</h2>
</div>
<p class="panel__intro panel__intro--tight" id="stats-search-help">
Usa al menos 1 car&aacute;cter para buscar. Al seleccionar un jugador se cargar&aacute;n sus
Usa al menos 4 caracteres para buscar. Al seleccionar un jugador se cargar&aacute;n sus
stats y ranking semanal y mensual. El ranking anual se consulta por separado.
</p>
<form class="stats-search-form" id="stats-search-form">
@@ -79,7 +79,13 @@
autocomplete="off"
aria-label="Texto de b&uacute;squeda de jugadores"
/>
<button class="discord-button" type="submit" aria-label="Buscar jugador por nombre o ID">
<button
class="discord-button"
id="stats-search-submit"
type="submit"
aria-label="Buscar jugador por nombre o ID"
disabled
>
Buscar jugador
</button>
</div>
@@ -123,30 +129,17 @@
<div class="panel__shell">
<div class="panel__header">
<p class="eyebrow eyebrow--section">Ranking anual</p>
<h2>Top 20 anual</h2>
<h2>Top 20 temporada 2026</h2>
</div>
<p class="panel__intro panel__intro--tight">
El ranking anual usa un corte ya preparado de la temporada para cargar el Top 20 sin recalcular toda la campa&ntilde;a en cada consulta.
</p>
<form class="stats-annual-form" id="stats-annual-form">
<label class="stats-search-form__label" for="stats-annual-year">A&ntilde;o</label>
<div class="stats-search-form__fields">
<input
class="stats-search-input"
id="stats-annual-year"
type="number"
min="2019"
value=""
aria-label="A&ntilde;o del ranking anual"
aria-describedby="stats-annual-state"
/>
<button class="discord-button" type="submit" aria-label="Cargar ranking anual">
Cargar ranking anual
</button>
</div>
</form>
<div class="stats-annual-season" aria-label="Temporada fija del ranking anual">
<span class="stats-annual-season__label">Temporada</span>
<strong class="stats-annual-season__value">2026</strong>
</div>
<p class="stats-state stats-state--neutral" id="stats-annual-state" role="status" aria-live="polite">
Esperando a&ntilde;o para cargar el ranking anual (Top 20 por kills).
Preparando ranking anual 2026 (Top 20 por kills).
</p>
<div class="stats-annual-table-wrap" id="stats-annual-content" aria-live="polite"></div>
</div>