Cargando...
Cargando...
Los grafismos del tiempo son un clásico de la televisión — desde los informativos locales hasta las previsiones nacionales. Esta tarjeta muestra el tiempo actual con la temperatura en grande, además de una fila con la previsión para varios días. Enseña a manejar schemas de datos anidados — campos escalares más un array de objetos — en un solo grafismo.
Schema de datos anidado
Cuatro campos escalares (location, temp, condition, icon) conviven con un array de objetos de previsión, cada uno con day, temp e icon. Las temperaturas son cadenas simples, así que la unidad viaja con el valor.
Emoji como iconos
Los iconos son emojis Unicode escritos directamente en los datos y pintados como texto. Sin SVG ni fuentes de iconos — cero dependencias, y el espectador los reconoce al instante.
Diseño en varias secciones
Dos zonas visuales: el tiempo actual (icono grande + temperatura) y, debajo, una fila compacta de previsión cuyos días aparecen con un fundido después de que la tarjeta se haya deslizado a su sitio.
_renderForecast convierte el array de previsión en un .weather-forecast-day por elemento, con el día, el emoji y la temperatura. Cada valor pasa por escapeHtml, y el transition-delay de cada día es de 500 ms + 60 ms por día, lo que crea una aparición de izquierda a derecha. load y updateAction comparten _applyData: los campos escalares se aplican cuando no son undefined (así que una cadena vacía borra uno), y un array de previsión vuelve a renderizar la fila.
_renderForecast(forecast) {
if (!Array.isArray(forecast)) return;
this._forecast.innerHTML = forecast.map((day, i) => `
<div class="weather-forecast-day" style="transition-delay: ${500 + i * 60}ms">
<div class="weather-forecast-label">${escapeHtml(day.day)}</div>
<div class="weather-forecast-icon">${escapeHtml(day.icon)}</div>
<div class="weather-forecast-temp">${escapeHtml(day.temp)}</div>
</div>
`).join('');
}
_applyData(data) {
if (!data) return;
if (data.location !== undefined) this._location.textContent = data.location;
if (data.temp !== undefined) this._temp.textContent = data.temp;
if (data.condition !== undefined) this._condition.textContent = data.condition;
if (data.icon !== undefined) this._icon.textContent = data.icon;
if (data.forecast) this._renderForecast(data.forecast);
}resolveTargetStep sigue 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 pone la tarjeta en antena en el paso 0; un segundo play va más allá del final, así que el grafismo se detiene y devuelve currentStep: undefined. playAction añade visible y se resuelve tras 1000 ms; stopAction añade out, espera 500 ms y solo quita las clases si ninguna acción más reciente ha incrementado this._rev — así que play → stop → play enviados sin esperar terminan en antena.
/**
* 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(1000);
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(500);
if (rev === this._rev) this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}El contenedor .weather empieza fuera de pantalla por la izquierda, transparente y desenfocado. Al añadir visible entra deslizándose en 0.7 s mientras se enfoca; out lo devuelve en 0.5 s. Los días de la previsión suben 8px y aparecen con un fundido cuando el contenedor es visible, cada uno con su propio retardo. El reset está acotado con :where(.weather-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(.weather-root, .weather-root *),
:where(.weather-root, .weather-root *)::before,
:where(.weather-root, .weather-root *)::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.weather {
position: absolute;
bottom: 60px;
left: 48px;
font-family: 'Inter', system-ui, sans-serif;
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
}
.weather.visible {
transform: translateX(0);
opacity: 1;
filter: blur(0);
transition:
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.5s ease,
filter 0.5s ease;
}
.weather.out {
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
transition:
transform 0.5s cubic-bezier(0.76, 0, 0.24, 1),
opacity 0.4s ease 0.1s,
filter 0.4s ease 0.1s;
}
.weather-forecast-day {
flex: 1;
text-align: center;
position: relative;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.weather.visible .weather-forecast-day {
opacity: 1;
transform: translateY(0);
}Consejo de diseño
Si las temperaturas se actualizan con la tarjeta en antena, plantéate añadir font-variant-numeric: tabular-nums a .weather-temp (la plantilla no lo define). Las cifras tabulares ocupan todas el mismo ancho, así que un cambio de "8°C" a "9°C" no desplaza el diseño.
El campo forecast mezcla propiedades escalares con un array tipado — así gestiona OGraf datos de varias secciones en un único schema.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.weather",
"version": "1.0.0",
"name": "Weather Forecast",
"description": "Weather card with current conditions and a 3-day forecast row. 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": {
"location": {
"type": "string",
"title": "Location",
"gddType": "single-line",
"default": "London"
},
"temp": {
"type": "string",
"title": "Temperature",
"gddType": "single-line",
"default": "18°C"
},
"condition": {
"type": "string",
"title": "Condition",
"gddType": "single-line",
"default": "Partly Cloudy"
},
"icon": {
"type": "string",
"title": "Icon (emoji)",
"gddType": "single-line",
"default": "⛅"
},
"forecast": {
"type": "array",
"title": "Forecast",
"items": {
"type": "object",
"properties": {
"day": {
"type": "string",
"title": "Day",
"gddType": "single-line"
},
"temp": {
"type": "string",
"title": "Temp",
"gddType": "single-line"
},
"icon": {
"type": "string",
"title": "Icon",
"gddType": "single-line"
}
},
"required": [
"day",
"temp",
"icon"
]
},
"default": [
{
"day": "Tue",
"temp": "20°C",
"icon": "☀️"
},
{
"day": "Wed",
"temp": "16°C",
"icon": "🌧️"
},
{
"day": "Thu",
"temp": "19°C",
"icon": "⛅"
}
]
}
}
}
}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.
weather.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
Datos anidados, iconos emoji, una tarjeta que entra deslizándose y una aparición escalonada de la previsión — lista para cualquier sección del tiempo.

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

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

Tarjeta de redes sociales
Publicación sobreimpresa con avatar