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:
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,73 +1,66 @@
|
||||
// Paleta heatmap: verde → amarillo → rojo
|
||||
// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.*
|
||||
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
|
||||
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
||||
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
|
||||
|
||||
// Paleta heatmap: verde → amarillo → naranja → rojo (4 stops, Sprint 5 Etapa 3)
|
||||
// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.* y con
|
||||
// drawHeatmapLegend() en src/lib/export/pdf-sections.ts
|
||||
export const HEATMAP_LOW_HEX = '#10b981'
|
||||
export const HEATMAP_MID_HEX = '#f59e0b'
|
||||
export const HEATMAP_HIGH_MID_HEX = '#f97316'
|
||||
export const HEATMAP_HIGH_HEX = '#ef4444'
|
||||
|
||||
const HEATMAP_STOPS: Array<[number, string]> = [
|
||||
[0.00, HEATMAP_LOW_HEX],
|
||||
[0.33, HEATMAP_MID_HEX],
|
||||
[0.67, HEATMAP_HIGH_MID_HEX],
|
||||
[1.00, HEATMAP_HIGH_HEX],
|
||||
]
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const v = hex.replace('#', '')
|
||||
return {
|
||||
r: parseInt(v.slice(0, 2), 16),
|
||||
g: parseInt(v.slice(2, 4), 16),
|
||||
b: parseInt(v.slice(4, 6), 16),
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t)
|
||||
}
|
||||
|
||||
// t en [0, 1]
|
||||
export function heatmapColor(t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
function lerpColor(c0: string, c1: string, t: number): { r: number; g: number; b: number } {
|
||||
const rgb0 = hexToRgb(c0)
|
||||
const rgb1 = hexToRgb(c1)
|
||||
return { r: lerp(rgb0.r, rgb1.r, t), g: lerp(rgb0.g, rgb1.g, t), b: lerp(rgb0.b, rgb1.b, t) }
|
||||
}
|
||||
|
||||
export function heatmapColorHex(t: number): string {
|
||||
// Interpolación genérica sobre N stops [t, color-hex] ordenados ascendentemente por t.
|
||||
// t se clampea a [0, 1] antes de ubicar el segmento correspondiente.
|
||||
function interpolateStops(t: number, stops: Array<[number, string]>): { r: number; g: number; b: number } {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const [t0, c0] = stops[i]
|
||||
const [t1, c1] = stops[i + 1]
|
||||
if (clamped <= t1) {
|
||||
const localT = t1 === t0 ? 0 : (clamped - t0) / (t1 - t0)
|
||||
return lerpColor(c0, c1, localT)
|
||||
}
|
||||
}
|
||||
return hexToRgb(stops[stops.length - 1][1])
|
||||
}
|
||||
|
||||
// t en [0, 1] → color hex del gradiente heatmap (verde → amarillo → naranja → rojo)
|
||||
export function intensityToColor(t: number): string {
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js)
|
||||
export function heatmapColor(t: number): string {
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
// Retorna color con alpha (útil para overlays sobre bpmn-js)
|
||||
export function heatmapColorWithAlpha(t: number, alpha = 0.75): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
@@ -78,7 +71,10 @@ export function getContrastText(t: number): 'white' | 'black' {
|
||||
|
||||
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
|
||||
|
||||
export type HeatmapMode = 'relative' | 'absolute'
|
||||
// percentile: posición relativa por rango entre todas las actividades del proceso (no sufre
|
||||
// distorsión por outliers).
|
||||
// minmax: (costo - mínimo) / (máximo - mínimo) — refleja valores absolutos, se comprime con outliers.
|
||||
export type HeatmapMode = 'percentile' | 'minmax'
|
||||
|
||||
export interface ActivityCostData {
|
||||
expectedTotalCost: number
|
||||
@@ -101,9 +97,20 @@ export function hasSignificantCostVariance(activities: ActivityCostData[]): bool
|
||||
return (max - min) / max > VARIANCE_THRESHOLD
|
||||
}
|
||||
|
||||
// Normalización por percentil: posición de `current` en el ranking de costos de `all`.
|
||||
// rank = cantidad de actividades estrictamente más baratas. Ties reciben el mismo rango
|
||||
// (densidad uniforme). No sufre distorsión por outliers, a diferencia de minmax.
|
||||
function percentileNorm(current: ActivityCostData, all: ActivityCostData[]): number {
|
||||
const n = all.length
|
||||
if (n <= 1) return 0.5
|
||||
if (current.expectedTotalCost < 0) return 0 // costo negativo = "más barato posible"
|
||||
|
||||
const currentCost = current.expectedTotalCost
|
||||
const rank = all.filter((a) => a.expectedTotalCost < currentCost).length
|
||||
return rank / (n - 1)
|
||||
}
|
||||
|
||||
// Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo.
|
||||
// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa).
|
||||
// Modo absoluto: normaliza sobre el rango min-max del proceso.
|
||||
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
|
||||
export function computeActivityT(
|
||||
activity: ActivityCostData,
|
||||
@@ -112,9 +119,8 @@ export function computeActivityT(
|
||||
): number {
|
||||
if (allActivities.length === 0) return 0
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001)
|
||||
return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent))
|
||||
if (mode === 'percentile') {
|
||||
return percentileNorm(activity, allActivities)
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
@@ -132,19 +138,21 @@ export function activityColor(
|
||||
mode: HeatmapMode
|
||||
): string {
|
||||
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
|
||||
return heatmapColorHex(computeActivityT(activity, allActivities, mode))
|
||||
return intensityToColor(computeActivityT(activity, allActivities, mode))
|
||||
}
|
||||
|
||||
// Valores de los extremos para la leyenda del heatmap
|
||||
// Valores de los extremos para la leyenda del heatmap.
|
||||
// percentile: los extremos del gradiente representan el rango (0 = más barata, 100 = más cara),
|
||||
// no un costo o porcentaje literal — el rank no es lineal respecto al costo.
|
||||
// minmax: los extremos son el costo mínimo y máximo reales del proceso.
|
||||
export function heatmapLegendBounds(
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } {
|
||||
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' }
|
||||
): { min: number; mid: number; max: number; unit: 'currency' | 'rank' } {
|
||||
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
|
||||
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
|
||||
if (mode === 'percentile') {
|
||||
return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
|
||||
@@ -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')
|
||||
|
||||
33
src/lib/hooks/useHeatmapMode.ts
Normal file
33
src/lib/hooks/useHeatmapMode.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import type { HeatmapMode } from '@/lib/colors'
|
||||
|
||||
const STORAGE_KEY = 'inq-roi:heatmap-mode'
|
||||
const DEFAULT_MODE: HeatmapMode = 'percentile'
|
||||
|
||||
function isValidMode(value: string | null): value is HeatmapMode {
|
||||
return value === 'percentile' || value === 'minmax'
|
||||
}
|
||||
|
||||
// Persiste la preferencia de modo de heatmap en localStorage. Nunca confía en el string
|
||||
// crudo leído de localStorage sin validarlo contra los modos válidos.
|
||||
export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] {
|
||||
const [mode, setModeState] = useState<HeatmapMode>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return isValidMode(stored) ? stored : DEFAULT_MODE
|
||||
} catch {
|
||||
return DEFAULT_MODE
|
||||
}
|
||||
})
|
||||
|
||||
function setMode(newMode: HeatmapMode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
} catch {
|
||||
// localStorage no disponible (modo privado, cuota excedida) — continuar sin persistir
|
||||
}
|
||||
setModeState(newMode)
|
||||
}
|
||||
|
||||
return [mode, setMode]
|
||||
}
|
||||
Reference in New Issue
Block a user