Carregando...
Carregando...
Grafismos de previsão do tempo são presença garantida na TV — de boletins locais a previsões nacionais. Este cartão mostra as condições atuais com a temperatura em destaque, além de uma linha com a previsão para os próximos dias. Ele mostra como lidar com schemas de dados aninhados — campos escalares mais um array de objetos — num único grafismo.
Schema de dados aninhado
Quatro campos escalares (location, temp, condition, icon) ficam ao lado de um array de objetos de previsão, cada um com day, temp e icon. As temperaturas são strings simples, então a unidade vai junto com o valor.
Emoji como ícones
Os ícones são emojis Unicode digitados direto nos dados e escritos como texto. Nada de SVGs nem fontes de ícones — zero dependências, e o espectador os reconhece na hora.
Layout em várias seções
Duas zonas visuais: as condições atuais (ícone grande + temperatura) e, abaixo, uma linha compacta de previsão, cujos dias aparecem com fade depois que o cartão desliza para o lugar.
_renderForecast transforma o array de previsão em um .weather-forecast-day por item, mostrando o dia, o emoji e a temperatura. Todo valor passa por escapeHtml, e o transition-delay de cada dia é 500 ms + 60 ms por dia, criando uma revelação da esquerda para a direita. load e updateAction compartilham _applyData: os campos escalares são aplicados quando não são undefined (então uma string vazia limpa o campo), e um array de previsão renderiza a linha de novo.
_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 segue 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 coloca o cartão no ar no passo 0; um segundo play passa do fim, então o grafismo para e retorna currentStep: undefined. playAction adiciona visible e resolve depois de 1000 ms; stopAction adiciona out, espera 500 ms e só limpa as classes se nenhuma ação mais nova tiver incrementado this._rev — então play → stop → play enviados sem esperar terminam no ar.
/**
* 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 };
}O wrapper .weather começa fora da tela, à esquerda, transparente e desfocado. Adicionar visible faz ele deslizar para dentro em 0.7 s enquanto ganha nitidez; out o leva de volta em 0.5 s. Os dias da previsão sobem 8px e aparecem com fade quando o wrapper fica visível, cada um com seu próprio atraso. O reset tem escopo em :where(.weather-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(.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);
}Dica de design
Se as temperaturas forem atualizadas com o cartão no ar, considere adicionar font-variant-numeric: tabular-nums a .weather-temp (o template não define isso). Dígitos tabulares têm todos a mesma largura, então uma mudança de "8°C" para "9°C" não empurra o layout.
O campo forecast mistura propriedades escalares com um array tipado — é assim que o OGraf lida com dados de várias seções num ú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": "⛅"
}
]
}
}
}
}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.
weather.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
Dados aninhados, ícones em emoji, um cartão que desliza para dentro e uma revelação escalonada da previsão — pronto para qualquer boletim do tempo.

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

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

Card de rede social
Post sobreposto com avatar