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

@@ -1,5 +1,5 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
import { formatCurrency } from '@/lib/format'
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
@@ -12,8 +12,10 @@ interface HeatmapLegendProps {
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
const bounds = heatmapLegendBounds(perActivity, mode)
// En modo percentile los extremos son rango (0 = más barata, 100 = más cara), no costo
// literal — el percentil no es lineal respecto al costo real.
function formatBound(value: number): string {
if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1)
if (bounds.unit === 'rank') return `P${Math.round(value)}`
return formatCurrency(value, currency)
}
@@ -21,7 +23,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
<div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{
background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_HEX})`
background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_MID_HEX}, ${HEATMAP_HIGH_HEX})`
}} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" />

View File

@@ -8,20 +8,20 @@ interface HeatmapModeToggleProps {
}
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
relative: {
label: 'Costo relativo (%)',
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.',
percentile: {
label: 'Percentil',
tip: 'Colorea cada actividad según su posición relativa entre todas las actividades del proceso. No se distorsiona por outliers.',
},
absolute: {
label: 'Costo absoluto',
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.',
minmax: {
label: 'Min-max',
tip: '(costo mínimo) / (máximo mínimo). Refleja valores absolutos, pero se comprime si hay outliers extremos.',
},
}
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
return (
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => {
{(['percentile', 'minmax'] as HeatmapMode[]).map((m) => {
const { label, tip } = MODE_LABELS[m]
return (
<Tooltip key={m}>

View File

@@ -26,7 +26,7 @@ import { TopSavingsChart } from './TopSavingsChart'
import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote'
import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors'
import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode'
import { useSimulationStore } from '@/store/simulation-store'
import { AppHeader } from '@/components/AppHeader'
@@ -36,7 +36,7 @@ export function ReportPage() {
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
const [heatmapMode, setHeatmapMode] = useHeatmapMode()
const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore()
// Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace)

View File

@@ -204,7 +204,7 @@ export function BpmnCanvas({
</button>
{/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ position: 'absolute', bottom: 12, left: 12, zIndex: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button onClick={handleZoomIn} title="Acercar" style={controlButtonStyle}>
<ZoomIn className="h-4 w-4 text-muted-foreground" />
</button>