/** * 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 `
${rows .map((row) => { const percent = proportionalPercent(row.value, max) const isHighlight = row.label === winner return `
${htmlEscape(row.label)}
${formatValue(row.value)}
` }) .join('')}
` } const columnBody = (spec) => { const rows = spec.rows const max = Math.max(...rows.map((row) => row.value)) const winner = highlightLabel(spec) return `
${rows .map((row) => { const isHighlight = row.label === winner return `
${formatValue(row.value)}
${htmlEscape(row.label)}
` }) .join('')}
` } const anchorBody = (spec) => { const row = spec.rows[0] const copy = row.label || spec.subtitle || '' return `
${formatValue(row.value)}
${copy ? `

${htmlEscape(copy)}

` : ''}
` } const plotBody = (spec) => { if (spec.chartType === 'anchor') return anchorBody(spec) if (spec.chartType === 'column') return columnBody(spec) if (spec.chartType === 'line') { return `
${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, })}
` } return rankingBody(spec) } const brandLockup = () => `
JORGE SOLLA
MANDATO DEPUTADO FEDERAL
` const footer = (spec) => `` 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 ` ` }