/** * 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, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs' import { dualLineChart, lineChart, proportionalPercent, tripleLineChart, } from './chartPrimitives.mjs' import { htmlEscape } from './reportText.mjs' /** Official kit 1313 palette, mapped by role (C203). */ export const SOLLA_PALETTE = { highlight: '#e4102f', brandBlue: '#184e92', 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, unit = '') => `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(value)}${unit}` const sortedByValue = (rows) => [...rows].sort((a, b) => b.value - a.value) const highlightLabel = (spec) => { if (spec.noHighlight) return null return 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, spec.unit)}
` }) .join('')}
` } const columnBody = (spec) => { const rows = spec.rows const max = Math.max(...rows.map((row) => row.value)) const winner = highlightLabel(spec) // CENA 02A (approved): the two-positive comparison caps and centers the pair // and assigns the tones by input order — never by value. const tracks = spec.dualPositive ? 'repeat(2, minmax(0, 280px))' : `repeat(${rows.length}, minmax(0, 320px))` return `
${rows .map((row, index) => { const isHighlight = !spec.dualPositive && row.label === winner const tone = spec.dualPositive ? ` positive-${index === 0 ? 'a' : 'b'}` : '' return `
${formatValue(row.value, spec.unit)}
${htmlEscape(row.label)}
` }) .join('')}
` } const formatPercent = (value) => { const rounded = Math.round(value * 10) / 10 return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1) } const deltaOrder = (rows) => [...rows].sort( (a, b) => b.final - a.final || a.initial - b.initial || a.label.localeCompare(b.label, 'pt-BR'), ) /** The sigla before "·" gets the bold of the approved variant; plain labels stay plain. */ const deltaLabelHtml = (label) => { const separator = label.indexOf('·') if (separator < 0) return htmlEscape(label) return `${htmlEscape(label.slice(0, separator).trim())} · ${htmlEscape( label.slice(separator + 1).trim(), )}` } /** * Variation bar (approved C191 variant): one zero-based bar per category, the * 0 → initial segment in the base tone and the initial → final extension in the * lighter tone. A flat category has no extension segment at all, and the two * numbers in the gutter carry the reading — never the color alone. */ const deltaBody = (spec) => { const rows = deltaOrder(spec.rows) const max = Math.max(1, ...rows.map((row) => row.final)) const aria = `Barras por tipo, de ${spec.startLabel} a ${spec.endLabel}: ${rows .map((row) => `${row.label} ${formatValue(row.initial)} → ${formatValue(row.final)}`) .join('; ')}.` return `` } const anchorBody = (spec) => { const row = spec.rows[0] const copy = row.label || spec.subtitle || '' return `
${formatValue(row.value, spec.unit)}
${copy ? `

${htmlEscape(copy)}

` : ''}
` } /** * Two-series time line (approved C191 variant): the valence travels by word + * arrow + marker shape + color, never by color alone. Red belongs to the * mandate (the good path); the falling series stays neutral. */ const DUAL_COLORS = { good: SOLLA_PALETTE.highlight, bad: SOLLA_PALETTE.axis, neutralA: SOLLA_PALETTE.ink, neutralB: SOLLA_PALETTE.axis, grid: SOLLA_PALETTE.barStrong, paper: SOLLA_PALETTE.paper, ink: SOLLA_PALETTE.ink, change: SOLLA_PALETTE.label, } const DUAL_VALENCE = { good: '↑ amplia', bad: '↓ recua' } const dualBody = (spec) => { const sizeKey = SIZES[spec.size] ? spec.size : 'feed' return `
${dualLineChart({ series: spec.series, size: sizeKey, projected: Boolean(spec.projectedLabel), crossingLabel: spec.crossingLabel ?? null, colors: DUAL_COLORS, valence: DUAL_VALENCE, format: (row) => formatValue(row.value, spec.unit), })}
` } /** * Three-series time line (approved C205 extension of the two-series variant, * projected final period certified by C207; feed only): melhor = red + circle + * "↑ amplia"; neutro = gray + diamond + "↗ cresce"; neutro escuro = ink + * triangle + "↘ diminui" (factual direction, never the bad valence "↓ recua"). * Every tone travels by word + arrow + shape + color, and the brand blue leaves * the data entirely. */ const TRIPLE_COLORS = { good: SOLLA_PALETTE.highlight, neutral: SOLLA_PALETTE.axis, 'neutral-dark': SOLLA_PALETTE.ink, grid: SOLLA_PALETTE.barStrong, paper: SOLLA_PALETTE.paper, ink: SOLLA_PALETTE.ink, } const TRIPLE_VALENCE = { good: '↑ amplia', neutral: '↗ cresce', 'neutral-dark': '↘ diminui' } const tripleBody = (spec) => `
${tripleLineChart({ series: spec.series, size: 'feed', projected: Boolean(spec.projectedLabel), colors: TRIPLE_COLORS, valence: TRIPLE_VALENCE, format: (row) => formatValue(row.value), })}
` const plotBody = (spec) => { if (spec.chartType === 'delta') return deltaBody(spec) const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0 if (seriesCount === 3) return tripleBody(spec) if (seriesCount > 0) return dualBody(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, spec.unit), stroke: SOLLA_PALETTE.axis, highlight: SOLLA_PALETTE.highlight, paper: SOLLA_PALETTE.paper, ink: SOLLA_PALETTE.ink, grid: SOLLA_PALETTE.barStrong, })}
` } return rankingBody(spec) } const brandMark = (brandMarkDataUri) => `
Jorge Solla — Deputado Federal
` const footer = (spec, brandMarkDataUri) => `` 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.brandBlue}; } .context { margin: 0; color: ${SOLLA_PALETTE.brandBlue}; 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; } .delta-grade { --delta-label-w: 292px; --delta-v1-w: 88px; --delta-v2-w: 110px; --delta-gap: 20px; --delta-bar-h: 53px; --delta-base: ${SOLLA_PALETTE.barStrong}; --delta-ext: ${SOLLA_PALETTE.bar}; flex: none; display: grid; gap: var(--delta-gap); margin-top: 34px; } .delta-head, .delta-row { display: grid; grid-template-columns: var(--delta-label-w) 1fr var(--delta-v1-w) var(--delta-v2-w); align-items: center; gap: var(--delta-gap); } .delta-head { align-items: end; padding-bottom: 6px; } .delta-label { font-size: 30px; line-height: 1.1; font-weight: 650; } .delta-label b { font-weight: 850; } .delta-track { position: relative; display: flex; height: var(--delta-bar-h); border-left: 2px solid ${SOLLA_PALETTE.axis}; } .delta-base { height: 100%; background: var(--delta-base); } .delta-ext { height: 100%; background: var(--delta-ext); border-left: 3px solid ${SOLLA_PALETTE.axis}; } .delta-value { font-variant-numeric: tabular-nums; font-size: 31px; line-height: 1; text-align: right; white-space: nowrap; } .delta-value.initial { color: ${SOLLA_PALETTE.label}; font-weight: 700; } .delta-value.final { color: ${SOLLA_PALETTE.ink}; font-weight: 850; } .delta-period { color: ${SOLLA_PALETTE.label}; font-size: 30px; line-height: 1; font-weight: 750; text-align: right; white-space: nowrap; } .columns { display: grid; justify-content: center; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; } .plot.dual-positive-plot { flex: none; height: 500px; margin-top: 34px; } .column.positive-a { background: ${SOLLA_PALETTE.highlight}; } .column.positive-b { background: ${SOLLA_PALETTE.highlight}; } .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; white-space: nowrap; } .line-plot { height: 100%; display: flex; align-items: center; } .line-plot svg { width: 100%; height: auto; } .dual-line-plot { flex: none; } .dual-line-plot svg { display: block; width: 880px; height: auto; } .dual-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; } .dual-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; } .dual-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } .dual-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; } .dual-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; } .dual-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; } .dual-end-value { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; } .dual-end-valence { font-weight: 850; } .dual-end-change { fill: ${SOLLA_PALETTE.label}; font-weight: 700; } .dual-crossing-guide { stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 2; stroke-dasharray: 8 8; opacity: 0.45; } .dual-crossing-ring { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.highlight}; stroke-width: 4; } .dual-crossing-box { fill: ${SOLLA_PALETTE.paper}; stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } .dual-crossing-year { fill: ${SOLLA_PALETTE.highlight}; font-weight: 850; } .dual-crossing-copy { fill: ${SOLLA_PALETTE.ink}; font-weight: 750; } .triple-line-plot { flex: none; } .triple-line-plot svg { display: block; width: 880px; height: auto; } .triple-grid-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-opacity: 0.32; stroke-width: 2; } .triple-zero-line { stroke: ${SOLLA_PALETTE.barStrong}; stroke-width: 2; } .triple-axis-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 650; } .triple-end-name { fill: ${SOLLA_PALETTE.ink}; font-weight: 700; } .triple-end-metric { fill: ${SOLLA_PALETTE.ink}; font-weight: 850; font-variant-numeric: tabular-nums; } .triple-end-valence { font-weight: 850; } .triple-end-leader { fill: none; stroke-width: 3; stroke-linecap: square; stroke-linejoin: round; } .triple-projection-band { fill: ${SOLLA_PALETTE.barStrong}; fill-opacity: 0.16; } .triple-projection-label { fill: ${SOLLA_PALETTE.axis}; font-weight: 700; } .triple-series-projection { stroke-dasharray: 18 12; } .triple-marker-projected { fill: ${SOLLA_PALETTE.paper}; stroke-width: 5; stroke-linejoin: round; } .triple-projected .triple-end-metric { font-size: 30px; } .triple-projected .source { max-width: 620px; } .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; min-height: 171px; } .source { margin: 0; color: ${SOLLA_PALETTE.label}; font-size: 27px; line-height: 1.32; } .source strong { color: ${SOLLA_PALETTE.ink}; font-weight: 750; } .brand-logo-frame { width: 251px; height: 144px; flex: 0 0 251px; } .brand-logo-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }` /** 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). `brandLogo` is * the official kit mark as a `data:image/png;base64,` URI (read by the entry * with `readKitAssets`); the render fails closed without it — the typographic * lockup is never recreated. */ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => { if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(brandLogoDataUri ?? '')) { throw new Error( 'marca oficial ausente: passe brandLogo como data:image/png;base64,… do ativo oficial do kit.', ) } const sizeKey = SIZES[spec.size] ? spec.size : 'feed' const canvas = SIZES[sizeKey] const layout = layoutFor(sizeKey) const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0 const dual = seriesCount === 2 const triple = seriesCount === 3 const tripleProjected = triple && Boolean(spec.projectedLabel) const dualPositive = Boolean(spec.dualPositive) const dualPositiveStyles = !dualPositive || sizeKey !== 'feed' ? '' : '.headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }' const dualStyles = !dual && !triple ? '' : triple ? '.headline { font-size: 56px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 27px; } .source { max-width: 570px; font-size: 25px; } .plot { margin-top: 16px; }' : sizeKey === 'feed' ? '.headline { font-size: 60px; max-width: 900px; } .subtitle { font-size: 28px; } .source { font-size: 25px; } .plot { margin-top: 28px; }' : sizeKey === 'square' ? '.inner { padding: 56px 84px 48px; } .headline { font-size: 54px; } .plot { margin-top: 20px; }' : '.headline { font-size: 64px; } .plot { margin-top: 28px; }' const footerStyles = sizeKey === 'square' ? '.footer { min-height: 128px; padding-top: 16px; } .source { font-size: 25px; } .brand-logo-frame { width: 192px; height: 110px; flex-basis: 192px; }' : sizeKey === 'story' ? '.footer { min-height: 190px; padding-top: 28px; gap: 28px; } .brand-logo-frame { width: 279px; height: 160px; flex-basis: 279px; }' : '' const delta = spec.chartType === 'delta' const deltaStyles = !delta ? '' : sizeKey === 'feed' ? '.plot { margin-top: 0; } .headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; } .delta-grade { margin-top: 30px; }' : sizeKey === 'square' ? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }' : '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }' const kicker = spec.kicker ?? (dual || triple ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico')) const anchorUsesSubtitle = spec.chartType === 'anchor' && !spec.rows[0]?.label const showSubtitle = Boolean(spec.subtitle) && !anchorUsesSubtitle return ` ` }