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:
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
|
||||
Reference in New Issue
Block a user