feat(sprint-1.5): rediseño visual PDF ROI + identidad InQ (Etapas 4-7)

- Header/footer compartidos con paleta InQ naranja en los 3 PDFs (Etapa 4)
- Layout híbrido portrait+landscape en PDFs Actual/Automatizado:
  página 2 landscape para diagrama BPMN heatmap (Etapa 5)
- Página 1 PDF ROI rediseñada: header denso, banner narrativo, KPI Hero
  con gradiente naranja→magenta, grid 2×2 de KPIs secundarios, Top 3
  actividades por ahorro (Etapa 6)
- Páginas 2-5 PDF ROI con identidad InQ: tabla con paleta naranja,
  análisis del ahorro, nota metodológica, trayectoria del ahorro
  acumulado con gráfico SVG nativo (Etapa 7)
- 500 tests verdes (+ 34 tests nuevos del Sprint 1.5)
- BACKLOG.md, TECH_DEBT.md, OBSERVATIONS.md: sistema de planificación
  reemplaza TODO.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:45:01 -03:00
parent 63c4fbac99
commit 2f4633a33c
20 changed files with 4163 additions and 117 deletions

View File

@@ -1,12 +1,13 @@
import { buildFileName } from './slug'
import { formatPayback } from '@/lib/format'
import { calculateRoi } from '@/domain/roi'
import {
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, PDF,
} from './pdf-sections'
import {
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection,
drawRoiPage1, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection, drawTrajectoryChart,
} from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
@@ -72,24 +73,25 @@ async function exportScenarioPdf(
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// Página 1
// ── Página 1 (portrait): KPI cards ──────────────────────────────────────────
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
y = drawKpiCards(docAny, result, currency, y)
y += 4
// ── Página 2 (landscape): Diagrama BPMN con heatmap ─────────────────────────
// Método obligatorio: doc.addPage('a4', 'landscape') — ver ETAPA_5_PROMPT §3.1
docAny.addPage('a4', 'landscape')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
docAny.setFont('helvetica', 'bold')
docAny.setFontSize(10)
docAny.setTextColor(...PDF.slate900)
docAny.text('Mapa de calor de costos', PDF.margin, y)
y += 5
y = drawHeatmapImage(docAny, heatmapImageData, y)
y = drawHeatmapLegend(docAny, y)
y += 4
// Página 2 — análisis + tabla de costos
docAny.addPage()
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
// ── Página 3 (portrait): Análisis + tabla + metodología ──────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
y = drawAnalysisSection(docAny, result, currency, y)
y += 6
@@ -127,14 +129,15 @@ async function exportScenarioPdf(
columnStyles: { 0: { cellWidth: 48 } },
})
// Página 3 — nota metodológica
docAny.addPage()
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
// ── Página 4 (portrait): Nota metodológica ────────────────────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
drawMethodologySection(docAny, process, y)
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
// ─── ROI — 5 páginas (Etapa 6) ───────────────────────────────────────────────
async function exportRoiPdf(
doc: any,
@@ -166,14 +169,12 @@ async function exportRoiPdf(
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 })
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
// ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
doc.addPage()
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
@@ -206,6 +207,30 @@ async function exportRoiPdf(
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 })
drawRoiMethodologySection(doc, process, y)
// ── Página 5: Trayectoria del ahorro acumulado (gráfico SVG nativo) ──────────
doc.addPage()
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 5 })
// Título y subtítulo de la página
doc.setFont('helvetica', 'bold')
doc.setFontSize(11)
doc.setTextColor(146, 64, 14) // #92400E naranja oscuro
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', PDF.margin, y)
y += 6
const subtitleText = roi.breaksEvenInHorizon
? `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback se alcanza en ${formatPayback(roi.paybackMonths)}.`
: `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback no se alcanza en el horizonte analizado.`
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate500)
const subtitleLines = doc.splitTextToSize(subtitleText, PDF.contentW)
doc.text(subtitleLines, PDF.margin, y)
y += subtitleLines.length * 4.5 + 6
drawTrajectoryChart(doc, roi, process, currency, PDF.margin, PDF.contentW, y)
applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}

View File

