Cargando...
Cargando...
El grafismo de alineación previo al partido es un clásico de las retransmisiones deportivas — de la Premier League al Mundial. Una cuadrícula de tarjetas de jugadores aparece una a una, con dorsal, nombre y posición, y el sistema de juego y el entrenador en el pie.
Diseño en cuadrícula
Usa CSS Grid con columnas auto-fill de al menos 120px de ancho. Las tarjetas se recolocan solas tanto si tienes 11 jugadores como 5 suplentes.
Tarjetas escalonadas
Cada tarjeta de jugador recibe un transition-delay en línea de 300 ms + 60 ms por tarjeta, lo que crea una ola de tarjetas que aparecen por la cuadrícula.
Estructura en tres partes
Cabecera con degradado oscuro, nombre del equipo y datos del partido; después la cuadrícula de jugadores; y un pie con el sistema de juego y el entrenador — tres zonas visuales bien diferenciadas.
_renderPlayers construye una .lineup-card por jugador: el dorsal en un círculo oscuro, el nombre y la posición debajo. Cada valor pasa por escapeHtml antes de llegar al marcado, y el transition-delay de cada tarjeta se calcula a partir de su índice. load y updateAction llaman a _applyData, que solo toca los campos que no son undefined — así, una actualización parcial cambia solo lo que envía, una cadena vacía deja un campo en blanco (en el sistema de juego y el entrenador desaparece también la etiqueta "Formation:" / "Coach:") y un array players vuelve a renderizar la cuadrícula.
_renderPlayers(players) {
this._grid.innerHTML = players.map((p, i) => `
<div class="lineup-card" style="transition-delay: ${300 + i * 60}ms">
<div class="lineup-number">${escapeHtml(p.number)}</div>
<div class="lineup-player-name">${escapeHtml(p.name)}</div>
<div class="lineup-position">${escapeHtml(p.position)}</div>
</div>
`).join('');
}
_applyData(data) {
if (!data) return;
if (data.team !== undefined) this._team.textContent = data.team;
if (data.meta !== undefined) this._meta.textContent = data.meta;
// An emptied field clears the whole line, label included.
if (data.formation !== undefined) this._formation.textContent = data.formation ? 'Formation: ' + data.formation : '';
if (data.coach !== undefined) this._coach.textContent = data.coach ? 'Coach: ' + data.coach : '';
if (Array.isArray(data.players)) this._renderPlayers(data.players);
}resolveTargetStep implementa el modelo de pasos de OGraf: goto si se indica; si no, el paso actual (-1 antes del primer play) más delta, 1 por defecto. Con un solo paso, el primer play cae en el paso 0 y un segundo play va más allá del final, así que el grafismo se detiene y devuelve currentStep: undefined. En el paso 0, playAction añade visible y espera hasta que termina la última tarjeta: 300 ms + 60 ms por tarjeta + 500 ms de la transición de la propia tarjeta. stopAction se desvanece en 400 ms y solo quita las clases si no ha empezado entretanto ninguna acción más reciente (un this._rev mayor).
/**
* The step a playAction() lands on, exactly as the spec defines it: `goto`
* wins; otherwise the current step (-1 before the first play) plus `delta`,
* which defaults to 1. A target at or past stepCount means "go to the end",
* returned as undefined — so a second play on a one-step graphic takes it off
* air instead of replaying it.
*/
function resolveTargetStep(currentStep, { goto, delta } = {}, stepCount = 1) {
const target = Number.isInteger(goto) && goto >= 0
? goto
: (currentStep ?? -1) + (Number.isInteger(delta) ? delta : 1);
return target >= stepCount ? undefined : Math.max(target, 0);
}
// Each action takes the next revision number. Anything that finishes after a
// newer action has started checks it and backs off, so play → stop → play
// sent without waiting ends on air instead of hidden by the stale stop.
async playAction({ goto, delta, skipAnimation } = {}) {
this._initDom();
const target = resolveTargetStep(this._step, { goto, delta });
if (target === undefined) {
await this.stopAction({ skipAnimation });
return { statusCode: 200, currentStep: undefined };
}
++this._rev;
this._step = target;
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('instant', 'visible');
return { statusCode: 200, currentStep: this._step };
}
this._root.classList.remove('instant');
// Force a reflow so the browser registers the starting state.
void this._root.offsetWidth;
this._root.classList.add('visible');
await sleep(300 + this._grid.querySelectorAll('.lineup-card').length * 60 + 500);
return { statusCode: 200, currentStep: this._step };
}
async stopAction({ skipAnimation } = {}) {
this._initDom();
const rev = ++this._rev;
this._step = undefined;
if (skipAnimation) {
this._root.classList.add('instant');
this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}
this._root.classList.remove('instant');
this._root.classList.add('out');
await sleep(400);
if (rev === this._rev) this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}La cuadrícula de jugadores usa grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) para adaptarse al ancho del contenedor; un gap de 2px sobre un fondo gris claro dibuja las separaciones entre tarjetas. Cada tarjeta aparece con un fundido y sube 12px cuando el elemento padre recibe la clase visible. El reset está acotado con :where(.sport-lineup-root, …), así que nunca cambia los estilos de la página del renderizador.
/* Reset scoped to the graphic. A bare `*` rule in light DOM would also restyle
* the renderer's page and every other graphic on it; :where() keeps the
* specificity at zero, same as the bare selector it replaces. */
:where(.sport-lineup-root, .sport-lineup-root *),
:where(.sport-lineup-root, .sport-lineup-root *)::before,
:where(.sport-lineup-root, .sport-lineup-root *)::after { margin: 0; padding: 0; box-sizing: border-box; }
.lineup.visible {
opacity: 1;
transition: opacity 0.6s ease;
}
.lineup.visible .lineup-card {
opacity: 1;
transform: translateY(0);
}
.lineup.out {
opacity: 0;
transition: opacity 0.4s ease;
}
.lineup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
background: linear-gradient(135deg, #0f172a, #1e293b);
}
.lineup-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 2px;
padding: 2px;
background: #e2e8f0;
}
.lineup-card {
background: white;
padding: 16px 12px;
text-align: center;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.lineup-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #0f172a;
color: white;
font-size: 14px;
font-weight: 800;
margin-bottom: 8px;
}Consejo de diseño
Usa auto-fill en lugar de un número fijo de columnas. Así la cuadrícula se adapta sin problemas a 11 titulares, 5 suplentes o cualquier otro tamaño de plantilla, sin cambiar el diseño en el código de la plantilla.
El campo players es un array tipado — items.type es object, con number, name y position obligatorios. Un controlador puede añadir, quitar y reordenar filas automáticamente.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.sport-lineup",
"version": "1.0.0",
"name": "Sport Lineup",
"description": "Team roster grid with numbered player cards and staggered reveal. Tutorial from ograf.dev.",
"author": {
"name": "ograf.dev",
"url": "https://ograf.dev"
},
"main": "graphic.mjs",
"stepCount": 1,
"supportsRealTime": true,
"supportsNonRealTime": false,
"thumbnails": [
{
"file": "thumbnail.webp",
"resolution": {
"width": 1920,
"height": 1080
}
}
],
"schema": {
"type": "object",
"properties": {
"team": {
"type": "string",
"title": "Team",
"gddType": "single-line",
"default": "FC Barcelona"
},
"meta": {
"type": "string",
"title": "Match Info",
"gddType": "single-line",
"default": "La Liga — Matchday 32"
},
"formation": {
"type": "string",
"title": "Formation",
"gddType": "single-line",
"default": "4-3-3"
},
"coach": {
"type": "string",
"title": "Coach",
"gddType": "single-line",
"default": "H. Flick"
},
"players": {
"type": "array",
"title": "Players",
"items": {
"type": "object",
"properties": {
"number": {
"type": "integer",
"title": "Number",
"minimum": 0
},
"name": {
"type": "string",
"title": "Name",
"gddType": "single-line"
},
"position": {
"type": "string",
"title": "Position",
"gddType": "single-line"
}
},
"required": [
"number",
"name",
"position"
]
},
"default": [
{
"number": 1,
"name": "ter Stegen",
"position": "GK"
},
{
"number": 23,
"name": "Koundé",
"position": "RB"
},
{
"number": 4,
"name": "Araújo",
"position": "CB"
},
{
"number": 15,
"name": "Christensen",
"position": "CB"
},
{
"number": 28,
"name": "Baldé",
"position": "LB"
},
{
"number": 8,
"name": "Pedri",
"position": "CM"
},
{
"number": 16,
"name": "Fermín",
"position": "CM"
},
{
"number": 6,
"name": "Gavi",
"position": "CM"
},
{
"number": 11,
"name": "Yamal",
"position": "RW"
},
{
"number": 9,
"name": "Lewandowski",
"position": "ST"
},
{
"number": 19,
"name": "Raphinha",
"position": "LW"
}
]
}
}
}
}Un paquete OGraf Graphics Definition v1 real. Un renderizador compatible lee el manifiesto y gestiona el ciclo de vida. Con licencia MIT; úsalo en cualquier sistema compatible con OGraf.
sport-lineup.ograf.json
Manifiesto — lo que lee el renderizador (id, schema, flags del ciclo de vida)
graphic.mjs
Web Component con load / play / update / stop / customAction / dispose
style.css
Hoja de estilos, cargada por graphic.mjs mediante una etiqueta <link>
thumbnail.webp
Vista previa de 1920×1080, declarada en el manifiesto
README.md
Notas de uso
LICENSE
MIT
Despliégalo en un renderizador OGraf compatible
CSS Grid, tarjetas que aparecen escalonadas y una estructura limpia de cabecera y pie — lista para las retransmisiones de día de partido.

Rótulo inferior
Nombre y cargo sobreimpresos

Mosca / EN DIRECTO
Indicador de esquina con pulso

Ticker de noticias
Titulares en desplazamiento

Cita a pantalla completa
Tipografía cinematográfica a pantalla completa

Barras electorales
Gráfico de porcentajes animado

Marcador
Marcador de partido en directo

Cuenta atrás
Reloj que avanza solo

Última hora
Alerta urgente a pantalla completa

Previsión meteorológica
Condiciones actuales y previsión a 3 días

Tarjeta de redes sociales
Publicación sobreimpresa con avatar