feat(sprint-5/etapa-3): heatmap gradiente 4 stops + modo percentil + localStorage + fix zoom

- colors.ts: gradiente continuo verde→amarillo→naranja→rojo (interpolación N-stops genérica)
- Renombra modos: relative→percentile (rank-based), absolute→minmax
- Hook useHeatmapMode(): persiste selección en localStorage con clave inq-roi:heatmap-mode
- HeatmapLegend: barra de 4 stops, labels P0/P50/P100 en modo percentil
- HeatmapModeToggle: etiquetas "Percentil" / "Min-max"
- pdf-sections.ts: leyenda PDF con 4 stops interpolados
- BpmnCanvas: controles de zoom movidos a esquina inferior izquierda (evita logo bpmn.io)
- Tests: 528 unitarios pasan; E2E recalibrados con vecino más cercano (distancia euclidiana)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 18:28:10 -03:00
parent 889929329e
commit a65bec45c3
15 changed files with 593 additions and 176 deletions

View File

@@ -28,6 +28,7 @@ export const PDF = {
slate50: [248, 250, 252] as [number, number, number],
heatLow: [16, 185, 129] as [number, number, number],
heatMid: [245, 158, 11] as [number, number, number],
heatHighMid: [249, 115, 22] as [number, number, number],
heatHigh: [239, 68, 68] as [number, number, number],
}
@@ -155,22 +156,31 @@ export function drawHeatmapLegend(doc: DocLike, startY: number): number {
doc.setTextColor(...PDF.heatLow)
doc.text('Bajo costo', x, labelY)
// Barra de gradiente: dibujada como N segmentos de colores
// Barra de gradiente: dibujada como N segmentos de colores, 4 stops
// (mismo gradiente que src/lib/colors.ts — verde → amarillo → naranja → rojo)
const heatStops: Array<[number, [number, number, number]]> = [
[0.00, PDF.heatLow],
[0.33, PDF.heatMid],
[0.67, PDF.heatHighMid],
[1.00, PDF.heatHigh],
]
const steps = 40
const segW = barW / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
let r: number, g: number, b: number
if (t <= 0.5) {
const lt = t * 2
r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt)
g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt)
b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt)
} else {
const lt = (t - 0.5) * 2
r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt)
g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt)
b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt)
let r = heatStops[heatStops.length - 1][1][0]
let g = heatStops[heatStops.length - 1][1][1]
let b = heatStops[heatStops.length - 1][1][2]
for (let s = 0; s < heatStops.length - 1; s++) {
const [t0, c0] = heatStops[s]
const [t1, c1] = heatStops[s + 1]
if (t <= t1) {
const lt = t1 === t0 ? 0 : (t - t0) / (t1 - t0)
r = Math.round(c0[0] + (c1[0] - c0[0]) * lt)
g = Math.round(c0[1] + (c1[1] - c0[1]) * lt)
b = Math.round(c0[2] + (c1[2] - c0[2]) * lt)
break
}
}
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F')