Carregando...
Carregando...
O grafismo de escalação antes do jogo é presença garantida nas transmissões esportivas — da Premier League à Copa do Mundo. Uma grade de cartões de jogadores aparece um a um, mostrando número, nome e posição, com o esquema tático e o técnico no rodapé.
Layout em grade
Usa CSS Grid com colunas auto-fill de pelo menos 120px de largura. Os cartões se reorganizam naturalmente, seja com 11 titulares ou com 5 reservas.
Cartões escalonados
Cada cartão de jogador recebe um transition-delay inline de 300 ms + 60 ms por cartão, criando uma onda de cartões que aparecem pela grade.
Estrutura em três partes
Cabeçalho com gradiente escuro, nome do time e informações da partida; depois a grade de jogadores; e um rodapé com esquema tático e técnico — três zonas visuais bem distintas.
_renderPlayers monta um .lineup-card para cada jogador: o número num círculo escuro, o nome e a posição logo abaixo. Todo valor passa por escapeHtml antes de entrar no markup, e o transition-delay de cada cartão é calculado a partir do seu índice. load e updateAction chamam _applyData, que só mexe nos campos que não são undefined — assim, um update parcial muda apenas o que envia, uma string vazia apaga o campo (no esquema tático e no técnico, o rótulo "Formation:" / "Coach:" some junto) e um array players renderiza a grade de novo.
_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 o modelo de passos do OGraf: goto, se informado; senão, o passo atual (-1 antes do primeiro play) mais delta, 1 por padrão. Com um passo só, o primeiro play cai no passo 0 e um segundo play passa do fim, então o grafismo para e retorna currentStep: undefined. No passo 0, playAction adiciona visible e espera até o último cartão terminar: 300 ms + 60 ms por cartão + 500 ms da transição do próprio cartão. stopAction faz fade out em 400 ms e só limpa as classes se nenhuma ação mais nova (um this._rev maior) tiver começado nesse meio-tempo.
/**
* 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 };
}A grade de jogadores usa grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)) para se adaptar à largura do contêiner; um gap de 2px sobre um fundo cinza-claro desenha as divisórias entre os cartões. Cada cartão aparece com fade e sobe 12px quando o elemento pai recebe a classe visible. O reset tem escopo em :where(.sport-lineup-root, …), então nunca altera o estilo da página do 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;
}Dica de design
Use auto-fill em vez de um número fixo de colunas. Assim a grade lida bem com 11 titulares, 5 reservas ou qualquer outro tamanho de elenco, sem mudar o layout no código do template.
O campo players é um array tipado — items.type é object, com number, name e position obrigatórios. Um controlador pode adicionar, remover e reordenar as linhas automaticamente.
{
"$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"
}
]
}
}
}
}Um pacote OGraf Graphics Definition v1 de verdade. Um renderizador compatível lê o manifesto e conduz o ciclo de vida. Licença MIT; coloque em qualquer sistema compatível com OGraf.
sport-lineup.ograf.json
Manifesto — o que o renderizador lê (id, schema, flags de ciclo de vida)
graphic.mjs
Web Component com load / play / update / stop / customAction / dispose
style.css
Folha de estilo, carregada pelo graphic.mjs com uma tag <link>
thumbnail.webp
Prévia em 1920×1080, declarada no manifesto
README.md
Instruções de uso
LICENSE
MIT
CSS Grid, cartões revelados de forma escalonada e uma estrutura limpa de cabeçalho e rodapé — pronto para as transmissões em dia de jogo.

Lower third
Nome e cargo sobre a imagem

Bug / AO VIVO
Indicador de canto com pulso

Ticker de notícias
Manchetes rolando na tela

Citação em tela cheia
Tipografia cinematográfica em tela cheia

Barras de eleição
Gráfico de porcentagens animado

Placar
Placar de partida ao vivo

Contagem regressiva
Relógio que avança sozinho

Plantão
Alerta urgente em tela cheia

Previsão do tempo
Condições atuais e previsão de 3 dias

Card de rede social
Post sobreposto com avatar