Cargando...
Cargando...
Las barras electorales son la columna vertebral de la cobertura de una noche electoral — piensa en la noche electoral de la CNN o en la retransmisión de las elecciones generales de la BBC. Cada fila representa a un partido con una barra de su color que crece hasta su porcentaje de voto, lo que da una comparación de resultados contundente y que se entiende de un vistazo.
Anchos guiados por datos
Cada relleno lleva su porcentaje en data-pct, y _animateBars fija fill.style.width = pct + '%' en línea. Lo visual lo controlan los datos, no una clase CSS.
Aparición escalonada
Las filas reciben la clase show con 120 ms de diferencia, sumados a un transition-delay en línea de 100 ms por fila, así que entran en cascada de arriba abajo.
Identidad por color
El color de cada partido viene de los datos y se convierte en el fondo en línea del relleno — sin paleta fija en el código. Sirve para cualquier elección, cualquier país y cualquier sistema de partidos.
_renderBars construye una .election-row por partido: nombre y número de votos a la izquierda, una pista con un relleno de color a la derecha y una etiqueta de porcentaje que empieza en 0%. El nombre y el color pasan por escapeHtml, y pct y votes por Number(), para que los datos del operador no puedan inyectar marcado. El truque clave: todavía no crece nada. Cada relleno empieza en width: 0, y su valor objetivo espera en data-pct hasta que se ejecuta _animateBars.
_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 muestra las filas con 120 ms de diferencia; a los 200 ms escribe el ancho de cada relleno y lleva cada etiqueta al extremo de la barra; 150 ms después, la etiqueta cuenta desde 0 hacia arriba durante 900 ms con una curva ease-out. Un updateAction posterior que incluya parties vuelve a renderizar las filas; en antena ejecuta _animateBars otra vez para que las barras crezcan hasta los nuevos resultados, y fuera de antena las deja preparadas para el siguiente play. Cada temporizador dentro de _animateBars comprueba la revisión con la que empezó, así que un stop que llega a mitad de la aparición nunca se sobrescribe, y stopAction llama a _resetBars cuando el panel ya ha desaparecido para que al volver a dar play las barras crezcan de nuevo desde cero.
// 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 sigue el modelo de pasos de OGraf. resolveTargetStep elige el destino: goto si se indica; si no, el paso actual (-1 antes del primer play) más delta, que por defecto vale 1. Este grafismo tiene un paso, así que el primer play cae en el paso 0 y un segundo play va más allá del final — el grafismo ejecuta stopAction y devuelve currentStep: undefined. En el paso 0 añade visible para que el panel suba, espera 400 ms, arranca las barras y se resuelve 1400 ms después. Con skipAnimation añade instant a la raíz — una clase que desactiva todas las transiciones — y _showBarsInstantly fija de golpe los anchos y las etiquetas finales.
/**
* 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 qué el contador de revisiones?
Cada play y cada stop toman el siguiente this._rev. Si llega una acción más reciente durante la espera de 400 ms, playAction se salta _animateBars, y un stopAction obsoleto no oculta el panel — así que play → stop → play enviados sin esperar terminan en antena.
Las filas entran deslizándose 20px desde la izquierda cuando reciben show. El relleno hace la transición de width en 1.2 s, y la etiqueta de porcentaje hace la de left con la misma duración y curva, de modo que acompaña al extremo de la barra mientras crece. El reset del principio está acotado con :where(.election-bars-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(.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;
}Consejo de diseño
Usa etiquetas de porcentaje asimétricas. _renderBars le da a la etiqueta la clase inside cuando el partido tiene un 15% o más (texto blanco, metido dentro de la barra) y outside por debajo de eso (texto oscuro justo después del final de la barra). Las barras diminutas nunca tienen que albergar una etiqueta que no les cabe — un error habitual en los grafismos electorales.
Los recuentos de votos grandes cuestan de leer sin separadores. _renderBars escribe (Number(p.votes) || 0).toLocaleString(), que añade separadores de millares según la configuración regional del renderizador — 1.284.000 se lee mucho mejor que 1284000. votes es opcional en el schema, así que un valor ausente se muestra como 0. La etiqueta de porcentaje se formatea aparte: _countUp redondea cada fotograma de la animación a un número entero.
_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);
}El campo parties es un array tipado — items.type es object, con name, pct y color obligatorios. Así declara OGraf datos estructurados que se repiten.
{
"$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"
}
]
}
}
}
}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.
election-bars.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
Anchos guiados por datos, apariciones escalonadas, partidos identificados por color y un modelo de pasos conforme a la especificación — todo lo que necesitas para cubrir una noche electoral.

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

Alineación deportiva
Cuadrícula con la plantilla del equipo

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