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

@@ -29,7 +29,11 @@
## Hallazgos resueltos durante el sprint
*(vacío)*
### [RESOLVED] Etapa 2 — Zoom BPMN fix + controles + skeleton
**Estado:** OK formal del director — 2026-06-19.
**Fixes:** fit-viewport `'auto'` agregado (una línea), zoom-in/zoom-out en esquina inferior derecha (Lucide icons, 1.2x factor, límites 0.1x4x), skeleton wired correctamente en WorkspacePage (guard `isLoading` simple sin `!currentProcess`).
**Carry-over a Etapa 3:** controles de zoom solapan el logo bpmn.io (esquina inferior derecha). Fix: mover a esquina inferior izquierda (`left: 12` en lugar de `right: 12`). Una línea CSS.
---

View File

@@ -0,0 +1,250 @@
# Sprint 5 — Etapa 3: Heatmap gradiente continuo + fix posición zoom
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapas 1 y 2 completadas con OK formal. Esta etapa tiene un fix carry-over de Etapa 2 y la mejora principal del heatmap.
---
## Fix 0 — Posición controles de zoom (carry-over Etapa 2)
En `BpmnCanvas.tsx`, los controles de zoom-in/zoom-out quedaron en la esquina inferior derecha (`bottom: 12, right: 12`), solapando el logo `bpmn.io` que bpmn-js renderiza en esa esquina por licencia.
**Fix:** cambiar la posición del grupo de controles de zoom a la esquina inferior izquierda.
```tsx
// Antes:
style={{ position: 'absolute', bottom: 12, right: 12, ... }}
// Después:
style={{ position: 'absolute', bottom: 12, left: 12, ... }}
```
El botón de encuadre (fit-viewport, esquina superior derecha) no se mueve — solo el grupo zoom-in/zoom-out.
---
## Mejora principal — Heatmap gradiente con percentil
### Contexto del sistema actual
El sistema de heatmap ya existe y está centralizado en `src/lib/colors.ts`. Tiene:
- Dos modos: `'relative'` (normalización por máximo de porcentaje) y `'absolute'` (min-max sobre costo)
- Gradiente de 3 stops: verde `#10b981` → ámbar `#f59e0b` → rojo `#ef4444`
- Función central `activityColor()` usada por todos los consumidores
- Compuerta de varianza: si varianza < 5%, todos reciben color neutro `#cbd5e1`
- El toggle de modo ya existe en `ReportPage.tsx` como estado local (no persistente)
El PDF captura el canvas coloreado con `html2canvas`, pero tiene una **leyenda hardcodeada** con los mismos 3 stops en `src/lib/export/pdf-sections.ts` (función `drawHeatmapLegend()`). Hay que mantenerla sincronizada.
### Cambio 1 — Cuarto stop en el gradiente (naranja)
Modificar `colors.ts` para usar 4 stops en lugar de 3:
```
0.00 → #10b981 (verde)
0.33 → #f59e0b (amarillo)
0.67 → #f97316 (naranja) ← nuevo
1.00 → #ef4444 (rojo vivo)
```
La función de interpolación actual usa `lerp` sobre 3 stops. Extenderla para que soporte N stops genéricos:
```ts
function interpolateColor(t: number, stops: [number, string][]): string {
// t está clampeado a [0, 1] antes de entrar
const clamped = Math.max(0, Math.min(1, t))
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 = (clamped - t0) / (t1 - t0)
return lerpColor(c0, c1, localT) // interpolación RGB lineal
}
}
return stops[stops.length - 1][1]
}
const HEATMAP_STOPS: [number, string][] = [
[0.00, '#10b981'],
[0.33, '#f59e0b'],
[0.67, '#f97316'],
[1.00, '#ef4444'],
]
```
Mantener `NEUTRAL_HEATMAP_COLOR = '#cbd5e1'` para el caso de varianza nula.
### Cambio 2 — Modo `'percentile'` reemplaza `'relative'`
**Renombrar** `'relative'``'percentile'` en el tipo `HeatmapMode`. Actualizar todos los usos (buscar con grep).
**Implementar normalización por percentil real** en `computeActivityT()`:
```ts
function percentileNorm(
current: ActivityWithCost,
all: ActivityWithCost[]
): number {
const n = all.length
if (n <= 1) return 0.5
const currentCost = current.expectedTotalCost
// Rango: cantidad de actividades con costo estrictamente menor
const rank = all.filter(a => a.expectedTotalCost < currentCost).length
// Normalizar a [0, 1]: 0 = la más barata, 1 = la más cara
// Ties reciben el mismo rango (densidad uniforme)
return rank / (n - 1)
}
```
**Edge cases obligatorios:**
- `n === 1`: retornar `0.5` (actividad única → color intermedio)
- Todas iguales (`max === min` o todos los ranks son 0): la compuerta de varianza ya maneja esto con `#cbd5e1` — verificar que siga funcionando con percentil
- `expectedTotalCost < 0`: clampear `t` a 0 (costo negativo = "más barato posible")
**Modo `'minmax'`** (renombrado de `'absolute'`): mantener la lógica existente, solo renombrar el string.
### Cambio 3 — Persistencia del modo en localStorage
El toggle hoy es estado local en `ReportPage.tsx` — se pierde al navegar o recargar.
Crear un hook `useHeatmapMode()` en `src/lib/colors.ts` o en `src/lib/hooks/useHeatmapMode.ts`:
```ts
const STORAGE_KEY = 'inq-roi:heatmap-mode'
const DEFAULT_MODE: HeatmapMode = 'percentile'
export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] {
const [mode, setModeState] = useState<HeatmapMode>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
// Validar que el valor guardado sea un modo válido
return (stored === 'percentile' || stored === 'minmax') ? stored : DEFAULT_MODE
} catch {
return DEFAULT_MODE
}
})
const setMode = (newMode: HeatmapMode) => {
try {
localStorage.setItem(STORAGE_KEY, newMode)
} catch {
// localStorage no disponible — continuar sin persistir
}
setModeState(newMode)
}
return [mode, setMode]
}
```
Reemplazar el `useState` local en `ReportPage.tsx` por `useHeatmapMode()`. Si el heatmap también se controla desde el workspace (canvas BPMN), usar el mismo hook allí.
**Nota:** el valor guardado en localStorage debe validarse antes de usarse (el hook ya lo hace con el check de `stored === 'percentile' || stored === 'minmax'`). Nunca confiar en strings crudos de localStorage sin validación.
### Cambio 4 — Actualizar leyenda del PDF
En `src/lib/export/pdf-sections.ts`, la función `drawHeatmapLegend()` tiene los colores hardcodeados en RGB con 3 stops. Actualizar a 4 stops para mantener sincronía visual con la web:
```ts
// Reemplazar los stops hardcodeados por los mismos 4:
const heatStops = [
{ t: 0.00, r: 16, g: 185, b: 129 }, // #10b981 verde
{ t: 0.33, r: 245, g: 158, b: 11 }, // #f59e0b amarillo
{ t: 0.67, r: 249, g: 115, b: 22 }, // #f97316 naranja
{ t: 1.00, r: 239, g: 68, b: 68 }, // #ef4444 rojo
]
```
La lógica de 40 segmentos puede mantenerse — solo actualizan los puntos de referencia.
El PDF siempre usa el modo activo al momento de exportar (lo que está en localStorage). No hace falta cambiar la lógica de exportación — el canvas ya está coloreado antes de que `html2canvas` lo capture.
### Cambio 5 — Toggle en UI
El toggle ya existe en `ReportPage.tsx`. Solo actualizar:
- Strings del label: `'relative'``'percentile'`, `'absolute'``'minmax'`
- Texto visible: "Relativo" → "Percentil", "Absoluto" → "Min-max"
- Conectar al hook `useHeatmapMode()` en lugar del estado local
Si el toggle también existe o debe existir en el workspace (para ver el heatmap en el canvas), agregar el mismo toggle allí usando el mismo hook.
---
## Tests obligatorios
### Tests unitarios en Vitest — `colors.ts`
Agregar o actualizar tests para:
```ts
// Normalización percentil
test('percentile: actividad más barata → t = 0', ...)
test('percentile: actividad más cara → t = 1', ...)
test('percentile: actividades con mismo costo → mismo t', ...)
test('percentile: n=1 → t = 0.5', ...)
// Interpolación de color
test('intensityToColor(0) → #10b981', ...)
test('intensityToColor(1) → #ef4444', ...)
test('intensityToColor(0.5) → color naranja intermedio (entre #f59e0b y #f97316)', ...)
test('intensityToColor(-0.1) → mismo que intensityToColor(0) [clamp]', ...)
test('intensityToColor(1.5) → mismo que intensityToColor(1) [clamp]', ...)
// Compuerta de varianza
test('todas las actividades con mismo costo → color neutro #cbd5e1', ...)
```
### Tests E2E — recalibrar specs de píxeles
Buscar en `tests/e2e/` los specs que verifican colores del canvas BPMN (probablemente `etapa-*.spec.ts` o `heatmap*.spec.ts`). Actualizarlos para los nuevos valores de color del gradiente de 4 stops.
Si un spec verifica que "la actividad más costosa es roja" — sigue siendo válido. Si verifica un color RGB exacto con los 3 stops anteriores — actualizar al nuevo gradiente.
---
## Qué NO entra en esta etapa
- Cambios en el motor de simulación o cálculos de ROI
- Cambios en el esquema de Supabase
- Nuevas columnas en tablas o paneles
- Exportación a Excel
- Cualquier feature fuera del heatmap y el fix de posición zoom
---
## Orden de ejecución
1. Fix 0 — mover controles zoom a `left: 12` en `BpmnCanvas.tsx`
2. Agregar cuarto stop naranja al gradiente en `colors.ts`
3. Implementar `percentileNorm()` y renombrar modos en `colors.ts`
4. Crear `useHeatmapMode()` hook con persistencia localStorage
5. Actualizar `ReportPage.tsx` para usar `useHeatmapMode()` (y workspace si aplica)
6. Actualizar `drawHeatmapLegend()` en `pdf-sections.ts` con 4 stops
7. Escribir/actualizar tests unitarios en Vitest para las funciones de color
8. Buscar y recalibrar specs E2E que verifiquen colores del heatmap
9. `npm run build` — 0 errores TypeScript
10. `npm run lint` — 0 warnings nuevos
11. `npm run test` — 521+ Vitest verdes
12. `npx playwright test` — sin nuevos rojos
13. Commit: `feat(sprint-5/etapa-3): heatmap gradiente 4 stops + percentil + localStorage + fix zoom position`
---
## Criterios de validación
- [ ] Controles de zoom en esquina inferior izquierda — sin solapamiento con logo bpmn.io
- [ ] El heatmap del canvas BPMN muestra transiciones suaves (verde → amarillo → naranja → rojo)
- [ ] Con outlier extremo en modo percentil: las actividades restantes tienen colores variados (no todas verde)
- [ ] Con outlier extremo en modo min-max: las actividades restantes se comprimen hacia el verde
- [ ] Toggle "Percentil / Min-max" visible en el reporte — cambia el heatmap en tiempo real
- [ ] El modo persiste entre recargas (verificar con localStorage en DevTools)
- [ ] PDF: la leyenda del heatmap muestra el gradiente de 4 stops
- [ ] Tests unitarios de `colors.ts` todos verdes
- [ ] E2E specs de heatmap recalibrados y verdes
- [ ] **Validación visual del director** antes del OK formal

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>

