- 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>
163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
// 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)
|
|
}
|
|
|
|
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) }
|
|
}
|
|
|
|
// 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))
|
|
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')}`
|
|
}
|
|
|
|
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 { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
}
|
|
|
|
// Determina si el texto sobre el color debe ser claro u oscuro (WCAG contrast)
|
|
export function getContrastText(t: number): 'white' | 'black' {
|
|
return t > 0.5 ? 'white' : 'black'
|
|
}
|
|
|
|
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
|
|
|
|
// 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
|
|
percentOfTotal: number
|
|
}
|
|
|
|
// Color neutro (gris claro) cuando no hay varianza significativa entre actividades.
|
|
// Evita comunicar una falsa diferenciación cuando todos los costos son similares.
|
|
export const NEUTRAL_HEATMAP_COLOR = '#cbd5e1' // slate-300
|
|
|
|
// Umbral: si el rango relativo (max-min)/max < 5%, la varianza no es significativa.
|
|
export const VARIANCE_THRESHOLD = 0.05
|
|
|
|
export function hasSignificantCostVariance(activities: ActivityCostData[]): boolean {
|
|
if (activities.length < 2) return false
|
|
const costs = activities.map((a) => a.expectedTotalCost)
|
|
const max = Math.max(...costs)
|
|
const min = Math.min(...costs)
|
|
if (max <= 0) return false
|
|
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.
|
|
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
|
|
export function computeActivityT(
|
|
activity: ActivityCostData,
|
|
allActivities: ActivityCostData[],
|
|
mode: HeatmapMode
|
|
): number {
|
|
if (allActivities.length === 0) return 0
|
|
|
|
if (mode === 'percentile') {
|
|
return percentileNorm(activity, allActivities)
|
|
}
|
|
|
|
const costs = allActivities.map((a) => a.expectedTotalCost)
|
|
const minCost = Math.min(...costs)
|
|
const maxCost = Math.max(...costs)
|
|
if (maxCost <= minCost) return 0.5
|
|
return Math.max(0, Math.min(1, (activity.expectedTotalCost - minCost) / (maxCost - minCost)))
|
|
}
|
|
|
|
// Función unificada: devuelve el color correcto considerando varianza.
|
|
// Usar en HeatmapCanvas, ActivitiesTable y los gráficos para consistencia.
|
|
export function activityColor(
|
|
activity: ActivityCostData,
|
|
allActivities: ActivityCostData[],
|
|
mode: HeatmapMode
|
|
): string {
|
|
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
|
|
return intensityToColor(computeActivityT(activity, allActivities, mode))
|
|
}
|
|
|
|
// 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' | 'rank' } {
|
|
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
|
|
|
if (mode === 'percentile') {
|
|
return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
|
}
|
|
|
|
const costs = allActivities.map((a) => a.expectedTotalCost)
|
|
const minCost = Math.min(...costs)
|
|
const maxCost = Math.max(...costs)
|
|
return { min: minCost, mid: (minCost + maxCost) / 2, max: maxCost, unit: 'currency' }
|
|
}
|