teqo-backup/scripts/lib/graficosInstagramRender.mjs

242 lines
9.7 KiB
JavaScript

/**
* Instagram chart renderer (C191): composes the approved hi-fi template
* (`docs/plans/graficos-dados-instagram-ui-design.html`) into a single canvas
* HTML document, class-by-class, with inline CSS (the pipeline has no bundler
* and the render must never need the network). One red highlight, honest scale,
* headline-manchete, source and brand inside the image.
*
* The page is authored at the exact export size; `buildPdf.screenshotHtmlPng`
* only sets the viewport and clips the canvas.
*/
import { RELATION_LABEL, SIZES } from './chartData.mjs'
import { lineChart, proportionalPercent } from './chartPrimitives.mjs'
import { htmlEscape } from './reportText.mjs'
/** Solla palette (verbatim from the intention). */
export const SOLLA_PALETTE = {
highlight: '#c51414',
brandDark: '#ae1603',
ptRed: '#a21c1c',
ptYellow: '#ffe607',
ink: '#1c1917',
hairline: '#e7e5e4',
paper: '#faf9f7',
bar: '#d6d3d1',
barStrong: '#a8a29e',
axis: '#78716c',
label: '#57534e',
}
const FONT_STACK =
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
const formatValue = (value) =>
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)
const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value)
const highlightLabel = (spec) => spec.highlight ?? sortedByValue(spec.rows)[0]?.label ?? null
const rankingBody = (spec) => {
const rows = sortedByValue(spec.rows)
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
return `<div class="rank">
${rows
.map((row) => {
const percent = proportionalPercent(row.value, max)
const isHighlight = row.label === winner
return `<div class="rank-row">
<span class="rank-label">${htmlEscape(row.label)}</span>
<div class="bar-track">
<div class="bar${isHighlight ? ' highlight' : ''}${percent === 0 ? ' zero' : ''}" style="width:${percent}%">${formatValue(row.value)}</div>
</div>
</div>`
})
.join('')}
</div>`
}
const columnBody = (spec) => {
const rows = spec.rows
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
return `<div class="columns" style="grid-template-columns:repeat(${rows.length}, 1fr)">
${rows
.map((row) => {
const isHighlight = row.label === winner
return `<div class="column-cell">
<span class="column-value">${formatValue(row.value)}</span>
<div class="column${isHighlight ? ' highlight' : ''}" style="height:${proportionalPercent(row.value, max)}%"></div>
<span class="column-label">${htmlEscape(row.label)}</span>
</div>`
})
.join('')}
</div>`
}
const anchorBody = (spec) => {
const row = spec.rows[0]
const copy = row.label || spec.subtitle || ''
return `<div class="anchor">
<div class="anchor-number">${formatValue(row.value)}</div>
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
</div>`
}
const plotBody = (spec) => {
if (spec.chartType === 'anchor') return anchorBody(spec)
if (spec.chartType === 'column') return columnBody(spec)
if (spec.chartType === 'line') {
return `<div class="line-plot">${lineChart({
rows: spec.rows,
width: 880,
height: 430,
format: (row) => formatValue(row.value),
stroke: SOLLA_PALETTE.axis,
highlight: SOLLA_PALETTE.highlight,
paper: SOLLA_PALETTE.paper,
ink: SOLLA_PALETTE.ink,
grid: SOLLA_PALETTE.barStrong,
})}</div>`
}
return rankingBody(spec)
}
const brandLockup = () => `<div class="brand">
<span class="brand-signal"></span>
<div>
<div class="brand-name">JORGE SOLLA</div>
<div class="brand-office">MANDATO DEPUTADO FEDERAL</div>
</div>
</div>`
const footer = (spec) => `<footer class="footer">
<p class="source"><strong>Fonte:</strong> ${htmlEscape(spec.source)}${spec.note ? `<br />Nota: ${htmlEscape(spec.note)}` : ''}</p>
${brandLockup()}
</footer>`
const CSS = `* { box-sizing: border-box; }
html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
.canvas {
position: relative;
overflow: hidden;
color: ${SOLLA_PALETTE.ink};
background: ${SOLLA_PALETTE.paper};
font-family: ${FONT_STACK};
-webkit-font-smoothing: antialiased;
}
.inner { position: absolute; inset: 0; display: flex; flex-direction: column; }
.top-rule { width: 64px; height: 10px; background: ${SOLLA_PALETTE.highlight}; }
.context { margin: 0; color: ${SOLLA_PALETTE.brandDark}; font-size: 30px; line-height: 1.2; font-weight: 750; }
.headline { margin: 0; font-weight: 800; line-height: 1.03; letter-spacing: -0.045em; }
.subtitle { margin: 0; color: ${SOLLA_PALETTE.label}; font-weight: 450; line-height: 1.35; }
.plot { flex: 1; min-height: 0; }
.rank { display: grid; align-content: start; }
.rank-row { display: grid; grid-template-columns: 250px 1fr; align-items: center; gap: 22px; }
.rank-label { font-size: 31px; line-height: 1.08; font-weight: 650; }
.bar-track { position: relative; height: 53px; border-left: 2px solid ${SOLLA_PALETTE.axis}; }
.bar {
display: flex; align-items: center; justify-content: flex-end; height: 100%;
min-width: 94px; padding-right: 16px; color: #292524; background: ${SOLLA_PALETTE.bar};
font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums;
}
.bar.highlight { color: #fff; background: ${SOLLA_PALETTE.highlight}; }
.bar.zero { min-width: 0; padding: 0 0 0 8px; justify-content: flex-start; }
.columns { display: grid; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
.column-cell { position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
.column-value { margin-bottom: 10px; text-align: center; font-size: 31px; font-weight: 800; font-variant-numeric: tabular-nums; }
.column { background: ${SOLLA_PALETTE.bar}; min-height: 6px; }
.column.highlight { background: ${SOLLA_PALETTE.highlight}; }
.column-label { position: absolute; bottom: -40px; left: 0; width: 100%; text-align: center; color: ${SOLLA_PALETTE.label}; font-size: 30px; font-weight: 650; }
.line-plot { height: 100%; display: flex; align-items: center; }
.line-plot svg { width: 100%; height: auto; }
.anchor { padding-top: 20px; }
.anchor-number { color: ${SOLLA_PALETTE.highlight}; font-size: 300px; line-height: 0.86; letter-spacing: -0.065em; font-weight: 900; font-variant-numeric: tabular-nums; }
.anchor-copy { max-width: 760px; margin: 40px 0 0; font-size: 52px; line-height: 1.12; font-weight: 750; }
.footer {
margin-top: auto; padding-top: 25px; border-top: 2px solid ${SOLLA_PALETTE.hairline};
display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px;
}
.source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; }
.source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; }
.brand { display: flex; align-items: center; gap: 13px; justify-content: flex-end; }
.brand-signal { width: 12px; height: 48px; background: ${SOLLA_PALETTE.ptYellow}; }
.brand-name { color: ${SOLLA_PALETTE.brandDark}; font-size: 29px; line-height: 0.88; font-weight: 900; letter-spacing: -0.045em; }
.brand-office { margin-top: 7px; color: ${SOLLA_PALETTE.label}; font-size: 17px; line-height: 1; font-weight: 750; letter-spacing: 0.04em; }`
/** Per-size vertical rhythm of the template (feed 4:5, square, stories 9:16). */
const layoutFor = (size) => {
if (size === 'story') {
const { safeTop, safeBottom } = SIZES.story
return {
// The content clears the Stories safe bands, plus the template's margin.
padding: `${safeTop + 56}px 84px ${safeBottom + 60}px`,
ruleGap: 34,
gapAfterContext: 18,
headline: 78,
subtitle: 32,
plotGap: 78,
}
}
if (size === 'square') {
return {
padding: '64px 84px 56px',
ruleGap: 28,
gapAfterContext: 16,
headline: 62,
subtitle: 30,
plotGap: 44,
}
}
return {
padding: '76px 84px 58px',
ruleGap: 34,
gapAfterContext: 18,
headline: 67,
subtitle: 31,
plotGap: 54,
}
}
/**
* Full HTML document of the chart, at the exact export size. `spec` must have
* passed `validateSpec` (the entry does it before rendering).
*/
export const renderChartHtml = (spec) => {
const sizeKey = SIZES[spec.size] ? spec.size : 'feed'
const canvas = SIZES[sizeKey]
const layout = layoutFor(sizeKey)
const kicker = RELATION_LABEL[spec.chartType] ?? 'Gráfico'
const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label
const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle
return `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<style>${CSS}</style>
<style>
.canvas { width: ${canvas.width}px; height: ${canvas.height}px; }
.inner { padding: ${layout.padding}; }
.top-rule { margin-bottom: ${layout.ruleGap}px; }
.context { margin-bottom: ${layout.gapAfterContext}px; }
.headline { font-size: ${layout.headline}px; max-width: ${canvas.width - 200}px; }
.subtitle { margin-top: ${Math.round(layout.gapAfterContext * 1.28)}px; font-size: ${layout.subtitle}px; max-width: ${canvas.width - 260}px; }
.plot { margin-top: ${layout.plotGap}px; }
</style>
</head>
<body>
<article class="canvas" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>
<h1 class="headline">${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle">${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="plot">${plotBody(spec)}</div>
${footer(spec)}
</div>
</article>
</body>
</html>`
}