View File

@@ -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)

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')

View 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]
}

View File

@@ -59,11 +59,12 @@ export default {
magenta: '#ED3E8F',
},
// ── Heatmap (saturado, calibrado para análisis de píxeles E2E) ──
// DEBE coincidir con HEATMAP_LOW/MID/HIGH en src/lib/colors.ts.
// DEBE coincidir con HEATMAP_STOPS en src/lib/colors.ts (4 stops, Sprint 5 Etapa 3).
// Cambiar solo en conjunto con recalibración de tests E2E.
heatmap: {
low: '#10B981',
mid: '#F59E0B',
highMid: '#F97316',
high: '#EF4444',
},
// ── Semánticos ───────────────────────────────────────────────────

View File

@@ -258,32 +258,56 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
/**
* Cuenta píxeles por categoría de color en un buffer JPEG.
* Umbrales calibrados para los colores del heatmap con opacity 0.82 sobre #f8fafc.
* Recalibrado Sprint 5 Etapa 3 — gradiente de 4 stops (verde→amarillo→naranja→rojo).
* Colores de referencia con opacity 0.82 sobre fondo #f8fafc (blend = bg·0.18 + color·0.82):
*
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 152) → G > R·1.4, G > 120
* Ámbar (#f59e0b + bg) → RGB ≈ (246, 175, 54) → R > 180, G > 120, B < 120, R/G < 1.8
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101) → R > G·1.8, R > B·1.8, R > 180
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 151)
* Amarillo(#f59e0b + bg) → RGB ≈ (246, 175, 54)
* Naranja (#f97316 + bg) → RGB ≈ (249, 139, 63)
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101)
*
* Clasificación por vecino más cercano (distancia euclidiana en RGB) en vez de desigualdades
* ad-hoc: con 4 stops, amarillo y naranja quedan demasiado cerca para distinguirlos con
* reglas simples de umbral. MAX_DIST excluye fondo/texto/bordes que no son parte del heatmap.
*/
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; redPx: number; total: number
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels // 3 para JPEG (RGB)
let greenPx = 0, amberPx = 0, redPx = 0
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2]
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
if (g > r * 1.4 && g > 120 && g > b) {
greenPx++ // Verde
} else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) {
amberPx++ // Ámbar / amarillo cálido
} else if (r > g * 1.8 && r > b * 1.8 && r > 180) {
redPx++ // Rojo
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, redPx, total: data.length / ch }
return { greenPx, amberPx, orangePx, redPx, total: data.length / ch }
}
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
@@ -366,6 +390,13 @@ test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
// Forzar modo Min-max: este test verifica la mecánica del gradiente de color con
// costos diseñados para posicionarse proporcionalmente (task_analisis a la mitad
// del rango). El modo 'percentile' (default desde Sprint 5 Etapa 3) normaliza por
// rango/rank, no por valor — rompería la posición esperada de "mitad" del fixture.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
@@ -407,14 +438,18 @@ test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }
const jpegBuf = await extractJpegFromPdf(pdfBuf)
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
const { greenPx, amberPx, redPx, total } = await analyzeJpegColors(jpegBuf)
const { greenPx, amberPx, orangePx, redPx, total } = await analyzeJpegColors(jpegBuf)
console.log(` Total píxeles: ${total.toLocaleString()}`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🟠 Naranja: ${orangePx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
const MIN_PX = 100
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
// orangePx no se exige con mínimo estricto: con solo 11 actividades el stop naranja
// (t=0.67) puede no tener ninguna actividad exactamente ahí — alcanza con que exista
// la transición suave entre amarillo y rojo (lo prueban los tests unitarios de colors.ts).
})

