teqo-backup/scripts/lib/chartData.mjs

334 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Chart data core (C191): parse the numbers the communication team pasted or
* dropped (xlsx/csv/md/txt/free text), pick the chart relation and enforce the
* correctness guardrails — all pure, no Chromium and no DOM, so the unit suite
* covers it without a database or a browser.
*
* The renderer (`graficosInstagramRender.mjs`) owns the look; this module never
* invents a value: anything missing or ambiguous becomes an explicit question.
*/
import { parse as parseCsv } from 'csv-parse/sync'
import XLSX from 'xlsx'
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor']
/** Max data points per chart (honest density; above this the person must summarize). */
export const MAX_POINTS = 7
/**
* Stories recompose the vertical rhythm with one fewer ranking point (approved
* design: "reduz um ponto do ranking"), so a six/seven-point ranking is refused
* instead of crowded.
*/
export const MAX_POINTS_STORY = 5
/** Output canvases: feed 4:5 (default), square, stories/reels 9:16 with safe bands. */
export const SIZES = {
feed: { width: 1080, height: 1350, label: '1080×1350', safeTop: 0, safeBottom: 0 },
square: { width: 1080, height: 1080, label: '1080×1080', safeTop: 0, safeBottom: 0 },
story: { width: 1080, height: 1920, label: '1080×1920', safeTop: 250, safeBottom: 250 },
}
/** Kicker of each relation, matching the approved hi-fi template. */
export const RELATION_LABEL = {
bar: 'Comparação',
column: 'Poucos períodos',
line: 'Série de tempo',
anchor: 'Número-âncora',
}
const TEMPORAL_RE =
/^(\d{4}|\d{2}|[A-Za-zÀ-ÿ]{3,9}[./-]?\d{2,4}|\d{1,2}[./-]\d{2,4}|[TQ]\d|P\d+|\d{4}[./-]\d{1,2})$/u
/** True when the label reads as a period (year, month, quarter, P1…), not a category. */
export const isTemporalLabel = (label) => TEMPORAL_RE.test(String(label).trim())
/**
* pt-BR/en number: strips currency/spaces/percent, treats the last separator as
* the decimal when both `.` and `,` appear, and comma-as-decimal otherwise.
* Returns null (never 0) for anything non-numeric, so "missing" stays honest.
*/
export const parseNumber = (raw) => {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
let value = String(raw ?? '')
.replace(/[R$\s%]/gi, '')
.trim()
if (value === '') return null
const hasDot = value.includes('.')
const hasComma = value.includes(',')
if (hasDot && hasComma) {
value =
value.lastIndexOf(',') > value.lastIndexOf('.')
? value.replaceAll('.', '').replace(',', '.')
: value.replaceAll(',', '')
} else if (hasComma) {
value = value.replace(',', '.')
}
if (!/^-?\d+(\.\d+)?$/.test(value)) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const lastCell = (row) => row[row.length - 1]
const normalizeWord = (value) =>
String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
/**
* A header row is only recognized by an explicit header word in some cell —
* never by "the first value is not numeric", which would drop a real data row
* with a missing value ("Ilhéus n/d") in silence. Unknown headers survive as a
* data row and become an explicit question instead.
*/
const HEADER_HINTS = new Set([
'valor',
'value',
'total',
'quantidade',
'qtd',
'numero',
'montante',
'percentual',
'percent',
'%',
'taxa',
'media',
'indice',
'votos',
'count',
'amount',
'resultado',
'unidade',
'ano',
'mes',
'periodo',
'data',
'indicador',
'metrica',
'r$',
'populacao',
'eleitores',
'eleitorado',
'cidade',
'municipio',
'categoria',
'grupo',
'regiao',
'estado',
'partido',
'candidato',
])
const looksLikeHeaderRow = (row) =>
row.some((cell) =>
normalizeWord(cell)
.split(/[^a-z0-9%$]+/)
.some((word) => HEADER_HINTS.has(word)),
)
const cleanMatrix = (matrix) =>
matrix
.map((row) => row.map((cell) => String(cell ?? '').trim()))
.filter((row) => row.some(Boolean))
const matrixToDataset = (matrix, format) => {
const cleaned = cleanMatrix(matrix)
const issues = []
if (cleaned.length === 0) return { rows: [], format, issues: ['nenhum dado encontrado'] }
let start = 0
if (
cleaned.length > 1 &&
parseNumber(lastCell(cleaned[0])) === null &&
looksLikeHeaderRow(cleaned[0])
) {
start = 1
}
const rows = []
for (const row of cleaned.slice(start)) {
const label = row[0]
const rawValue = lastCell(row)
const value = parseNumber(rawValue)
if (!label && value === null) {
issues.push(`linha sem rótulo: ${JSON.stringify(row.join(' | '))}`)
continue
}
if (value === null) {
issues.push(`valor não numérico em "${label}": ${JSON.stringify(rawValue)}`)
continue
}
rows.push({ label, value })
}
const seen = new Set()
for (const row of rows) {
if (seen.has(row.label)) issues.push(`rótulo repetido: "${row.label}"`)
seen.add(row.label)
}
return { rows, format, issues }
}
const sniffDelimiter = (line) => {
let best = null
let bestCount = 0
for (const delimiter of ['\t', ';', ',']) {
const count = line.split(delimiter).length - 1
if (count > bestCount) {
bestCount = count
best = delimiter
}
}
return bestCount > 0 ? best : null
}
const isMarkdownTable = (lines) =>
lines.length > 1 && lines[0].includes('|') && /^\s*\|?[\s:-]*-{3,}[\s:|-]*\|?\s*$/.test(lines[1])
const parseMarkdownTable = (lines) =>
lines
.filter((line) => line.includes('|'))
.filter((_line, index) => index !== 1)
.map((line) =>
line
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((cell) => cell.trim()),
)
/** Last separator wins, so labels may contain spaces: "Votos em Ilhéus: 84". */
const splitPair = (line) => {
const separators = [':', '=', '\t']
for (const separator of separators) {
const at = line.lastIndexOf(separator)
if (at > 0) return [line.slice(0, at), line.slice(at + 1)]
}
const match = line.match(/^(.*\S)\s+([^\s]+)$/s)
if (match) return [match[1], match[2]]
// A bare number is an unlabeled single measure (the anchor relation).
if (parseNumber(line) !== null) return ['', line]
return [line, '']
}
const parseFreeText = (text) => {
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
if (lines.length === 0) return { rows: [], format: 'txt', issues: ['nenhum dado encontrado'] }
if (isMarkdownTable(lines)) return matrixToDataset(parseMarkdownTable(lines), 'md')
const delimiter = sniffDelimiter(lines[0])
if (delimiter) {
const matrix = lines.map((line) => line.split(delimiter))
return matrixToDataset(matrix, delimiter === '\t' ? 'tsv' : 'csv')
}
const matrix = lines.map(splitPair)
return matrixToDataset(matrix, 'txt')
}
/**
* Parse the raw input into `{ rows, format, issues }`. `buffer` is required for
* xlsx/xls; `text` for csv/md/txt. `format` overrides the extension inference.
*
* @param {{ text?: string | null, buffer?: Buffer | null, format?: string | null }} [options]
*/
export const parseInput = ({ text = null, buffer = null, format = null } = {}) => {
if (format === 'xlsx' || format === 'xls') {
const workbook = XLSX.read(buffer, { type: 'buffer' })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false })
return matrixToDataset(matrix, 'xlsx')
}
const body = String(text ?? '')
if (format === 'csv' || format === 'tsv') {
const delimiter =
format === 'tsv' ? '\t' : (sniffDelimiter(body.split(/\r?\n/)[0] ?? '') ?? ',')
const matrix = parseCsv(body, {
delimiter,
skip_empty_lines: true,
relax_column_count: true,
})
return matrixToDataset(matrix, format)
}
return parseFreeText(body)
}
/**
* Relation of the data → chart type. One measure is an anchor; every-temporal
* labels are a series (few periods as columns, many as a line); anything else
* is a ranking/comparison in horizontal bars.
*
* @param {{ label: string, value: number }[]} rows
* @param {string | null} [forcedType]
*/
export const classifyRelation = (rows, forcedType = null) => {
if (forcedType) {
// `pie` passes through so `validateSpec` answers with the actionable
// "use barras horizontais" message instead of a generic unknown type.
if (forcedType !== 'pie' && !CHART_TYPES.includes(forcedType)) {
throw new Error(
`tipo de gráfico desconhecido: ${JSON.stringify(forcedType)} (use ${CHART_TYPES.join(', ')})`,
)
}
return forcedType
}
if (rows.length === 1) return 'anchor'
if (rows.length > 1 && rows.every((row) => isTemporalLabel(row.label))) {
return rows.length <= 4 ? 'column' : 'line'
}
return 'bar'
}
/**
* Fail-closed guardrails of the approved template. Throws with the actionable
* reason instead of drawing a misleading chart.
*/
export const validateSpec = (spec) => {
const { chartType, rows, headline } = spec
if (spec.size && !SIZES[spec.size]) {
throw new Error(
`tamanho inválido: ${JSON.stringify(spec.size)} (use ${Object.keys(SIZES).join(', ')})`,
)
}
if (!CHART_TYPES.includes(chartType)) {
if (chartType === 'pie') {
throw new Error(
'pizza não é gerada no v1 (a comparação fica escondida); use barras horizontais.',
)
}
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
}
if (!Array.isArray(rows) || rows.length === 0)
throw new Error('sem pontos de dado para desenhar.')
if (rows.length > MAX_POINTS) {
throw new Error(
`${rows.length} pontos (> ${MAX_POINTS}): resuma as categorias antes de gerar o gráfico.`,
)
}
if (
spec.size === 'story' &&
(chartType === 'bar' || chartType === 'column') &&
rows.length > MAX_POINTS_STORY
) {
throw new Error(
`stories comporta até ${MAX_POINTS_STORY} pontos no ranking (${rows.length} recebidos): resuma para gerar a versão vertical.`,
)
}
if (chartType === 'anchor' && rows.length !== 1) {
throw new Error('número-âncora exige exatamente um valor.')
}
for (const row of rows) {
if (!row.label && chartType !== 'anchor') throw new Error('ponto de dado sem rótulo.')
if (!Number.isFinite(row.value)) throw new Error(`valor ausente/inválido em "${row.label}".`)
if (row.value < 0) throw new Error(`valor negativo em "${row.label}" — barras partem do zero.`)
}
if (spec.highlight && !rows.some((row) => row.label === spec.highlight)) {
throw new Error(`destaque "${spec.highlight}" não existe nos dados — confira o rótulo.`)
}
if (typeof headline !== 'string' || headline.trim() === '') {
throw new Error('título-manchete ausente — a manchete é obrigatória.')
}
return spec
}