/** * Chart primitives (owner of the drawing): inline-SVG emitters shared by the * A4 dossier renderer (C186/C187) and the Instagram chart renderer (C191). * * Extracted from `dossieRender.mjs` when the Instagram generator became the * second consumer, so a fix to the honest-scale/guardrail drawing lives once. * The dossier keeps its mm/pt page composition; each consumer composes its own * page around these primitives — never a second copy of the drawing. */ import { htmlEscape } from './reportText.mjs' /** * Domain palette of the dossier charts. Instagram passes its own Solla palette * explicitly; this map stays the dossier default so its output is unchanged. */ export const CHART_COLORS = { municipio: '#315c75', regiao: '#9d8b64', polo: '#8f819c', instituicao: '#315c75', setor: '#9d8b64', rede: '#8f819c', empenhado: '#6b4918', liquidado: '#4a6b7c', pago: '#285338', autorizado: '#8a5a18', restos: '#5b6470', nao_informado: '#8b96a2', default: '#315c75', } /** * Honest-scale proportion of a value against the maximum, as a percentage * (0..100). Zero stays zero (no bar is drawn); a present small value keeps a * visible minimum so it is not mistaken for absent. Used for the ranking width * and the column height of the Instagram renderer. */ export const proportionalPercent = (value, max) => value <= 0 ? 0 : Math.min(100, Math.max(2, Math.round((value / Math.max(1, max)) * 100))) export const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => { const max = Math.max(1, ...rows.map((row) => row.count)) const barMax = width - labelWidth - 26 const height = rows.length * rowHeight + 4 return ` ${rows .map((row, index) => { const y = index * rowHeight + 3 const barWidth = Math.max(2, Math.round((row.count / max) * barMax)) return `${htmlEscape(row.label)} ${row.count}` }) .join('')} ` } /** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */ export const valueList = ({ rows, format }) => { const max = Math.max(1, ...rows.map((row) => row.value)) return `` } /** * Vertical columns with the value label above and the year label below. * `fullYearLabel` prints the whole year (theme axis); the institution keeps the * 2-digit label. */ export const columnChart = ({ rows, width = 330, height = 132, format = (row) => String(row.count), fullYearLabel = false, }) => { const max = Math.max(1, ...rows.map((row) => row.value ?? row.count)) const baseY = height - 18 const plotHeight = baseY - 16 const columnWidth = (width - 8) / Math.max(1, rows.length) return ` ${rows .map((row, index) => { const value = row.value ?? row.count const barHeight = Math.max(2, Math.round((value / max) * plotHeight)) const x = 4 + index * columnWidth const y = baseY - barHeight return ` ${htmlEscape(format(row))} ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` }) .join('')} ` } /** Vertical columns stacked by phase (money by year), total label above. */ export const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false, }) => { const max = Math.max(1, ...rows.map((row) => row.value)) const baseY = height - 18 const plotHeight = baseY - 16 const columnWidth = (width - 8) / Math.max(1, rows.length) return ` ${rows .map((row, index) => { const x = 4 + index * columnWidth let y = baseY const bars = row.segments .map((segment) => { const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight)) y -= segmentHeight return `` }) .join('') const top = baseY - Math.round((row.value / max) * plotHeight) return `${bars} ${htmlEscape(format(row))} ${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}` }) .join('')} ` } /** * Geometry of the approved two-series time line (C191 variant), per export * size. The data box is the same on every canvas; only the vertical rhythm and * the marker sizes change (design: "Feed / Quadrado / Story" rules). */ const DUAL_GEOMETRY = { feed: { width: 880, height: 620, left: 52, right: 600, top: 56, baseline: 544, stroke: 8, dotRadius: 8, squareSize: 16, projectedDotRadius: 11, projectedSquareSize: 20, dash: '18 12', nameSize: 30, valueSize: 36, valenceSize: 30, changeSize: 30, axisSize: 30, blockOffsets: [-35, 7, 47, 87], blockGap: 72, crossing: { boxWidth: 254, boxHeight: 118, boxY: 398, yearOffset: 33, copyOffset: 67, copyOffset2: 99, }, }, square: { width: 880, height: 440, left: 52, right: 600, top: 40, baseline: 370, stroke: 7, dotRadius: 7, squareSize: 14, projectedDotRadius: 10, projectedSquareSize: 18, dash: '15 11', nameSize: 30, valueSize: 32, valenceSize: 30, changeSize: 30, axisSize: 30, blockOffsets: [-31, 7, 45, 83], blockGap: 40, crossing: { boxWidth: 254, boxHeight: 108, boxY: 252, yearOffset: 30, copyOffset: 59, copyOffset2: 88, }, }, story: { width: 880, height: 700, left: 52, right: 600, top: 64, baseline: 622, stroke: 8, dotRadius: 8, squareSize: 16, projectedDotRadius: 11, projectedSquareSize: 20, dash: '18 12', nameSize: 32, valueSize: 36, valenceSize: 32, changeSize: 32, axisSize: 30, blockOffsets: [-34, 10, 52, 94], blockGap: 84, crossing: { boxWidth: 254, boxHeight: 118, boxY: 478, yearOffset: 33, copyOffset: 67, copyOffset2: 99, }, }, } /** Rounds the shared maximum up to the next legible step (never down). */ const niceMax = (value) => { if (!(value > 0)) return 1 const magnitude = 10 ** Math.floor(Math.log10(value)) for (const step of [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) { const candidate = step * magnitude if (candidate >= value) return candidate } return 10 * magnitude } /** * 9–12 annual points alternate their axis labels, always keeping the first and * the last; the penultimate is dropped when it would collide with the last * (design: "2017, 2019, 2021, 2023 e 2026"). Shared by the two- and the * three-series lines. */ const axisLabelIndices = (count) => { if (count < 9) return [...Array(count).keys()] const shown = new Set() for (let index = 0; index < count; index += 2) shown.add(index) const last = count - 1 if (!shown.has(last)) { shown.delete(last - 1) shown.add(last) } return [...shown].sort((a, b) => a - b) } /** Keeps the crossing box inside the data box while staying centered on the point. */ const crossingCenter = (x, cfg) => Math.min(Math.max(x, cfg.left + cfg.crossing.boxWidth / 2), cfg.right - cfg.crossing.boxWidth / 2) /** * Two-series time line (Instagram, C191 approved variant): shared zero-based * scale, direct end labels and valence carried by word + arrow + shape + color. * `series` items are `{ name, tone?: 'good'|'bad', rows: [{ label, value }] }`; * tones come in pairs, or not at all (neutral comparison, no red in the plot). */ export const dualLineChart = ({ series, size = 'feed', projected = false, crossingLabel = /** @type {string | null} */ (null), colors, valence, format, }) => { const cfg = DUAL_GEOMETRY[size] ?? DUAL_GEOMETRY.feed const count = series[0].rows.length const max = niceMax(Math.max(...series.flatMap((serie) => serie.rows.map((row) => row.value)))) const step = count > 1 ? (cfg.right - cfg.left) / (count - 1) : 0 const xAt = (index) => Math.round(cfg.left + index * step) const yAt = (value) => cfg.baseline - Math.round((value / max) * (cfg.baseline - cfg.top)) const pointsOf = (serie) => serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) })) const styles = series.map((serie, index) => { if (serie.tone === 'good') { return { color: colors.good, shape: 'circle', valence: valence.good, tone: 'good' } } if (serie.tone === 'bad') { return { color: colors.bad, shape: 'square', valence: valence.bad, tone: 'bad' } } return index === 0 ? { color: colors.neutralA, shape: 'circle', valence: null, tone: 'a' } : { color: colors.neutralB, shape: 'square', valence: null, tone: 'b' } }) const marker = ({ x, y, shape, size: markerSize, fill, className = '' }) => shape === 'square' ? `` : `` const band = projected ? (() => { const before = xAt(count - 2) const last = xAt(count - 1) const start = Math.round((before + last) / 2) const end = last + 33 return `` })() : '' const gridLines = ` 0` const projectionLabel = projected ? `projeção` : '' const lines = series .map((serie, index) => { const points = pointsOf(serie) const style = styles[index] const solid = projected ? points.slice(0, -1) : points const polyline = `` const dashed = projected ? `` : '' const regular = projected ? points.slice(0, -1) : points const markers = `${regular .map((point) => marker({ x: point.x, y: point.y, shape: style.shape, size: style.shape === 'square' ? cfg.squareSize : cfg.dotRadius * 2, fill: style.color, }), ) .join('')}` return `${polyline}${dashed}${markers}` }) .join('') const projectedMarkers = projected ? series .map((serie, index) => { const point = pointsOf(serie)[count - 1] const style = styles[index] return marker({ x: point.x, y: point.y, shape: style.shape, size: style.shape === 'square' ? cfg.projectedSquareSize : cfg.projectedDotRadius * 2, fill: colors.paper, className: `dual-marker-projected dual-marker-projected-${style.tone}`, }).replace('/>', ` stroke="${style.color}" stroke-width="5" />`) }) .join('') : '' const observedIndex = projected ? count - 2 : count - 1 const changeOf = (serie) => { const firstValue = serie.rows[0].value const observed = serie.rows[observedIndex]?.value if (!(firstValue > 0) || !Number.isFinite(observed)) return null const percent = Math.round(((observed - firstValue) / firstValue) * 100) const sign = percent > 0 ? '+' : percent < 0 ? '−' : '' const until = `até ${serie.rows[observedIndex].label}` return { percent: `${sign}${Math.abs(percent)}%`, until, label: `${sign}${Math.abs(percent)}% ${until}`, } } const blocks = series .map((serie, index) => { const point = pointsOf(serie)[count - 1] const offsets = styles[index].valence ? cfg.blockOffsets : cfg.blockOffsets.slice(0, 3) return { name: serie.name, style: styles[index], point, baselines: offsets.map((offset) => point.y + offset), value: format(serie.rows[count - 1]), change: changeOf(serie), } }) .sort((a, b) => a.point.y - b.point.y) let previousBottom = null for (const block of blocks) { if (previousBottom !== null) { const minName = previousBottom + cfg.blockGap const delta = minName - block.baselines[0] if (delta > 0) block.baselines = block.baselines.map((baseline) => baseline + delta) } previousBottom = block.baselines[block.baselines.length - 1] } const clampOverflow = blocks[blocks.length - 1]?.baselines.at(-1) - (cfg.height - 12) const endBlocks = blocks .map((block) => { const baselineOffset = clampOverflow > 0 ? -clampOverflow : 0 const text = (className, size, fill, value, index) => `${htmlEscape(value)}` const parts = [ ``, text('dual-end-name', cfg.nameSize, colors.ink, block.name, 0), text('dual-end-value', cfg.valueSize, colors.ink, block.value, 1), ] if (block.style.valence) { parts.push( text('dual-end-valence', cfg.valenceSize, block.style.color, block.style.valence, 2), ) } if (block.change) { parts.push( text( 'dual-end-change', cfg.changeSize, colors.change, block.change.label, block.style.valence ? 3 : 2, ), ) } return parts.join('') }) .join('') const goodSeries = series.find((serie) => serie.tone === 'good') const crossingIndex = crossingLabel ? series[0].rows.findIndex((row) => row.label === crossingLabel) : -1 const showCrossing = Boolean(goodSeries) && crossingIndex >= 1 const crossingGuide = showCrossing ? (() => { const point = pointsOf(goodSeries)[crossingIndex] const center = crossingCenter(point.x, cfg) return `` })() : '' const crossingAnnotation = showCrossing ? (() => { const point = pointsOf(goodSeries)[crossingIndex] const box = cfg.crossing const center = crossingCenter(point.x, cfg) return ` ${htmlEscape(crossingLabel)} ${htmlEscape(goodSeries.name)} ultrapassa` })() : '' const axis = axisLabelIndices(count) .map( (index) => `${htmlEscape(series[0].rows[index].label)}`, ) .join('') const ariaLabel = `${series .map((serie) => { const change = changeOf(serie) const destination = projected ? ` para projeção de ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}` : ` para ${format(serie.rows[count - 1])} em ${serie.rows[count - 1].label}` const trend = change ? `, variação observada de ${change.percent} até ${serie.rows[observedIndex].label}` : '' return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}` }) .join('. ')}${showCrossing ? `. ${goodSeries.name} ultrapassa em ${crossingLabel}` : ''}` return ` ${band} ${gridLines} ${projectionLabel} ${crossingGuide} ${lines} ${projectedMarkers} ${crossingAnnotation} ${endBlocks} ${axis} ` } /** * Time-series line (Instagram): every point a hollow marker, the last one the * single red highlight with its value. Honest scale — the baseline is zero. */ export const lineChart = ({ rows, width = 880, height = 420, format, stroke = '#78716c', highlight = '#e4102f', paper = '#faf9f7', ink = '#1c1917', grid = '#a8a29e', }) => { const max = Math.max(1, ...rows.map((row) => row.value)) const padX = 46 const padTop = 42 const baseline = height - 46 const plotHeight = baseline - padTop const step = rows.length > 1 ? (width - padX * 2) / (rows.length - 1) : 0 const pointAt = (row, index) => { const x = padX + index * step const y = baseline - Math.round((row.value / max) * plotHeight) return { x, y } } const points = rows.map(pointAt) const last = points[points.length - 1] return ` ${points .slice(0, -1) .map((point) => ``) .join('')} ${htmlEscape(format(rows[rows.length - 1]))} ${rows .map( (row, index) => `${htmlEscape(row.label)}`, ) .join('')} ` } /** * Geometry of the approved three-series time line (C205 extension, feed only; * projected final period added by C207). The data box is narrower than the * two-series one (x=52…560) so the wider end gutter (x=600…880, 280px) holds * three three-line blocks; x=560…600 belongs to the leaders only. Lanes: * name / metric / valence with 30–40px line boxes and a 196px pitch (design: * 62/108/150 · 258/304/346 · 454/500/542). */ const TRIPLE_GEOMETRY = { feed: { width: 880, height: 650, left: 52, right: 560, top: 56, baseline: 544, stroke: 7, dotRadius: 8, diamondSize: 14, triangleWidth: 20, triangleTop: 8, triangleApex: 10, projectedDotRadius: 11, projectedDiamondSize: 28, projectedTriangleWidth: 30, projectedTriangleTop: 11, projectedTriangleApex: 15, axisSize: 30, axisY: 636, zeroX: 42, zeroY: 538, nameSize: 30, metricSize: 34, projectedMetricSize: 30, valenceSize: 30, changeX: 874, textX: 600, leader: { start: 570, rail: 580, bend: 590, end: 594 }, projectedLeader: { start: 578, rail: 584, bend: 590, end: 594 }, laneOffsets: [62, 108, 150], lanePitch: 196, }, } /** * Three-series time line (C205 extension of the two-series variant, projected * final period certified by C207): three series on a shared zero-based scale * and the triad of redundant tones (melhor = red circle ↑ "amplia"; neutro = * gray diamond ↗ "cresce"; neutro escuro = ink triangle ↘ "diminui") with a * three-line end gutter. Every point observed — no band, no dash, no hollow * marker, no "projeção". When `projected` is on (last period only): the band * covers the final step, its top label names the projected period ("projeção * 2026"), only the last segment dashes and the three final markers are hollow, * and the gutter shows the projected value beside the variation that runs * first → projected period (human gate feedback on C207: the percentage must * close on the same pair as the headline). * * `series` items are `{ name, tone: 'good'|'neutral'|'neutral-dark', rows }`; * the caller owns the palette and the valence words (never color alone). */ export const tripleLineChart = ({ series, size = 'feed', projected = false, colors, valence, format, }) => { const cfg = TRIPLE_GEOMETRY[size] ?? TRIPLE_GEOMETRY.feed const count = series[0].rows.length const max = niceMax(Math.max(...series.flatMap((serie) => serie.rows.map((row) => row.value)))) const step = count > 1 ? (cfg.right - cfg.left) / (count - 1) : 0 const xAt = (index) => Math.round(cfg.left + index * step) const yAt = (value) => cfg.baseline - Math.round((value / max) * (cfg.baseline - cfg.top)) const lastIndex = count - 1 const marker = (tone, x, y) => { if (tone === 'neutral') { const half = cfg.diamondSize / 2 return `` } if (tone === 'neutral-dark') { const half = cfg.triangleWidth / 2 return `` } return `` } /** Hollow final marker of the projected period (C207): same tone shape, big. */ const projectedMarker = (tone, x, y) => { const color = colors[tone] ?? colors.neutral const className = `triple-marker-projected triple-marker-projected-${tone}` if (tone === 'neutral') { const half = cfg.projectedDiamondSize / 2 return `` } if (tone === 'neutral-dark') { const half = cfg.projectedTriangleWidth / 2 return `` } return `` } const band = projected ? `` : '' const projectionLabel = projected ? `projeção ${htmlEscape(series[0].rows[lastIndex].label)}` : '' const gridLines = ` 0` const lines = series .map((serie) => { const color = colors[serie.tone] ?? colors.neutral const points = serie.rows.map((row, index) => ({ x: xAt(index), y: yAt(row.value) })) const solid = projected ? points.slice(0, -1) : points const polyline = `` const dashed = projected ? `` : '' const markers = `${solid.map((point) => marker(serie.tone, point.x, point.y)).join('')}` return `${polyline}${dashed}${markers}` }) .join('') const projectedMarkers = projected ? series .map((serie) => { const point = { x: xAt(lastIndex), y: yAt(serie.rows[lastIndex].value) } return projectedMarker(serie.tone, point.x, point.y) }) .join('') : '' const changeOf = (serie) => { const firstValue = serie.rows[0].value const observed = serie.rows[lastIndex]?.value if (!(firstValue > 0) || !Number.isFinite(observed)) return null const percent = Math.round(((observed - firstValue) / firstValue) * 100) const sign = percent > 0 ? '+' : percent < 0 ? '−' : '' return { percent, percentLabel: `${sign}${Math.abs(percent)}%` } } const blocks = series .map((serie) => ({ serie, color: colors[serie.tone] ?? colors.neutral, change: changeOf(serie), point: { y: yAt(serie.rows[lastIndex].value) }, })) .sort((a, b) => a.point.y - b.point.y) .map((block, lane) => ({ ...block, baselines: cfg.laneOffsets.map((offset) => offset + lane * cfg.lanePitch), })) const endBlocks = blocks .map((block) => { const { baselines, point } = block const leader = projected ? cfg.projectedLeader : cfg.leader const metricSize = projected ? cfg.projectedMetricSize : cfg.metricSize const metric = `${format(block.serie.rows[lastIndex])}${ block.change ? ` · ${block.change.percentLabel}` : '' }` const text = (className, size, fill, value, index, options = {}) => `${htmlEscape(value)}` const metricLine = projected ? [ text('triple-end-metric', metricSize, null, format(block.serie.rows[lastIndex]), 1), block.change ? text('triple-end-metric', metricSize, null, `· ${block.change.percentLabel}`, 1, { x: cfg.changeX, anchor: 'end', }) : '', ].join('') : text('triple-end-metric', metricSize, null, metric, 1) const valenceLine = text( 'triple-end-valence', cfg.valenceSize, block.color, valence[block.serie.tone] ?? '', 2, ) return [ ``, text('triple-end-name', cfg.nameSize, null, block.serie.name, 0), metricLine, valenceLine, ].join('') }) .join('') const axis = axisLabelIndices(count) .map( (index) => `${htmlEscape(series[0].rows[index].label)}`, ) .join('') const ariaLabel = `${series .map((serie) => { const change = changeOf(serie) const direction = !change ? '' : change.percent > 0 ? 'aumento' : change.percent < 0 ? 'diminuição' : 'sem variação' const destination = projected ? ` para projeção de ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}` : ` para ${format(serie.rows[lastIndex])} em ${serie.rows[lastIndex].label}` const trend = !change ? '' : projected ? `, variação de ${change.percent > 0 ? 'mais ' : change.percent < 0 ? 'menos ' : ''}${Math.abs(change.percent)} por cento até ${serie.rows[lastIndex].label} (projeção)` : `, ${direction} de ${Math.abs(change.percent)} por cento` return `${serie.name}: de ${format(serie.rows[0])} em ${serie.rows[0].label}${destination}${trend}` }) .join('. ')}. ${ projected ? `O último segmento e os três marcadores de ${series[0].rows[lastIndex].label} são projetados.` : 'Todos os pontos de cada série são observados.' }` return ` ${band} ${gridLines} ${projectionLabel} ${lines} ${projectedMarkers} ${endBlocks} ${axis} ` }