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