@@ -1,9 +1,421 @@
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent, formatSimulationTimestamp } from '@/lib/format'
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
import { PDF, type DocLike } from './pdf-sections'
import { PDF_COLORS } from './pdf-sections-shared'
// ─── Colores locales de página 1 ROI ─────────────────────────────────────────
const C = {
heroOrange: [245, 152, 69] as [number, number, number], // #F59845
heroMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F
orangeLight: [254, 243, 226] as [number, number, number], // #FEF3E2
orangeLighter: [255, 248, 237] as [number, number, number], // #FFF8ED
orangeBar: [254, 215, 170] as [number, number, number], // #FED7AA
metaBg: [250, 251, 252] as [number, number, number], // #FAFBFC
orangeDark: [180, 83, 9] as [number, number, number], // #B45309
orangeDarker: [146, 64, 14] as [number, number, number], // #92400E
white: [255, 255, 255] as [number, number, number],
}
// ─── Página 1 ROI — diseño completo (Etapa 6) ────────────────────────────────
export function drawRoiPage1(
doc: DocLike,
process: Process,
roi: RoiResult,
simulatedAt: number,
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
currency: string
): void {
const pageW = doc.internal.pageSize.getWidth()
const m = 20 // margin mm
const cW = pageW - 2 * m // content width
let y = m
y = _drawDenseHeader(doc, pageW, m, cW, y)
y = _drawMetadataStrip(doc, process, simulatedAt, m, cW, y)
y = _drawNarrativeBanner(doc, process, roi, currency, m, cW, y)
y = _drawHeroCard(doc, roi, process, currency, m, cW, y)
y = _drawSecondaryGrid(doc, roi, process, currency, m, cW, y)
_drawTop3Activities(doc, perActivityActual, perActivityAutomated, currency, m, cW, y)
}
// ── 5.1 Header denso de página 1 ─────────────────────────────────────────────
function _drawDenseHeader(
doc: DocLike, pageW: number, m: number, cW: number, y: number
): number {
const right = pageW - m
// Izquierda: "InQ ROI" 22pt bold naranja
doc.setFont('helvetica', 'bold')
doc.setFontSize(22)
doc.setTextColor(...C.heroOrange)
doc.text('InQ ROI', m, y)
// Derecha: "UN PRODUCTO DE" 9pt slate-400
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('UN PRODUCTO DE', right, y, { align: 'right' } as Record<string, unknown>)
y += 9
// Izquierda: subtítulo 9pt slate-400 (margin-top 4mm ya incluido en line-height)
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('ANÁLISIS DE RETORNO DE INVERSIÓN', m, y)
// Derecha: "InQ" 16pt bold slate-600
doc.setFont('helvetica', 'bold')
doc.setFontSize(16)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text('InQ', right, y, { align: 'right' } as Record<string, unknown>)
y += 6
// Derecha: "InQuality" 9pt slate-500
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('InQuality', right, y, { align: 'right' } as Record<string, unknown>)
y += 10
// Border-bottom naranja 0.8mm
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.8)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 10 // margin-bottom below border
return y
}
// ── 5.2 Strip de metadata ─────────────────────────────────────────────────────
function _drawMetadataStrip(
doc: DocLike, process: Process, simulatedAt: number,
m: number, cW: number, y: number
): number {
const h = 16, r = 2
doc.setFillColor(...C.metaBg)
doc.roundedRect(m, y, cW, h, r, r, 'F')
const { date } = formatSimulationTimestamp(simulatedAt)
const col2 = m + cW * 0.5
const col3 = m + cW * 0.75
const cells = [
{ label: 'PROCESO', value: process.name, x: m + 4 },
{ label: 'CLIENTE', value: process.clientName || '—', x: col2 + 4 },
{ label: 'FECHA', value: date, x: col3 + 4 },
]
for (const cell of cells) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(cell.label, cell.x, y + 5)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
const truncated = cell.value.length > 28 ? cell.value.slice(0, 26) + '…' : cell.value
doc.text(truncated, cell.x, y + 12)
}
return y + h + 6
}
// ── 5.3 Banner narrativo ──────────────────────────────────────────────────────
function _drawNarrativeBanner(
doc: DocLike, process: Process, roi: RoiResult, currency: string,
m: number, cW: number, y: number
): number {
const padH = 5, padV = 8, borderW = 3
const investStr = process.automationInvestment > 0
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
: 'inversión aún no configurada'
const line1 = `Análisis de retorno de inversión para automatización del proceso` +
(process.clientName ? ` del cliente "${process.clientName}"` : '') +
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
` horizonte de ${process.analysisHorizonYears} años e ${investStr}.`
const resultStr = roi.savingsPerExecution > 0
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución` +
` (${formatPercent(roi.savingsPerExecutionPercent)} de reducción).` +
(Number.isFinite(roi.paybackMonths)
? ` La inversión se recupera en ${formatPayback(roi.paybackMonths)}, ` +
(roi.breaksEvenInHorizon ? 'dentro del horizonte de análisis.' : 'fuera del horizonte analizado.')
: ' Sin ahorro neto con los datos actuales.')
: 'El escenario automatizado no genera ahorro con los datos actuales.'
doc.setFontSize(9)
const l1 = doc.splitTextToSize(line1, cW - padH * 2 - borderW - 4)
const l2 = doc.splitTextToSize(resultStr, cW - padH * 2 - borderW - 4)
const totalLines = l1.length + 1 + l2.length
const textH = totalLines * 4.2
const boxH = textH + padV * 2
// Fondo naranja ligero
doc.setFillColor(...C.orangeLighter)
doc.setDrawColor(...C.orangeLighter)
doc.rect(m + borderW, y, cW - borderW, boxH, 'F')
// Border-left naranja
doc.setFillColor(...C.heroOrange)
doc.rect(m, y, borderW, boxH, 'F')
const tx = m + borderW + padH
let ty = y + padV
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text(l1, tx, ty)
ty += l1.length * 4.2 + 3
doc.text(l2, tx, ty)
return y + boxH + 8
}
// ── 5.4 KPI Hero con gradiente ────────────────────────────────────────────────
function _createGradientDataUrl(wMm: number, hMm: number): string | null {
if (typeof document === 'undefined') return null
try {
const scale = 3
const w = Math.round(wMm * scale)
const h = Math.round(hMm * scale)
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return null
const grad = ctx.createLinearGradient(0, 0, w, h) // 135deg approx
grad.addColorStop(0, '#F59845')
grad.addColorStop(1, '#ED3E8F')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
return canvas.toDataURL('image/png')
} catch {
return null
}
}
function _drawGradientFallback(
doc: DocLike, x: number, y: number, w: number, h: number
): void {
const steps = 50
const segW = w / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
const r = Math.round(245 + (237 - 245) * t)
const g = Math.round(152 + (62 - 152) * t)
const b = Math.round(69 + (143 - 69) * t)
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, y, segW + 0.1, h, 'F')
}
}
function _drawHeroCard(
doc: DocLike, roi: RoiResult, process: Process, currency: string,
m: number, cW: number, y: number
): number {
const heroH = 46
// Gradiente de fondo
const dataUrl = _createGradientDataUrl(cW, heroH)
if (dataUrl) {
doc.addImage(dataUrl, 'PNG', m, y, cW, heroH)
} else {
_drawGradientFallback(doc, m, y, cW, heroH)
}
// Esquinas redondeadas — simular con rect blanco en las esquinas (Nivel 1)
// jsPDF no soporta clip en addImage, así que el gradiente queda sin border-radius.
// Aceptable para esta etapa.
// Texto sobre el gradiente
const heroX = m + 14
let ty = y + 12
// Label superior
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text(`AHORRO PROYECTADO A ${process.analysisHorizonYears} AÑOS`, heroX, ty)
ty += 8
// Cifra principal 28pt
doc.setFont('helvetica', 'bold')
doc.setFontSize(28)
doc.setTextColor(...C.white)
doc.text(formatCurrency(roi.cumulativeSavingsHorizon, currency), heroX, ty)
ty += 9
// Subtítulo
const multiple = process.automationInvestment > 0 && Number.isFinite(roi.roiAnnualPercent)
? `neto de inversión · ${(roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)}× retorno`
: 'neto de inversión'
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text(multiple, heroX, ty)
return y + heroH + 8
}
// ── 5.5 Grid 2x2 KPIs secundarios ────────────────────────────────────────────
function _drawSecondaryGrid(
doc: DocLike, roi: RoiResult, process: Process, currency: string,
m: number, cW: number, y: number
): number {
const cardW = (cW - 4) / 2
const cardH = 24
const gapX = 4, gapY = 4
const r = 2
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !Number.isFinite(roi.roiAnnualPercent) ? 'sin inv.' : `${roi.roiAnnualPercent.toFixed(0)}%`
const savingsPct = formatPercent(roi.savingsPerExecutionPercent)
const cards = [
{
label: 'AHORRO POR EJECUCIÓN',
value: formatCurrency(roi.savingsPerExecution, currency),
delta: `-${savingsPct} vs. actual`,
col: 0, row: 0,
},
{
label: 'AHORRO ANUAL',
value: formatCurrency(roi.annualSavings, currency),
delta: `× ${process.analysisHorizonYears} años`,
col: 1, row: 0,
},
{
label: 'PAYBACK',
value: paybackStr,
delta: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte',
col: 0, row: 1,
},
{
label: 'ROI ANUAL',
value: roiStr,
delta: 'retorno anual',
col: 1, row: 1,
},
]
let maxY = y
for (const card of cards) {
const cx = m + card.col * (cardW + gapX)
const cy = y + card.row * (cardH + gapY)
doc.setFillColor(...C.orangeLight)
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
// Label
doc.setFont('helvetica', 'bold')
doc.setFontSize(6.5)
doc.setTextColor(...C.orangeDarker)
doc.text(card.label, cx + 5, cy + 6)
// Valor
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(card.value, cx + 5, cy + 14)
// Delta
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...C.orangeDark)
doc.text(card.delta, cx + 5, cy + 20)
maxY = Math.max(maxY, cy + cardH)
}
return maxY + 8
}
// ── 5.6 Top 3 actividades por ahorro ─────────────────────────────────────────
function _drawTop3Activities(
doc: DocLike,
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
currency: string,
m: number, cW: number, y: number
): void {
const autoMap = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const top3 = perActivityActual
.map((a) => ({
name: a.activityName,
savings: a.expectedTotalCost - (autoMap.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost),
}))
.filter((s) => s.savings > 0)
.sort((a, b) => b.savings - a.savings)
.slice(0, 3)
// Título
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('TOP 3 ACTIVIDADES POR AHORRO', m, y)
y += 7
if (top3.length === 0) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin actividades con ahorro positivo con los datos actuales.', m, y)
return
}
const maxSavings = top3[0].savings
const totalSavings = top3.reduce((s, a) => s + a.savings, 0)
const nameColW = cW * 0.38
const barColW = cW * 0.38
const valColW = cW * 0.24
const barH = 5
for (const act of top3) {
const pct = totalSavings > 0 ? (act.savings / totalSavings * 100).toFixed(0) : '0'
const barFill = maxSavings > 0 ? (act.savings / maxSavings) * barColW : 0
// Nombre (truncado a 30 chars)
const name = act.name.length > 30 ? act.name.slice(0, 28) + '…' : act.name
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(name, m, y + barH - 1)
// Barra de fondo
doc.setFillColor(...C.orangeBar)
doc.roundedRect(m + nameColW, y, barColW, barH, 1, 1, 'F')
// Barra de fill
if (barFill > 0) {
doc.setFillColor(...C.heroOrange)
doc.roundedRect(m + nameColW, y, barFill, barH, 1, 1, 'F')
}
// Valor + porcentaje
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.setTextColor(...C.orangeDark)
const valStr = `${formatCurrency(act.savings, currency)} (${pct}%)`
doc.text(valStr, m + nameColW + barColW + valColW, y + barH - 1, { align: 'right' } as Record<string, unknown>)
y += barH + 5
}
}
const ROI_HIGH_THRESHOLD = 1000
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
@@ -178,14 +590,22 @@ export function buildComparisonTableConfig(
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
const nonAutoText = [148, 163, 184] // slate-400, más tenue
const savingsCell = isAutomatable
? { content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } }
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
const savingsPctCell = isAutomatable
? { content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } }
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
return [
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const } },
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
{ content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } },
{ content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } },
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill } },
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
savingsCell,
savingsPctCell,
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
]
})
@@ -194,8 +614,15 @@ export function buildComparisonTableConfig(
body,
startY,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 },
headStyles: {
fillColor: [255, 248, 237], // #FFF8ED naranja claro
textColor: [146, 64, 14], // #92400E naranja oscuro
fontStyle: 'bold',
fontSize: 8,
lineColor: [245, 152, 69], // #F59845 border naranja
lineWidth: 0.5,
},
columnStyles: { 0: { cellWidth: 42 } },
}
}
@@ -213,19 +640,28 @@ export function drawRoiAnalysisPage(
startY: number
): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
// Título de página
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis del ahorro', PDF.margin, y)
y += 6
doc.setFontSize(11)
doc.setTextColor(...C.orangeDarker)
doc.text('ANÁLISIS DEL AHORRO', m, y)
y += 5
doc.setDrawColor(...PDF.slate200)
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 6
y += 7
// ── COMPOSICIÓN DEL AHORRO ────────────────────────────────────────────────
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...C.orangeDarker)
doc.text('COMPOSICIÓN DEL AHORRO', m, y)
y += 7
// ── Composición del ahorro ──────────────────────────────────────────────────
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const savingsPerAct = perActivityActual
.map((a) => {
@@ -235,14 +671,9 @@ export function drawRoiAnalysisPage(
.filter((s) => s.savings > 0)
.sort((a, b) => b.savings - a.savings)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate700)
doc.text('Composición del ahorro', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
if (savingsPerAct.length > 0) {
const top3 = savingsPerAct.slice(0, 3)
@@ -251,55 +682,57 @@ export function drawRoiAnalysisPage(
const top3Pct = totalSavingsAll > 0 ? (top3TotalSavings / totalSavingsAll * 100).toFixed(0) : '0'
const compositionText = `El ${top3Pct}% del ahorro total proviene de las primeras ${Math.min(3, savingsPerAct.length)} actividades automatizadas:`
const compositionLines = doc.splitTextToSize(compositionText, PDF.contentW)
doc.text(compositionLines, PDF.margin, y)
y += compositionLines.length * 4.5 + 2
const compositionLines = doc.splitTextToSize(compositionText, cW)
doc.text(compositionLines, m, y)
y += compositionLines.length * 4.5 + 4
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
for (const act of top3) {
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
doc.text(` ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, PDF.margin, y)
doc.text(` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, m, y)
y += 4.5
}
} else {
doc.setTextColor(...PDF.slate500)
doc.text('Sin actividades con ahorro positivo en el escenario actual.', PDF.margin, y)
doc.setTextColor(...PDF.slate700)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin actividades con ahorro positivo en el escenario actual.', m, y)
y += 5
}
y += 4
y += 6
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
// ── TRAYECTORIA DEL AHORRO ACUMULADO (texto narrativo) ───────────────────
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.text('Trayectoria del ahorro acumulado', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(...C.orangeDarker)
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', m, y)
y += 7
let trajectoryText: string
if (process.automationInvestment > 0 && Number.isFinite(roi.paybackMonths)) {
const recoveryStr = roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const multiple = process.automationInvestment > 0
? (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)
: '∞'
const multiple = (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)
trajectoryText =
`La inversión de ${formatCurrency(process.automationInvestment, currency)} se recupera en ${recoveryStr}. ` +
`A ${process.analysisHorizonYears} años, el ahorro acumulado proyectado es ` +
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial.`
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial. ` +
`Ver gráfico en página 5.`
} else if (process.automationInvestment === 0) {
trajectoryText =
`Sin inversión configurada — el ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años.`
`Sin inversión configurada. El ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años. ` +
`Ver gráfico en página 5.`
} else {
trajectoryText = 'Sin ahorro proyectado con los datos actuales. Revisá los costos automatizados configurados.'
}
const trajectoryLines = doc.splitTextToSize(trajectoryText, PDF.contentW)
doc.text(trajectoryLines, PDF.margin, y)
y += trajectoryLines.length * 4.5 + 6
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
const trajectoryLines = doc.splitTextToSize(trajectoryText, cW)
doc.text(trajectoryLines, m, y)
y += trajectoryLines.length * 4.5 + 8
// ── Transparencia metodológica (si aplica) ────────────────────────────────
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
@@ -324,8 +757,9 @@ export function drawTransparencyBlock(
const boxX = PDF.margin
const boxW = PDF.contentW
const padH = 5, padV = 6
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
// ── Paso 1: recopilar contenido para calcular altura ─────────────────────
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
const blocks: TextBlock[] = []
@@ -333,121 +767,310 @@ export function drawTransparencyBlock(
if (zeroCost.length > 0) {
const msg = zeroCost.length === 1
? `1 actividad automatizable sin costo configurado: "${zeroCost[0].name || zeroCost[0].bpmnElementId}". El ahorro puede estar inflado artificialmente.`
? `Actividad "${zeroCost[0].name || zeroCost[0].bpmnElementId}" automatizable sin costo configurado. El ahorro puede estar inflado artificialmente.`
: `${zeroCost.length} actividades sin costo automatizado: ${zeroCost.map((a) => a.name || a.bpmnElementId).join(', ')}. El ahorro puede estar inflado.`
blocks.push({ text: doc.splitTextToSize(msg, boxW - 8), fontSize: 8 })
blocks.push({ text: doc.splitTextToSize(msg, boxW - padH * 2), fontSize: 8.5 })
}
if (investmentZero) {
blocks.push({
text: doc.splitTextToSize(
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión en el workspace para un análisis realista.',
boxW - 8
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión para un análisis realista.',
boxW - padH * 2
),
fontSize: 8,
fontSize: 8.5,
})
}
if (roiHigh) {
blocks.push({
text: doc.splitTextToSize(
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
boxW - 8
`ROI inusualmente alto: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente.`,
boxW - padH * 2
),
fontSize: 8,
fontSize: 8.5,
})
}
// Calcular altura total del bloque
let height = 6 // padding top
// Calcular altura total
let height = padV
for (const b of blocks) {
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
height += b.text.length * (b.bold ? 5 : 4.2) + 3
}
height += 3 // padding bottom
height += padV
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
doc.setFillColor(255, 251, 235) // amber-50
doc.setDrawColor(253, 230, 138) // amber-200
doc.setLineWidth(0.3)
// ── Paso 2: fondo + borde ─────────────────────────────────────────────────
doc.setFillColor(255, 251, 235) // #FFFBEB warning-bg
doc.setDrawColor(245, 158, 11) // #F59E0B warning-border
doc.setLineWidth(0.4)
doc.rect(boxX, startY, boxW, height, 'FD')
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
let y = startY + 6
// ── Paso 3: texto ─────────────────────────────────────────────────────────
let y = startY + padV
for (const b of blocks) {
if (b.bold) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(b.fontSize)
doc.setTextColor(146, 64, 14) // amber-800
doc.setTextColor(...C.orangeDarker)
} else {
doc.setFont('helvetica', 'normal')
doc.setFontSize(b.fontSize)
doc.setTextColor(180, 83, 9) // amber-700
doc.setTextColor(120, 53, 15) // #78350F
}
doc.text(b.text, boxX + 4, y)
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
doc.text(b.text, boxX + padH, y)
y += b.text.length * (b.bold ? 5 : 4.2) + 3
}
return startY + height + 4
return startY + height + 6
}
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
// ─── Página 4: nota metodológica con identidad InQ ───────────────────────────
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
const boxPad = 6
// Título uppercase naranja oscuro
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
doc.setFontSize(11)
doc.setTextColor(...C.orangeDarker)
doc.text('NOTA METODOLOGICA - ANALISIS DE ROI', m, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 8
// Caja contenedora: fondo slate-50 + border-left #94A3B8
const boxX = m
const boxW = cW
const contentX = m + boxPad + 3 // +3 para el border-left visual
const sections = [
{
title: 'Costo por ejecución actual:',
body: `Σ(actividades) [costo_fijo × prob_ejecución] × (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagación hacia adelante en el grafo BPMN.`,
title: 'Costo por ejecucion actual:',
body: `SUM(actividades) [costo_fijo x prob_ejecucion] x (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagacion hacia adelante en el grafo BPMN.`,
},
{
title: 'Costo por ejecución automatizado:',
title: 'Costo por ejecucion automatizado:',
body: 'Mismo modelo, pero las actividades marcadas como automatizables usan su costo automatizado configurado. Las no marcadas mantienen el costo actual.',
},
{
title: 'Ahorro anual:',
body: `(Costo actual Costo automatizado) × ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`,
body: `(Costo actual - Costo automatizado) x ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`,
},
{
title: 'Payback (meses):',
body: `(Inversión / Ahorro anual) × 12. Nominal simple — no incluye tasa de descuento ni inflación.`,
body: '(Inversion / Ahorro anual) x 12. Nominal simple, sin tasa de descuento ni inflacion.',
},
{
title: 'Ahorro acumulado:',
body: `Ahorro anual × ${process.analysisHorizonYears} años Inversión inicial. Nominal, sin VP.`,
body: `Ahorro anual x ${process.analysisHorizonYears} años - Inversion inicial. Nominal, sin VP.`,
},
{
title: 'ROI anualizado:',
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
body: '(Ahorro anual / Inversion) x 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
},
]
// Pre-calcular altura total para dibujar el fondo primero
let boxHeight = boxPad * 2
for (const s of sections) {
doc.setFont('helvetica', 'bold')
doc.text(s.title, PDF.margin, y)
y += 4
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(s.body, PDF.contentW - 4)
doc.text(lines, PDF.margin + 4, y)
y += lines.length * 3.8 + 2
const bodyLines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
boxHeight += 5 + bodyLines.length * 4.2 + 5
}
return y
doc.setFillColor(248, 250, 252) // #F8FAFC slate-50
doc.rect(boxX, y, boxW, boxHeight, 'F')
doc.setFillColor(...C.metaBg)
doc.rect(boxX, y, 2, boxHeight, 'F') // border-left visual: 2mm rect gris
let ty = y + boxPad
for (const s of sections) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(s.title, contentX, ty)
ty += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(...PDF_COLORS.textSecondary)
const lines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
doc.text(lines, contentX, ty)
ty += lines.length * 4.2 + 5
}
return y + boxHeight + 4
}
// ─── Página 5: Trayectoria del ahorro acumulado (SVG nativo) ─────────────────
export function drawTrajectoryChart(
doc: DocLike,
roi: RoiResult,
process: Process,
currency: string,
m: number,
cW: number,
startY: number
): number {
const horizonMonths = process.analysisHorizonYears * 12
const investment = process.automationInvestment
const annualSavings = roi.annualSavings
const finalCumulative = roi.cumulativeSavingsHorizon
// Función de la trayectoria: lineal desde -investment hasta finalCumulative
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
// Área del gráfico
const labelColW = 20 // mm para etiquetas eje Y
const chartLeft = m + labelColW
const chartRight = m + cW
const chartWidth = chartRight - chartLeft
const chartTop = startY
const chartHeight = 90 // mm — 60-70% del alto disponible
const chartBottom = chartTop + chartHeight
// Rango de valores Y
const yMin = Math.min(-investment, 0)
const yMax = Math.max(finalCumulative, 0)
const yRange = yMax - yMin || 1
// Conversiones coordenadas
const toX = (month: number) => chartLeft + (month / (horizonMonths || 1)) * chartWidth
const toY = (value: number) => chartBottom - ((value - yMin) / yRange) * chartHeight
const zeroY = toY(0)
const paybackX = roi.breaksEvenInHorizon && roi.paybackMonths > 0
? toX(roi.paybackMonths)
: chartLeft // sin payback: toda el área es negativa o positiva
// 1. Fondo del gráfico (slate-50)
doc.setFillColor(248, 250, 252)
doc.rect(chartLeft, chartTop, chartWidth, chartHeight, 'F')
// 2. Área negativa (rojo claro) y positiva (naranja claro) — 100 tiras verticales
const strips = 100
for (let i = 0; i < strips; i++) {
const m1 = (i / strips) * horizonMonths
const m2 = ((i + 1) / strips) * horizonMonths
const mMid = (m1 + m2) / 2
const val = valueAt(mMid)
const x1 = toX(m1)
const x2 = toX(m2)
const cy = toY(val)
const yTop = Math.max(Math.min(cy, zeroY), chartTop)
const yBot = Math.min(Math.max(cy, zeroY), chartBottom)
const h = yBot - yTop
if (h > 0.1) {
if (val < 0) {
doc.setFillColor(254, 226, 226) // #FEE2E2 rojo claro
} else {
doc.setFillColor(254, 215, 170) // #FED7AA naranja claro
}
doc.rect(x1, yTop, Math.max(x2 - x1, 0.1), h, 'F')
}
}
// 3. Línea principal naranja (segmentos mensuales)
doc.setDrawColor(245, 152, 69) // #F59845
doc.setLineWidth(0.5)
for (let mo = 0; mo < horizonMonths; mo++) {
doc.line(toX(mo), toY(valueAt(mo)), toX(mo + 1), toY(valueAt(mo + 1)))
}
// 4. Ejes
doc.setDrawColor(148, 163, 184) // slate-400
doc.setLineWidth(0.3)
doc.line(chartLeft, chartTop, chartLeft, chartBottom) // eje Y
doc.line(chartLeft, zeroY, chartRight, zeroY) // eje X (línea cero)
// 5. Labels eje X: Año 0, 1, 2, ... N
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(100, 116, 139) // slate-500
for (let yr = 0; yr <= process.analysisHorizonYears; yr++) {
const lx = toX(yr * 12)
doc.text(`Año ${yr}`, lx, chartBottom + 5, { align: 'center' } as Record<string, unknown>)
doc.setDrawColor(200, 200, 200)
doc.setLineWidth(0.2)
doc.line(lx, chartBottom, lx, chartBottom + 2)
}
// 6. Labels eje Y: valor mínimo (abajo), 0 (línea cero), valor máximo (arriba)
doc.setDrawColor(148, 163, 184)
doc.setLineWidth(0.2)
doc.setFont('helvetica', 'normal')
doc.setFontSize(6.5)
doc.setTextColor(100, 116, 139)
// Máximo
doc.text(_fmtAxisVal(yMax), chartLeft - 2, chartTop + 4, { align: 'right' } as Record<string, unknown>)
// Cero (solo si hay rango negativo)
if (yMin < 0) {
doc.text('0', chartLeft - 2, zeroY + 1, { align: 'right' } as Record<string, unknown>)
// Mínimo
doc.text(_fmtAxisVal(yMin), chartLeft - 2, chartBottom, { align: 'right' } as Record<string, unknown>)
}
// 7. Marker de payback (línea vertical punteada, solo si hay payback en horizonte)
if (roi.breaksEvenInHorizon && roi.paybackMonths > 0 && roi.paybackMonths <= horizonMonths) {
doc.setDrawColor(180, 83, 9) // #B45309
doc.setLineWidth(0.4)
const dashLen = 2.5, gapLen = 1.8
let dy = chartTop
while (dy < chartBottom) {
doc.line(paybackX, dy, paybackX, Math.min(dy + dashLen, chartBottom))
dy += dashLen + gapLen
}
// Label del payback (offset a la derecha para no tapar el eje Y)
const labelX = Math.min(paybackX + 3, chartLeft + chartWidth - 35)
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
doc.setTextColor(180, 83, 9)
doc.text(`Payback: ${formatPayback(roi.paybackMonths)}`, labelX, Math.max(zeroY - 3, chartTop + 5))
}
doc.setLineWidth(0.2)
// 8. Anotaciones al pie del gráfico
const annY = chartBottom + 12
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textSecondary)
const annotations: string[] = []
if (investment > 0) {
annotations.push(`Inversión inicial: ${formatCurrency(investment, currency)}`)
}
annotations.push(`Ahorro acumulado al año ${process.analysisHorizonYears}: ${formatCurrency(finalCumulative, currency)}`)
if (investment > 0 && finalCumulative > 0) {
const mult = (finalCumulative / investment + 1).toFixed(1)
annotations.push(`Multiplicador de retorno: ${mult}x la inversión`)
}
const annotText = annotations.join(' | ')
const annotLines = doc.splitTextToSize(annotText, cW)
doc.text(annotLines, m, annY)
return annY + annotLines.length * 4.5 + 4
}
// ─── Helpers locales ──────────────────────────────────────────────────────────
function _fmtAxisVal(n: number): string {
const abs = Math.abs(n)
const sign = n < 0 ? '-' : ''
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}K`
return `${sign}${abs.toFixed(0)}`
}
function fmtNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}

View File

@@ -111,12 +111,16 @@ export function drawHeatmapImage(
): number {
if (!imageDataUrl) return startY
// Calcular altura proporcional (asumiendo aspect ratio ~2:1 del canvas BPMN)
const imgW = PDF.contentW
const imgH = Math.min(imgW / 2.2, 80) // máximo 80mm de alto
// Usar dimensiones dinámicas — funciona en portrait (210mm) y landscape (297mm)
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const imgW = pageW - 2 * PDF.margin
// Altura: proporción ~2.2:1 del canvas BPMN, limitada por espacio disponible hasta el footer
const maxH = pageH - startY - PDF.margin - 20 // 20mm reservado para footer
const imgH = Math.min(imgW / 2.2, maxH)
const x = PDF.margin
if (startY + imgH > PDF.pageH - PDF.margin - 10) {
if (startY + imgH > pageH - PDF.margin - 10) {
doc.addPage()
startY = PDF.margin
}
@@ -131,7 +135,8 @@ export function drawHeatmapImage(
}
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
const x = PDF.margin, barW = PDF.contentW, barH = 4
const pageW = doc.internal.pageSize.getWidth()
const x = PDF.margin, barW = pageW - 2 * PDF.margin, barH = 4
const labelY = startY + barH + 3.5
doc.setFontSize(7)