View File

@@ -71,17 +71,43 @@ async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
return jpeg
}
async function analyzeJpegColors(jpegBuf: Buffer) {
// Gradiente 4 stops — colores de referencia con opacity 0.82 sobre fondo #f8fafc
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels
let greenPx = 0, amberPx = 0, redPx = 0
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2]
if (g > r * 1.4 && g > 120 && g > b) greenPx++
else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) amberPx++
else if (r > g * 1.8 && r > b * 1.8 && r > 180) redPx++
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, redPx, total: data.length / ch }
return { greenPx, amberPx, orangePx, redPx, total: data.length / ch }
}
// ─── Configuración del proceso ────────────────────────────────────────────────
@@ -196,6 +222,12 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
})
}, { timeout: 30_000 })
// Forzar modo Min-max: los costos del fixture están diseñados para posicionarse
// proporcionalmente en el rango (task_analisis a la mitad). El modo 'percentile'
// (default desde Sprint 5 Etapa 3) normaliza por rank y rompería la posición esperada.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── Fase 3: exportar desde tab "Actual" ──────────────────────────────────────
// Tab "Actual" está activo por defecto
@@ -283,7 +315,7 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
actualPixels.greenPx >= 100
results.push(`║ actual.pdf │ ${actualKb.padEnd(6)} KB │ ${String(actualPages).padEnd(6)} pgs │ ${actualOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(5)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${actualPixels.orangePx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar automatizado.pdf ──────────────────────────────────────────────────
const autoPdfBuf = readFileSync(autoPdfPath)
@@ -302,7 +334,7 @@ test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-
autoPixels.greenPx >= 100
results.push(`║ automatizado.pdf│ ${autoKb.padEnd(6)} KB │ ${String(autoPages).padEnd(6)} pgs │ ${autoOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(5)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${autoPixels.orangePx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar roi.pdf ───────────────────────────────────────────────────────────
const roiPdfBuf = readFileSync(roiPdfPath)

View File

@@ -146,7 +146,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
@@ -161,7 +161,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
@@ -177,7 +177,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={onRowClick}
@@ -193,7 +193,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
mode="percentile"
currency="USD"
highlightedId="task1"
onRowClick={vi.fn()}
@@ -208,7 +208,7 @@ describe('ActivitiesTable', () => {
render(
<ActivitiesTable
perActivity={[]}
mode="relative"
mode="percentile"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}

View File

@@ -213,7 +213,7 @@ describe('buildTopActivitiesOption — estructura', () => {
it('serie de tipo bar con hasta 10 items', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const series = option.series as any[]
expect(series[0].type).toBe('bar')
expect(series[0].data.length).toBeLessThanOrEqual(10)
@@ -225,7 +225,7 @@ describe('buildTopActivitiesOption — estructura', () => {
const result = runSimulation(buildSimInput(xml, 200, 0))
// En modo relativo con varianza real, la 1ra actividad → t=1 → rojo
// En simple-linear todos cuestan igual → NEUTRAL_HEATMAP_COLOR
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const series = option.series as any[]
// Verifica que itemStyle.color existe en cada barra
series[0].data.forEach((item: any) => {
@@ -237,7 +237,7 @@ describe('buildTopActivitiesOption — estructura', () => {
it('yAxis data contiene los nombres de actividades (truncados)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const option = buildTopActivitiesOption(result.perActivity, 'percentile', 'USD')
const yAxis = option.yAxis as any
expect(yAxis.data).toHaveLength(result.perActivity.length)
// Los nombres deben existir (no vacíos)

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
heatmapColorHex,
intensityToColor,
computeActivityT,
hasSignificantCostVariance,
activityColor,
@@ -18,39 +18,50 @@ function makeActs(costs: number[]): ActivityCostData[] {
}))
}
// ─── heatmapColorHex ─────────────────────────────────────────────────────────
describe('heatmapColorHex', () => {
// ─── intensityToColor — gradiente de 4 stops ─────────────────────────────────
describe('intensityToColor', () => {
it('t=0 → verde (#10b981)', () => {
expect(heatmapColorHex(0)).toBe('#10b981')
expect(intensityToColor(0)).toBe('#10b981')
})
it('t=1 → rojo (#ef4444)', () => {
expect(heatmapColorHex(1)).toBe('#ef4444')
expect(intensityToColor(1)).toBe('#ef4444')
})
it('t=0.5 → amarillo (#f59e0b)', () => {
expect(heatmapColorHex(0.5)).toBe('#f59e0b')
it('t=0.33 → amarillo (#f59e0b)', () => {
expect(intensityToColor(0.33)).toBe('#f59e0b')
})
it('t=0.67 → naranja (#f97316)', () => {
expect(intensityToColor(0.67)).toBe('#f97316')
})
it('t=0.5 → color naranja intermedio (entre #f59e0b y #f97316)', () => {
const color = intensityToColor(0.5)
expect(color).not.toBe('#f59e0b')
expect(color).not.toBe('#f97316')
expect(color).toMatch(/^#[0-9a-f]{6}$/)
})
it('t<0 → clampea a t=0 (verde)', () => {
expect(heatmapColorHex(-1)).toBe(heatmapColorHex(0))
expect(intensityToColor(-0.1)).toBe(intensityToColor(0))
})
it('t>1 → clampea a t=1 (rojo)', () => {
expect(heatmapColorHex(2)).toBe(heatmapColorHex(1))
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
})
it('devuelve string hex válido en formato #rrggbb', () => {
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const hex = heatmapColorHex(t)
const hex = intensityToColor(t)
expect(hex).toMatch(/^#[0-9a-f]{6}$/)
}
})
it('colores intermedios son distintos entre sí', () => {
const colors = [0, 0.25, 0.5, 0.75, 1].map(heatmapColorHex)
it('colores intermedios son distintos entre sí (transición suave, no saltos)', () => {
const colors = [0, 0.2, 0.4, 0.6, 0.8, 1].map(intensityToColor)
const unique = new Set(colors)
expect(unique.size).toBe(5)
expect(unique.size).toBe(6)
})
})
@@ -94,25 +105,36 @@ describe('hasSignificantCostVariance', () => {
})
})
// ─── computeActivityT ────────────────────────────────────────────────────────
describe('computeActivityT — modo relativo', () => {
it('actividad con mayor % → t = 1.0', () => {
// ─── computeActivityT — modo percentile ──────────────────────────────────────
describe('computeActivityT — modo percentile', () => {
it('actividad más barata → t = 0', () => {
const acts = makeActs([300, 100, 50])
const t = computeActivityT(acts[0], acts, 'relative')
expect(t).toBeCloseTo(1.0)
const t = computeActivityT(acts[2], acts, 'percentile')
expect(t).toBe(0)
})
it('actividad con menor costo → t < t de la más cara', () => {
it('actividad más cara → t = 1', () => {
const acts = makeActs([300, 100, 50])
const tBarata = computeActivityT(acts[2], acts, 'relative')
const tCara = computeActivityT(acts[0], acts, 'relative')
expect(tBarata).toBeLessThan(tCara)
const t = computeActivityT(acts[0], acts, 'percentile')
expect(t).toBe(1)
})
it('actividades con el mismo costo → mismo t', () => {
const acts = makeActs([100, 100, 500])
const tA = computeActivityT(acts[0], acts, 'percentile')
const tB = computeActivityT(acts[1], acts, 'percentile')
expect(tA).toBe(tB)
})
it('n=1 → t = 0.5', () => {
const acts = makeActs([500])
expect(computeActivityT(acts[0], acts, 'percentile')).toBe(0.5)
})
it('t siempre está en [0, 1]', () => {
const acts = makeActs([1000, 500, 100, 10])
for (const act of acts) {
const t = computeActivityT(act, acts, 'relative')
const t = computeActivityT(act, acts, 'percentile')
expect(t).toBeGreaterThanOrEqual(0)
expect(t).toBeLessThanOrEqual(1)
}
@@ -120,34 +142,56 @@ describe('computeActivityT — modo relativo', () => {
it('lista vacía → t = 0 sin crash', () => {
const act: ActivityCostData = { expectedTotalCost: 100, percentOfTotal: 100 }
expect(computeActivityT(act, [], 'relative')).toBe(0)
expect(computeActivityT(act, [], 'percentile')).toBe(0)
})
it('costo negativo → t = 0 (clamp explícito)', () => {
const acts = makeActs([100, 200, 300])
const negativa: ActivityCostData = { expectedTotalCost: -50, percentOfTotal: 0 }
expect(computeActivityT(negativa, [negativa, ...acts], 'percentile')).toBe(0)
})
it('outlier extremo: las actividades restantes se distribuyen (no todas en 0)', () => {
// 1 outlier carísimo + 4 actividades normales — percentile no se distorsiona
const acts = makeActs([1, 2, 3, 4, 100000])
const ts = acts.slice(0, 4).map((a) => computeActivityT(a, acts, 'percentile'))
const unique = new Set(ts)
expect(unique.size).toBeGreaterThan(1) // distribuidas, no comprimidas a un solo valor
})
})
describe('computeActivityT — modo absoluto', () => {
// ─── computeActivityT — modo minmax ──────────────────────────────────────────
describe('computeActivityT — modo minmax', () => {
it('actividad más cara → t = 1.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[2], acts, 'absolute')
const t = computeActivityT(acts[2], acts, 'minmax')
expect(t).toBeCloseTo(1.0)
})
it('actividad más barata → t = 0.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[0], acts, 'absolute')
const t = computeActivityT(acts[0], acts, 'minmax')
expect(t).toBeCloseTo(0.0)
})
it('actividad del medio → t ≈ 0.5', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[1], acts, 'absolute')
const t = computeActivityT(acts[1], acts, 'minmax')
expect(t).toBeCloseTo(0.5)
})
it('un solo precio (max=min) → t = 0.5 (defensivo)', () => {
const acts = makeActs([100, 100])
const t = computeActivityT(acts[0], acts, 'absolute')
const t = computeActivityT(acts[0], acts, 'minmax')
expect(t).toBe(0.5)
})
it('outlier extremo: las actividades restantes se comprimen hacia 0', () => {
const acts = makeActs([1, 2, 3, 4, 100000])
const ts = acts.slice(0, 4).map((a) => computeActivityT(a, acts, 'minmax'))
// Todas muy cerca de 0 — comportamiento esperado del minmax con outliers
for (const t of ts) expect(t).toBeLessThan(0.01)
})
})
// ─── activityColor — función unificada ───────────────────────────────────────
@@ -155,52 +199,49 @@ describe('activityColor', () => {
it('SIN varianza significativa → TODOS devuelven NEUTRAL_HEATMAP_COLOR', () => {
const acts = makeActs([100, 100, 100, 100, 100])
for (const act of acts) {
expect(activityColor(act, acts, 'relative')).toBe(NEUTRAL_HEATMAP_COLOR)
expect(activityColor(act, acts, 'absolute')).toBe(NEUTRAL_HEATMAP_COLOR)
expect(activityColor(act, acts, 'percentile')).toBe(NEUTRAL_HEATMAP_COLOR)
expect(activityColor(act, acts, 'minmax')).toBe(NEUTRAL_HEATMAP_COLOR)
}
})
it('CON varianza real modo relativo: la más cara NO es neutra', () => {
it('CON varianza real modo percentile: la más cara NO es neutra', () => {
const acts = makeActs([500, 100, 10])
const color = activityColor(acts[0], acts, 'relative')
const color = activityColor(acts[0], acts, 'percentile')
expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR)
})
it('CON varianza real modo relativo: la más cara es roja (#ef4444)', () => {
it('CON varianza real modo percentile: la más cara es roja (#ef4444)', () => {
const acts = makeActs([500, 100, 10])
const colorCara = activityColor(acts[0], acts, 'relative')
const colorCara = activityColor(acts[0], acts, 'percentile')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo relativo: la más barata es verde (#10b981)', () => {
it('CON varianza real modo percentile: la más barata es verde (#10b981)', () => {
const acts = makeActs([500, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'relative')
// No necesariamente exactamente verde, pero sí del espectro inferior
// La más barata tiene percentOfTotal bajo / maxPercent bajo → t bajo → color verdoso
expect(colorBarata).not.toBe('#ef4444')
const colorBarata = activityColor(acts[2], acts, 'percentile')
expect(colorBarata).toBe('#10b981')
})
it('CON varianza real modo absoluto: la más cara es roja', () => {
it('CON varianza real modo minmax: la más cara es roja', () => {
const acts = makeActs([1000, 100, 10])
const colorCara = activityColor(acts[0], acts, 'absolute')
const colorCara = activityColor(acts[0], acts, 'minmax')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más barata es verde', () => {
it('CON varianza real modo minmax: la más barata es verde', () => {
const acts = makeActs([1000, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'absolute')
const colorBarata = activityColor(acts[2], acts, 'minmax')
expect(colorBarata).toBe('#10b981')
})
it('edge: costos negativos no crashean', () => {
const acts = makeActs([0, 0, 0])
expect(() => activityColor(acts[0], acts, 'relative')).not.toThrow()
expect(() => activityColor(acts[0], acts, 'percentile')).not.toThrow()
})
it('simple-linear scenario: 5 actividades igual costo → todas neutras', () => {
// Este es el caso de use real de simple-linear.bpmn con costos fijos iguales
const acts = makeActs([100, 100, 100, 100, 100])
const colors = acts.map((a) => activityColor(a, acts, 'relative'))
const colors = acts.map((a) => activityColor(a, acts, 'percentile'))
expect(colors.every((c) => c === NEUTRAL_HEATMAP_COLOR)).toBe(true)
})
@@ -208,6 +249,7 @@ describe('activityColor', () => {
expect(NEUTRAL_HEATMAP_COLOR).toMatch(/^#[0-9a-f]{6}$/)
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#10b981') // no es verde
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f59e0b') // no es amarillo
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f97316') // no es naranja
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#ef4444') // no es rojo
})
})