Carregando...
Carregando...
As barras de eleição são a espinha dorsal da cobertura da noite de apuração — pense na noite eleitoral da CNN ou na cobertura das eleições gerais da BBC. Cada linha representa um partido, com uma barra na cor dele que cresce até o percentual de votos, criando uma comparação visceral dos resultados, que se entende num relance.
Larguras guiadas por dados
Cada preenchimento guarda seu percentual em data-pct, e _animateBars define fill.style.width = pct + '%' inline. Quem controla o visual são os dados, não uma classe CSS.
Revelação escalonada
As linhas recebem a classe show com 120 ms de intervalo, somados a um transition-delay inline de 100 ms por linha, então elas entram em cascata de cima para baixo.
Identidade por cor
A color de cada partido vem dos dados e vira o fundo inline do preenchimento — nenhuma paleta fixa no código. Funciona para qualquer eleição, qualquer país, qualquer sistema partidário.
_renderBars monta uma .election-row por partido: nome e número de votos à esquerda, uma trilha com preenchimento colorido à direita e um rótulo de percentual que começa em 0%. Nome e cor passam por escapeHtml, e pct e votes por Number(), para que os dados do operador não consigam injetar markup. O truque principal: nada cresce ainda. Todo preenchimento começa em width: 0, e o valor-alvo espera em data-pct até _animateBars rodar.
_renderBars(parties) {
this._barsContainer.innerHTML = parties.map((p, i) => `
<div class="election-row" style="transition-delay: ${i * 100}ms">
<div class="election-party">
<div class="election-party-name">${escapeHtml(p.name)}</div>
<div class="election-party-votes">${(Number(p.votes) || 0).toLocaleString()} votes</div>
</div>
<div class="election-bar-wrapper">
<div class="election-bar-track">
<div class="election-bar-fill" style="background: ${escapeHtml(p.color)}" data-pct="${Number(p.pct)}"></div>
</div>
<div class="election-pct ${Number(p.pct) >= 15 ? 'inside' : 'outside'}" data-pct="${Number(p.pct)}">
<span class="election-pct-value">0%</span>
</div>
</div>
</div>
`).join('');
}_animateBars mostra as linhas com 120 ms de intervalo; depois de 200 ms, escreve a largura de cada preenchimento e leva cada rótulo até a ponta da barra; 150 ms depois, o rótulo conta de 0 para cima ao longo de 900 ms com uma curva ease-out. Um updateAction posterior que inclua parties renderiza as linhas de novo; no ar, roda _animateBars outra vez para as barras crescerem até os novos resultados, e fora do ar as deixa prontas para o próximo play. Cada timer dentro de _animateBars confere a revisão em que começou, então um stop que chega no meio da revelação nunca é sobrescrito, e stopAction chama _resetBars quando o painel some, para que um novo play faça as barras crescerem do zero outra vez.
// Grow the bars from zero. Every timer checks the revision it started under,
// so a stop (or a newer play) that lands mid-reveal is never overwritten.
_animateBars(rev) {
const rows = this._barsContainer.querySelectorAll('.election-row');
const fills = this._barsContainer.querySelectorAll('.election-bar-fill');
const pctLabels = this._barsContainer.querySelectorAll('.election-pct');
rows.forEach((row, i) => setTimeout(() => {
if (rev === this._rev) row.classList.add('show');
}, i * 120));
setTimeout(() => {
if (rev !== this._rev) return;
fills.forEach((fill, i) => {
const pct = Number(fill.dataset.pct);
fill.style.width = pct + '%';
const label = pctLabels[i];
if (label) {
label.style.left = pct + '%';
const valueEl = label.querySelector('.election-pct-value');
if (valueEl) setTimeout(() => {
if (rev === this._rev) this._countUp(valueEl, pct, 900);
}, 150);
}
});
}, 200);
}
// Back to the pre-play state, so the next play grows the bars again.
_resetBars() {
this._barsContainer.querySelectorAll('.election-row').forEach((row) => row.classList.remove('show'));
this._barsContainer.querySelectorAll('.election-bar-fill').forEach((fill) => { fill.style.width = ''; });
this._barsContainer.querySelectorAll('.election-pct').forEach((label) => {
label.style.left = '';
const value = label.querySelector('.election-pct-value');
if (value) value.textContent = '0%';
});
}playAction segue o modelo de passos do OGraf. resolveTargetStep escolhe o destino: goto, se informado; senão, o passo atual (-1 antes do primeiro play) mais delta, que por padrão é 1. Este grafismo tem um passo, então o primeiro play cai no passo 0 e um segundo play passa do fim — o grafismo roda stopAction e retorna currentStep: undefined. No passo 0, ele adiciona visible para o painel subir, espera 400 ms, inicia as barras e resolve 1400 ms depois. Com skipAnimation, adiciona instant à raiz — uma classe que desliga todas as transições — e _showBarsInstantly aplica de uma vez as larguras e os rótulos finais.
/**
* 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 };
}
const rev = ++this._rev;
this._step = target;
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('instant', 'visible');
this._showBarsInstantly();
} else {
this._root.classList.remove('instant');
void this._root.offsetWidth;
this._root.classList.add('visible');
await sleep(400);
if (rev !== this._rev) return { statusCode: 200, currentStep: this._step };
this._animateBars(rev);
await sleep(1400);
}
return { statusCode: 200, currentStep: this._step };
}
// skipAnimation: the end state at once. The root carries `.instant`, which
// turns every transition off, so widths and rows land without animating.
_showBarsInstantly() {
this._barsContainer.querySelectorAll('.election-row').forEach((row) => row.classList.add('show'));
this._barsContainer.querySelectorAll('.election-bar-fill').forEach((fill) => {
fill.style.width = fill.dataset.pct + '%';
});
this._barsContainer.querySelectorAll('.election-pct').forEach((label) => {
label.style.left = label.dataset.pct + '%';
const value = label.querySelector('.election-pct-value');
if (value) value.textContent = label.dataset.pct + '%';
});
}Por que o contador de revisão?
Cada play e stop pega o próximo this._rev. Se uma ação mais nova chegar durante a espera de 400 ms, playAction pula _animateBars, e um stopAction desatualizado não esconde o painel — então play → stop → play enviados sem esperar terminam no ar.
As linhas deslizam 20px da esquerda quando recebem show. O preenchimento faz a transição de width em 1.2 s, e o rótulo de percentual faz a transição de left com a mesma duração e curva, acompanhando a ponta da barra enquanto ela cresce. O reset no topo tem escopo em :where(.election-bars-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(.election-bars-root, .election-bars-root *),
:where(.election-bars-root, .election-bars-root *)::before,
:where(.election-bars-root, .election-bars-root *)::after { margin: 0; padding: 0; box-sizing: border-box; }
.election-row {
display: flex;
align-items: center;
gap: 16px;
opacity: 0;
transform: translateX(-20px);
}
.election-row.show {
opacity: 1;
transform: translateX(0);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.election-bar-fill {
height: 100%;
border-radius: 6px;
width: 0;
transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);
}
/* The percentage label — floats at the bar's edge */
.election-pct {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
pointer-events: none;
left: 0;
transition: left 1.2s cubic-bezier(0.16, 1, 0.3, 1);
font-variant-numeric: tabular-nums;
}
/* Inside the bar (white on color) */
.election-pct.inside {
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
justify-content: flex-end;
padding-right: 10px;
/* Label is positioned at left = barWidth, but needs to sit inside */
transform: translateX(-100%);
width: auto;
min-width: 44px;
}
/* Outside the bar (dark text to the right) */
.election-pct.outside {
color: #0f172a;
padding-left: 8px;
}Dica de design
Use rótulos de percentual assimétricos. _renderBars dá ao rótulo a classe inside quando o partido tem 15% ou mais (texto branco, recuado para dentro da barra) e outside abaixo disso (texto escuro logo depois do fim da barra). Barras minúsculas nunca precisam acomodar um rótulo que não cabe nelas — uma armadilha comum em grafismos de eleição.
Totais grandes de votos são difíceis de ler sem separadores. _renderBars escreve (Number(p.votes) || 0).toLocaleString(), que adiciona separadores de milhar conforme o locale do renderizador — 1.284.000 é muito mais legível que 1284000. votes é opcional no schema, então um valor ausente aparece como 0. O rótulo de percentual é formatado à parte: _countUp arredonda cada quadro da animação para um número inteiro.
_countUp(el, target, duration) {
const start = performance.now();
const update = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(eased * target) + '%';
if (progress < 1) requestAnimationFrame(update);
};
requestAnimationFrame(update);
}O campo parties é um array tipado — items.type é object, com name, pct e color obrigatórios. É assim que o OGraf declara dados estruturados que se repetem.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.election-bars",
"version": "1.0.0",
"name": "Election Bars",
"description": "Animated horizontal bar chart for election results with data-driven widths and color-coded parties. 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": {
"title": {
"type": "string",
"title": "Title",
"gddType": "single-line",
"default": "General Election 2026"
},
"subtitle": {
"type": "string",
"title": "Subtitle",
"gddType": "single-line",
"default": "78% reporting"
},
"parties": {
"type": "array",
"title": "Parties",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
"gddType": "single-line"
},
"pct": {
"type": "number",
"title": "Percentage",
"minimum": 0,
"maximum": 100
},
"votes": {
"type": "integer",
"title": "Votes",
"minimum": 0
},
"color": {
"type": "string",
"title": "Color",
"gddType": "color-rrggbb",
"pattern": "^#[0-9a-f]{6}quot;
}
},
"required": [
"name",
"pct",
"color"
]
},
"default": [
{
"name": "Progressives",
"pct": 42,
"votes": 1284000,
"color": "#2563eb"
},
{
"name": "Conservatives",
"pct": 35,
"votes": 1070000,
"color": "#dc2626"
},
{
"name": "Centrists",
"pct": 15,
"votes": 459000,
"color": "#f59e0b"
},
{
"name": "Greens",
"pct": 8,
"votes": 245000,
"color": "#16a34a"
}
]
}
}
}
}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.
election-bars.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
Larguras guiadas por dados, revelações escalonadas, partidos identificados por cor e um modelo de passos fiel à especificação — tudo o que você precisa para a cobertura da noite de apuração.

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

Escalação esportiva
Grade com o elenco do time

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