/**
* 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 `
${rows
.map(
(row) => `
${htmlEscape(row.label)}
${htmlEscape(format(row))}
`,
)
.join('')}
`
}
/**
* 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}
`
}