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:
@@ -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.1x–4x), 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
250
sprints/sprint-5/ETAPA_3_PROMPT.md
Normal file
250
sprints/sprint-5/ETAPA_3_PROMPT.md
Normal 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
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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).
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user