// 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' } }