El Nivel 2 ha cubierto variables, comparaciones, template literals, condicionales, bucles, funciones, arrays, objetos, métodos de array y el DOM. Son nueve capítulos de fundamentos. Este es el décimo: no hay concepto nuevo. La pregunta es si puedes usar todo eso junto para resolver un problema real de principio a fin.
Qué construyes#
Partes de la home del Team Builder que maquetaste en el Nivel 1: el HTML semántico, el CSS responsive y las tarjetas ya dibujadas. Esa base sigue intacta.
No tienes que traer tu propio trabajo del Nivel 1: el ejercicio ya incluye el HTML y el CSS de partida listos (adaptados para recibir contenido dinámico). Aunque no hicieras el proyecto anterior, aquí arrancas con la maqueta puesta y solo añades el JS.
Lo que añades en este proyecto es un app.js que le da vida:
- Pintar las cartas desde datos. En vez de tener ocho
<article>hardcodeados en el HTML, el JS lee el array de héroes y genera los nodos del DOM. - Filtrar por rol. Cuatro botones (Todos / Tanque / Daño / Apoyo) que, al pulsarse, muestran solo los héroes del rol elegido y marcan el botón activo.
- Buscar por nombre. Un
<input>que filtra en tiempo real mientras escribes, combinado con el filtro de rol. - Calcular y mostrar el winrate.
victorias / partidas × 100, redondeado, en porcentaje. - Estado vacío. Si los filtros combinados no devuelven héroes, aparece un aviso.
El HTML y el CSS siguen siendo tu responsabilidad: los criterios de aceptación exigen que sigan semánticos y responsive en los tres tiers.
Por qué importa construir sobre lo anterior#
En un proyecto real nunca empiezas de cero. Te incorporas a una base existente, o añades una capa sobre algo que ya funciona. La disciplina de no romper lo que ya estaba (HTML semántico, foco accesible, grid responsive a 375px) es tan parte del trabajo como añadir las funcionalidades nuevas.
El tier «excelente» no solo exige que el JS funcione: exige que la página siga
accesible con contenido generado dinámicamente y que respete prefers-reduced-motion,
igual que pedía el proyecto del Nivel 1.
Cómo se ataca#
El orden importa. Empieza por lo más pequeño que demuestre que funciona:
- Coge las referencias al DOM que vas a necesitar (
getElementByIdoquerySelector). - Define el dataset en el propio fichero (array de objetos con
nombre,siglas,rol,partidas,victorias). - Escribe la función de render: recibe un array y pinta las tarjetas. Comprueba que se ven al abrir el fichero antes de añadir eventos.
- Añade los botones de filtro (puedes crearlos con
createElemento escribirlos en el HTML; cualquiera está bien para el tier «ok»). - Conecta los eventos de los botones y del input de búsqueda. Para la búsqueda
por nombre sin distinguir mayúsculas, usa
.toLowerCase()y.includes()que viste en el capítulo de template literals:nombre.toLowerCase().includes(texto.toLowerCase()). - Verifica a 375px: arrastra el borde del navegador o usa DevTools. Los filtros y la búsqueda deben funcionar igual de bien en móvil.
No intentes hacer todo a la vez. Un paso a la vez, comprueba que funciona, y avanza.
Una nota sobre DocumentFragment, que aparece en los criterios del tier «excelente»: es
una técnica de optimización del DOM que no se ha enseñado en un capítulo propio y no
es requisito para aprobar el proyecto. La descubrirás leyendo la solución «excelente», donde
va comentada. Considérala un premio para quien quiera ir más allá, no un obstáculo de paso.
Comprueba lo que sabes#
Pregunta 1 de 5
El grid de héroes lo pinta el JS cada vez que cambia un filtro. ¿Por qué es mejor calcularlo con funciones puras en lugar de lógica mezclada?
Tu turno#
Este ejercicio se hace en local, en tu editor: es un proyecto completo que conviene vivir como tal, con tus ficheros, tu editor y tu navegador.
Clona o descarga la carpeta, abre app.js y sigue los TODO. Cuando termines,
despliega las soluciones y compara. Fíjate en cómo el «ok» mezcla lógica y
en cómo el «excelente» la separa sin complicar más el código.
Ejercicio · hazlo en local
Dale vida al Team Builder con JavaScript
El HTML y el CSS ya están (los construiste en el Nivel 1, adaptados para recibir contenido dinámico). Tu trabajo es app.js: leer el dataset de héroes, pintarlos en el grid, hacer que los botones de filtro funcionen y conectar la búsqueda por nombre. Sin módulos import/export ni fetch: los datos van como array literal en el propio fichero.
Paso 1: Que funcione
- Las ocho cartas se ven al cargar la página (pintadas desde el array, no hardcodeadas en el HTML).
- Los botones Todos / Tanque / Daño / Apoyo filtran correctamente y el botón activo se marca con la clase "activo".
- El input de búsqueda filtra en tiempo real por nombre (sin distinguir mayúsculas).
- Cuando no hay resultados aparece el párrafo de aviso; cuando sí los hay, desaparece.
- El HTML del Nivel 1 sigue semántico y el CSS sigue responsive (la página no se rompe a ningún ancho).
- Si usas innerHTML += dentro del bucle de render, funciona pero reprocesa todo el DOM en cada vuelta; el tier «mejor» lo resuelve con createElement y DocumentFragment.
Paso 2: Que esté pulido
- Las tarjetas se construyen con createElement y textContent, no con innerHTML concatenando datos.
- Los eventos usan addEventListener, no onclick inline en el HTML.
- Al menos un listener delegado (en el contenedor padre, no uno por tarjeta o botón).
- La lógica de render, filtros y eventos vive en funciones con responsabilidad única.
- El grid sigue responsive (auto-fit + minmax) y aguanta a 375px sin romperse.
Paso 3: Que sea excelente
- Los datos se enriquecen con map una sola vez (winrate calculado al inicio); el resto del código solo lo lee.
- Las funciones de filtrado son puras: reciben datos y devuelven datos nuevos sin mutar el original.
- DocumentFragment: todas las tarjetas se insertan de golpe (un solo reflow).
- textContent en todos los datos de usuario o del dataset: ningún valor puede inyectar HTML.
- Delegación en todos los listeners interactivos (filtros y selección de tarjeta).
- El aviso de «sin resultados» tiene aria-live="polite": los lectores de pantalla anuncian el cambio sin interrumpir al usuario.
- El HTML y CSS del Nivel 1 siguen correctos; el CSS aguanta a 375px y respeta prefers-reduced-motion en transiciones y transforms.
Cómo hacerlo en local
Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el
index.html en tu navegador. Toda tu solución va en
solucion.js.
git clone <repo>
cd exercises/nivel-2/proyecto-team-builder-js
# abre index.html en el navegador y edita solucion.js Ver soluciones
// SOLUCIÓN OK — funciona: pinta los héroes, los filtros responden,
// la búsqueda filtra por nombre.
// El HTML/CSS del Nivel 1 sigue intacto: la página se ve bien y es responsiva.
//
// Qué tiene de mejorable:
// - innerHTML con concatenación de strings: si un nombre llevara "<" o ">",
// el navegador lo interpretaría como HTML y podría romper la página.
// - Un listener por tarjeta (bucle for) en lugar de delegación: si hay muchas
// cartas, hay muchos handlers en memoria.
// - La lógica de filtrado, render y eventos está mezclada: cuesta seguirla.
// - El winrate se recalcula cada vez que se repinta en lugar de calcularse una sola vez.
// ── Referencias al DOM ────────────────────────────────────────────────────
// el <ul> donde van las tarjetas
const grid = document.getElementById("hero-grid");
// el <nav> con los botones de rol
const navFiltros = document.getElementById("filtros");
// el <input type="search">
const inputBusqueda = document.getElementById("busqueda");
// el aviso de "sin resultados"
const parrafoVacio = document.getElementById("resultado-vacio");
// ── Dataset de héroes ─────────────────────────────────────────────────────
const heroes = [
{ nombre: "Tracer", siglas: "TR", rol: "Daño", partidas: 120, victorias: 78 },
{
nombre: "Reinhardt",
siglas: "RE",
rol: "Tanque",
partidas: 90,
victorias: 51,
},
{
nombre: "Mercy",
siglas: "ME",
rol: "Apoyo",
partidas: 200,
victorias: 130,
},
{ nombre: "Genji", siglas: "GE", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "D.Va", siglas: "DV", rol: "Tanque", partidas: 140, victorias: 84 },
{ nombre: "Ana", siglas: "AN", rol: "Apoyo", partidas: 95, victorias: 57 },
{
nombre: "Zenyatta",
siglas: "ZE",
rol: "Apoyo",
partidas: 80,
victorias: 44,
},
{ nombre: "Pharah", siglas: "PH", rol: "Daño", partidas: 130, victorias: 71 },
];
// ── Estado de la interfaz ─────────────────────────────────────────────────
// el rol del botón que está marcado como activo
let rolActivo = "todos";
// el texto que hay en el input en este momento
let textoBusqueda = "";
// ── Botones de filtro ─────────────────────────────────────────────────────
// los cuatro filtros posibles
const roles = ["todos", "Tanque", "Daño", "Apoyo"];
// recorremos los roles con un bucle
for (let i = 0; i < roles.length; i++) {
// nombre del rol en esta vuelta
const rol = roles[i];
// "todos" se muestra como "Todos"
const etiqueta = rol === "todos" ? "Todos" : rol;
// innerHTML crea el botón como string HTML; es rápido pero mezcla JS y marcado
navFiltros.innerHTML += `<button class="filtro${rol === "todos" ? " activo" : ""}" data-rol="${rol}">${etiqueta}</button>`;
}
// ── Función de render ─────────────────────────────────────────────────────
// recibe el array ya filtrado
function pintarHeroes(lista) {
// borra las tarjetas anteriores
grid.innerHTML = "";
// si no hay resultados...
if (lista.length === 0) {
// mostramos el aviso de vacío
parrafoVacio.hidden = false;
// y salimos: no hay nada más que pintar
return;
}
// si sí hay resultados, ocultamos el aviso
parrafoVacio.hidden = true;
// recorremos el array de héroes a pintar
for (let i = 0; i < lista.length; i++) {
// héroe en esta vuelta
const h = lista[i];
// calculamos el % (0.65 → 65)
const winrate = Math.round((h.victorias / h.partidas) * 100);
// Construimos la tarjeta como un string HTML con template literal.
// Ojo: si h.nombre contuviera "<script>", esto sería un problema de seguridad.
const html = `
<li>
<article class="hero-card">
<div class="hero-portrait" aria-hidden="true">${h.siglas}</div>
<h3 class="hero-name">${h.nombre}</h3>
<p class="hero-role">${h.rol}</p>
<dl class="hero-stats">
<div class="stat"><dt>Partidas</dt><dd>${h.partidas}</dd></div>
<div class="stat"><dt>Victorias</dt><dd>${h.victorias}</dd></div>
<div class="stat"><dt>Winrate</dt><dd>${winrate}%</dd></div>
</dl>
</article>
</li>
`;
// añadimos la tarjeta al grid
grid.innerHTML += html;
}
// Añadimos el listener de selección DESPUÉS de pintar, porque innerHTML renueva los nodos
// cogemos todas las tarjetas recién pintadas
const tarjetas = grid.querySelectorAll(".hero-card");
// un listener por tarjeta
for (let j = 0; j < tarjetas.length; j++) {
// al hacer clic en esta tarjeta...
tarjetas[j].addEventListener("click", function () {
// alterna la clase seleccionado
this.classList.toggle("seleccionado");
});
}
}
// ── Función que aplica filtros ────────────────────────────────────────────
// combina rol activo y texto de búsqueda
function aplicarFiltros() {
// empezamos con todos los héroes
let resultado = heroes;
// si el filtro no es "Todos"...
if (rolActivo !== "todos") {
// filter devuelve solo los del rol activo
resultado = resultado.filter(function (h) {
return h.rol === rolActivo;
});
}
// si hay texto en el buscador...
if (textoBusqueda !== "") {
// pasamos a minúsculas para comparar sin distinción
const texto = textoBusqueda.toLowerCase();
// filter con los que incluyen el texto
resultado = resultado.filter(function (h) {
return h.nombre.toLowerCase().includes(texto);
});
}
// pintamos el resultado filtrado
pintarHeroes(resultado);
}
// ── Eventos de filtro ─────────────────────────────────────────────────────
// escuchamos todos los clics en el nav
navFiltros.addEventListener("click", function (e) {
// e.target es el elemento pulsado
const boton = e.target;
// ignoramos clics fuera de los botones
if (!boton.classList.contains("filtro")) return;
// Marcamos el botón pulsado como activo y quitamos la clase a los demás
// todos los botones del nav
const botones = navFiltros.querySelectorAll(".filtro");
// recorremos para quitar la clase activo
for (let k = 0; k < botones.length; k++) {
botones[k].classList.remove("activo");
}
// ponemos activo solo en el pulsado
boton.classList.add("activo");
// guardamos el rol seleccionado
rolActivo = boton.dataset.rol;
// y repintamos con los filtros combinados
aplicarFiltros();
});
// ── Evento de búsqueda ────────────────────────────────────────────────────
// se dispara en cada tecla
inputBusqueda.addEventListener("input", function () {
// guardamos el texto actual del input
textoBusqueda = this.value;
// y repintamos combinando rol + texto
aplicarFiltros();
});
// ── Arranque ──────────────────────────────────────────────────────────────
// pintamos todos los héroes al cargar la página
pintarHeroes(heroes); Por qué este nivel
- Funciona: las cartas se pintan desde el array, los filtros responden y la búsqueda filtra en tiempo real.
- Construye las tarjetas con innerHTML y template literals: es rápido de escribir, pero si los datos provinieran del usuario, un valor con < o > podría interpretarse como HTML.
- El winrate se recalcula en cada render en vez de una sola vez al enriquecer los datos.
- Un listener por tarjeta (bucle for) en lugar de delegación: si hay muchas cartas, hay muchos handlers en memoria, y las tarjetas pintadas tras un filtro no tienen listener hasta que se repinta.
- La lógica de filtrado, render y eventos está mezclada: funciona, pero cuesta seguirla y cambiarla.
// SOLUCIÓN MEJOR — funciones con responsabilidad única, createElement + textContent,
// addEventListener en lugar de onclick, delegación de eventos.
// El HTML/CSS del Nivel 1 sigue intacto y aguanta perfectamente a 375px.
//
// Qué tiene de mejorable todavía (lo resuelve "excelente"):
// - El winrate se calcula en crearTarjeta en lugar de enriquecer los datos una sola vez.
// - Se vuelca el innerHTML del grid completo en cada render (aunque se construye nodo a nodo).
// - No usa DocumentFragment: los nodos se insertan uno a uno al DOM activo,
// lo que provoca varios reflows en lugar de uno solo.
// - No hay aria-live ni gestión de foco al cambiar contenido dinámico.
// ── Referencias al DOM ────────────────────────────────────────────────────
// el <ul> donde van las tarjetas
const grid = document.getElementById("hero-grid");
// el <nav> de botones de rol
const navFiltros = document.getElementById("filtros");
// el <input type="search">
const inputBusqueda = document.getElementById("busqueda");
// aviso "sin resultados"
const parrafoVacio = document.getElementById("resultado-vacio");
// ── Dataset de héroes ─────────────────────────────────────────────────────
const heroes = [
{ nombre: "Tracer", siglas: "TR", rol: "Daño", partidas: 120, victorias: 78 },
{
nombre: "Reinhardt",
siglas: "RE",
rol: "Tanque",
partidas: 90,
victorias: 51,
},
{
nombre: "Mercy",
siglas: "ME",
rol: "Apoyo",
partidas: 200,
victorias: 130,
},
{ nombre: "Genji", siglas: "GE", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "D.Va", siglas: "DV", rol: "Tanque", partidas: 140, victorias: 84 },
{ nombre: "Ana", siglas: "AN", rol: "Apoyo", partidas: 95, victorias: 57 },
{
nombre: "Zenyatta",
siglas: "ZE",
rol: "Apoyo",
partidas: 80,
victorias: 44,
},
{ nombre: "Pharah", siglas: "PH", rol: "Daño", partidas: 130, victorias: 71 },
];
// ── Estado de la interfaz ─────────────────────────────────────────────────
// rol del botón activo en este momento
let rolActivo = "todos";
// texto actual del input de búsqueda
let textoBusqueda = "";
// ── Función: crear una tarjeta DOM a partir de un héroe ───────────────────
// recibe un objeto héroe
function crearTarjeta(heroe) {
// % redondeado
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
// Creamos el árbol de nodos con createElement: los datos nunca se interpretan como HTML
// el <li> que va dentro del <ul>
const li = document.createElement("li");
// el contenedor de la tarjeta
const article = document.createElement("article");
// asignamos la clase CSS
article.className = "hero-card";
// el círculo con las siglas
const retrato = document.createElement("div");
retrato.className = "hero-portrait";
// decorativo: lo ocultamos a lectores de pantalla
retrato.setAttribute("aria-hidden", "true");
// textContent: solo texto, nunca HTML
retrato.textContent = heroe.siglas;
// el nombre del héroe
const nombre = document.createElement("h3");
nombre.className = "hero-name";
// textContent es seguro con cualquier string
nombre.textContent = heroe.nombre;
// el rol (Tanque, Daño, Apoyo)
const rol = document.createElement("p");
rol.className = "hero-role";
rol.textContent = heroe.rol;
// lista de definiciones para las estadísticas
const stats = document.createElement("dl");
stats.className = "hero-stats";
// Función auxiliar para construir un par dt/dd reutilizable
function crearStat(etiqueta, valor) {
// wrapper del par etiqueta-valor
const div = document.createElement("div");
div.className = "stat";
// término (label)
const dt = document.createElement("dt");
dt.textContent = etiqueta;
// dato (value)
const dd = document.createElement("dd");
dd.textContent = valor;
// append acepta varios nodos a la vez
div.append(dt, dd);
return div;
}
stats.append(
// añadimos los tres stats
crearStat("Partidas", heroe.partidas),
crearStat("Victorias", heroe.victorias),
// winrate formateado
crearStat("Winrate", winrate + "%"),
);
// montamos el article
article.append(retrato, nombre, rol, stats);
// metemos el article en el li
li.append(article);
// devolvemos el nodo listo para insertar
return li;
}
// ── Función: pintar la lista de héroes ────────────────────────────────────
// recibe el array ya filtrado
function renderHeroes(lista) {
// limpiamos el grid
grid.innerHTML = "";
// sin resultados...
if (lista.length === 0) {
// mostramos el aviso
parrafoVacio.hidden = false;
// salimos
return;
}
// con resultados, ocultamos el aviso
parrafoVacio.hidden = true;
// forEach es más limpio que for manual
lista.forEach(function (heroe) {
// añadimos cada tarjeta al grid
grid.append(crearTarjeta(heroe));
});
}
// ── Función: aplicar filtros combinados ───────────────────────────────────
// combina rol y texto de búsqueda
function aplicarFiltros() {
// partimos del array completo
let resultado = heroes;
// si el filtro no es "Todos"...
if (rolActivo !== "todos") {
// filter devuelve solo los del rol activo
resultado = resultado.filter(function (h) {
return h.rol === rolActivo;
});
}
// si hay texto en el buscador...
if (textoBusqueda !== "") {
// normalizamos para buscar sin distinción
const texto = textoBusqueda.toLowerCase();
resultado = resultado.filter(function (h) {
// includes comprueba si el texto está dentro
return h.nombre.toLowerCase().includes(texto);
});
}
// pintamos el resultado
renderHeroes(resultado);
}
// ── Inicializar botones de filtro ─────────────────────────────────────────
// crea y monta los botones
function iniciarFiltros() {
// los cuatro filtros
const roles = ["todos", "Tanque", "Daño", "Apoyo"];
// uno por uno
roles.forEach(function (rol) {
// creamos el botón con createElement
const btn = document.createElement("button");
// clase CSS del botón de filtro
btn.className = "filtro";
// texto visible del botón
btn.textContent = rol === "todos" ? "Todos" : rol;
// guardamos el valor del filtro en data-rol
btn.dataset.rol = rol;
// el botón "Todos" arranca marcado
if (rol === "todos") btn.classList.add("activo");
// lo añadimos al nav
navFiltros.append(btn);
});
// Delegación: un solo listener en el nav para todos los botones
// escuchamos el nav, no cada botón
navFiltros.addEventListener("click", function (e) {
// closest sube hasta el .filtro más cercano
const boton = e.target.closest(".filtro");
// si el clic fue en el hueco del nav, ignoramos
if (!boton) return;
// recorremos todos los botones
navFiltros.querySelectorAll(".filtro").forEach(function (b) {
// añade "activo" si es el pulsado, quita si no
b.classList.toggle("activo", b === boton);
});
// actualizamos el rol activo
rolActivo = boton.dataset.rol;
// repintamos
aplicarFiltros();
});
}
// ── Delegación para seleccionar tarjetas ──────────────────────────────────
// un solo listener en el grid
grid.addEventListener("click", function (e) {
// subimos desde e.target hasta .hero-card
const tarjeta = e.target.closest(".hero-card");
// clic fuera de una tarjeta: ignoramos
if (!tarjeta) return;
// alternamos la clase seleccionado
tarjeta.classList.toggle("seleccionado");
});
// ── Evento de búsqueda ────────────────────────────────────────────────────
// se dispara en cada tecla
inputBusqueda.addEventListener("input", function () {
// actualizamos el texto guardado
textoBusqueda = this.value;
// y repintamos
aplicarFiltros();
});
// ── Arranque ──────────────────────────────────────────────────────────────
// monta los botones de filtro en el DOM
iniciarFiltros();
// pinta todos los héroes al cargar la página
renderHeroes(heroes); Por qué es mejor que el anterior
- Las tarjetas se crean con createElement y textContent: los datos nunca se convierten en HTML, aunque contengan caracteres especiales.
- addEventListener en lugar de onclick: el comportamiento queda separado del marcado y se pueden añadir más listeners sin pisarse.
- Delegación en el nav de filtros: un solo listener comprueba event.target.closest(".filtro") en lugar de adjuntar uno por botón.
- Delegación en el grid: un solo listener en el <ul> gestiona la selección de cualquier tarjeta, incluso las que se pinten después de un filtro.
- Tres funciones con responsabilidades claras (crearTarjeta, renderHeroes, iniciarFiltros): cada una hace una sola cosa.
// SOLUCIÓN EXCELENTE — arquitectura limpia: datos enriquecidos una sola vez,
// funciones puras para filtrar, DocumentFragment para un único reflow,
// textContent en todos los datos, delegación de eventos y aria-live para
// que los lectores de pantalla anuncien el estado vacío.
// El CSS del Nivel 1 sigue intacto (incluyendo prefers-reduced-motion para
// transiciones y transforms) y aguanta impecable a 375px.
// ── Referencias al DOM ────────────────────────────────────────────────────
// el <ul> donde se pintan las tarjetas
const grid = document.getElementById("hero-grid");
// el <nav> con los botones de rol
const navFiltros = document.getElementById("filtros");
// el <input type="search">
const inputBusqueda = document.getElementById("busqueda");
// aviso "sin resultados"
const parrafoVacio = document.getElementById("resultado-vacio");
// aria-live="polite" hace que los lectores de pantalla anuncien el cambio en esta
// región cuando el contenido aparezca o desaparezca, sin interrumpir lo que leen.
parrafoVacio.setAttribute("aria-live", "polite");
// ── Dataset base ───────────────────────────────────────────────────────────
// Los datos en bruto, tal como vendrían de una API (sin winrate calculado).
const heroesBase = [
{ nombre: "Tracer", siglas: "TR", rol: "Daño", partidas: 120, victorias: 78 },
{
nombre: "Reinhardt",
siglas: "RE",
rol: "Tanque",
partidas: 90,
victorias: 51,
},
{
nombre: "Mercy",
siglas: "ME",
rol: "Apoyo",
partidas: 200,
victorias: 130,
},
{ nombre: "Genji", siglas: "GE", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "D.Va", siglas: "DV", rol: "Tanque", partidas: 140, victorias: 84 },
{ nombre: "Ana", siglas: "AN", rol: "Apoyo", partidas: 95, victorias: 57 },
{
nombre: "Zenyatta",
siglas: "ZE",
rol: "Apoyo",
partidas: 80,
victorias: 44,
},
{ nombre: "Pharah", siglas: "PH", rol: "Daño", partidas: 130, victorias: 71 },
];
// ── Enriquecer datos una sola vez ─────────────────────────────────────────
// map crea un array NUEVO sin mutar heroesBase: principio de inmutabilidad.
// El winrate se calcula aquí y queda en cada objeto; el resto del código solo lo LEE.
// Si tuviéramos 500 héroes, calcularlo en cada render o comparación sería un coste innecesario.
const heroes = heroesBase.map(function (h) {
// Object.assign({}, h, {...}) crea un objeto NUEVO copiando h en {}
// y luego sobreescribe (o añade) las propiedades del tercer argumento.
// Nunca toca heroesBase: el original queda intacto (inmutabilidad).
return Object.assign({}, h, {
// winrate redondeado al entero más próximo; se calcula solo una vez aquí
winrate: Math.round((h.victorias / h.partidas) * 100),
});
});
// ── Estado de la interfaz ─────────────────────────────────────────────────
// Mantenemos el estado en variables con nombres claros.
// En Nivel 6 (React) este mismo patrón se convierte en useState.
// el rol cuyo botón está marcado como activo
let rolActivo = "todos";
// el texto actual del input de búsqueda
let textoBusqueda = "";
// ── Función pura: filtrar héroes ──────────────────────────────────────────
// "Pura" = solo depende de sus argumentos, no modifica nada fuera de ella.
// Recibe el array completo y devuelve una copia filtrada sin tocarlo.
// tres parámetros: datos, filtro de rol, texto
function filtrarHeroes(lista, rol, texto) {
// filter devuelve un array nuevo
return lista.filter(function (h) {
// todos los roles, o el rol exacto
const coincideRol = rol === "todos" || h.rol === rol;
// sin distinguir mayúsculas
const coincideTexto = h.nombre.toLowerCase().includes(texto.toLowerCase());
// debe cumplir AMBAS condiciones
return coincideRol && coincideTexto;
});
}
// ── Función: crear una stat (par etiqueta-valor) ──────────────────────────
// función auxiliar reutilizable
function crearStat(etiqueta, valor) {
// wrapper del par dt/dd
const div = document.createElement("div");
div.className = "stat";
// término (etiqueta)
const dt = document.createElement("dt");
// textContent: texto plano, nunca HTML
dt.textContent = etiqueta;
// dato (valor)
const dd = document.createElement("dd");
// textContent también aquí: seguro ante XSS
dd.textContent = valor;
// añadimos dt y dd al wrapper
div.append(dt, dd);
// devolvemos el nodo para quien lo use
return div;
}
// ── Función: construir una tarjeta DOM ────────────────────────────────────
// Recibe un héroe ya enriquecido (con winrate) y devuelve el <li> listo.
// Usa solo textContent para los datos: ningún valor del dataset puede convertirse
// en HTML aunque contenga caracteres como <, > o &.
function crearTarjeta(heroe) {
// el <li> del grid
const li = document.createElement("li");
// la tarjeta en sí
const article = document.createElement("article");
article.className = "hero-card";
// círculo con las siglas del héroe
const retrato = document.createElement("div");
retrato.className = "hero-portrait";
// decorativo: oculto a lectores de pantalla
retrato.setAttribute("aria-hidden", "true");
// siglas del héroe (texto, no HTML)
retrato.textContent = heroe.siglas;
// nombre del héroe
const nombre = document.createElement("h3");
nombre.className = "hero-name";
// textContent es inviolable por el usuario
nombre.textContent = heroe.nombre;
// rol
const rol = document.createElement("p");
rol.className = "hero-role";
rol.textContent = heroe.rol;
// lista de definiciones para las estadísticas
const stats = document.createElement("dl");
stats.className = "hero-stats";
stats.append(
// leemos heroe.winrate ya calculado
crearStat("Partidas", heroe.partidas),
crearStat("Victorias", heroe.victorias),
// no recalculamos: lo calculamos al enriquecer
crearStat("Winrate", heroe.winrate + "%"),
);
// montamos el article con todos sus hijos
article.append(retrato, nombre, rol, stats);
// lo metemos en el li
li.append(article);
// devolvemos el nodo completo
return li;
}
// ── Función: pintar la lista de héroes ────────────────────────────────────
// DocumentFragment: construimos todos los nodos FUERA del DOM activo y los
// insertamos de golpe. Así el navegador solo recalcula el layout una vez
// en lugar de hacerlo por cada appendChild individual (reflow único).
function renderHeroes(lista) {
// vaciamos el grid
grid.innerHTML = "";
// sin resultados: mostramos el aviso
if (lista.length === 0) {
parrafoVacio.hidden = false;
return;
}
// con resultados: ocultamos el aviso
parrafoVacio.hidden = true;
// contenedor ligero, fuera del DOM activo
const fragmento = document.createDocumentFragment();
lista.forEach(function (heroe) {
// añadimos cada tarjeta al fragmento
fragmento.append(crearTarjeta(heroe));
});
// UN solo reflow: insertamos todo de una vez
grid.append(fragmento);
}
// ── Inicializar botones de filtro ─────────────────────────────────────────
function iniciarFiltros() {
// los cuatro valores posibles
const roles = ["todos", "Tanque", "Daño", "Apoyo"];
roles.forEach(function (rol) {
// cada botón se crea con createElement
const btn = document.createElement("button");
btn.className = "filtro";
// texto visible
btn.textContent = rol === "todos" ? "Todos" : rol;
// dato interno para el evento
btn.dataset.rol = rol;
// "Todos" arranca marcado
if (rol === "todos") btn.classList.add("activo");
navFiltros.append(btn);
});
// Delegación: un solo listener en el <nav> para todos los botones, incluso futuros.
navFiltros.addEventListener("click", function (e) {
// closest sube hasta .filtro desde el elemento pulsado
const boton = e.target.closest(".filtro");
// clic en el hueco del nav: ignoramos
if (!boton) return;
navFiltros.querySelectorAll(".filtro").forEach(function (b) {
// segundo argumento: añade si true, quita si false
b.classList.toggle("activo", b === boton);
});
// actualizamos el rol activo
rolActivo = boton.dataset.rol;
aplicarFiltros();
});
}
// ── Aplicar filtros combinados ────────────────────────────────────────────
// Punto de coordinación: lee el estado actual y pide a las funciones puras
// que hagan su trabajo. Nada de lógica aquí, solo orquestar.
function aplicarFiltros() {
// función pura: no muta nada
const resultado = filtrarHeroes(heroes, rolActivo, textoBusqueda);
renderHeroes(resultado);
}
// ── Delegación para seleccionar tarjetas ──────────────────────────────────
// Un solo listener en el grid: funciona para las tarjetas que están ahora
// Y para las que se pinen después de un filtro, sin volver a adjuntarlo.
grid.addEventListener("click", function (e) {
// subimos desde e.target hasta .hero-card
const tarjeta = e.target.closest(".hero-card");
// clic en el hueco del grid: ignoramos
if (!tarjeta) return;
// alterna el estado seleccionado
tarjeta.classList.toggle("seleccionado");
});
// ── Evento de búsqueda ────────────────────────────────────────────────────
// "input" se dispara en cada tecla, corte y pega
inputBusqueda.addEventListener("input", function () {
// actualizamos el estado de búsqueda
textoBusqueda = this.value;
aplicarFiltros();
});
// ── Arranque ──────────────────────────────────────────────────────────────
// monta los botones de filtro en el nav
iniciarFiltros();
// pinta la plantilla completa al cargar
renderHeroes(heroes); Por qué es mejor que el anterior
- Los datos se enriquecen con map una sola vez al inicio: winrate calculado en heroesBase → heroes. El resto del código solo lee h.winrate. En una lista grande, recalcular en cada render lastra el rendimiento.
- filtrarHeroes es una función pura: recibe (lista, rol, texto) y devuelve un array nuevo sin tocar el original. Es predecible, fácil de probar y de reutilizar.
- DocumentFragment: todos los nodos se construyen fuera del DOM activo y se insertan en una sola operación, provocando un solo reflow en lugar de uno por tarjeta.
- textContent en todos los datos: ningún valor del dataset puede convertirse en etiquetas HTML. El hábito protege cuando los datos vienen de una API externa.
- Delegación en todos los listeners interactivos: un handler en el nav de filtros y otro en el grid cubren todos los elementos presentes y futuros.
- aria-live="polite" en el aviso de estado vacío: los lectores de pantalla anuncian el cambio cuando aparece o desaparece, sin interrumpir al usuario. El CSS ya respeta prefers-reduced-motion para transiciones y transforms.