feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0. Funcionalidades incluidas: - Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo - Visualización read-only del diagrama (bpmn-js) - Configuración de actividades (costo, tiempo, recursos asignados) - CRUD de recursos (rol, persona, sistema, equipo, insumo) - Configuración global (moneda, overhead %, nombre, cliente) - Motor de simulación determinístico agregado con propagación de gateways XOR/AND - Reporte visual con mapa de calor en dos modos (relativo/absoluto) - Gráficos: KPIs, top actividades, composición, costo por recurso - Export PDF (jsPDF + html2canvas) y CSV (papaparse) - Persistencia en IndexedDB (Dexie) - 268 tests unitarios/integración + 6 E2E con Playwright - Deploy: https://process-cost-platform.pages.dev Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
149
src/lib/colors.ts
Normal file
149
src/lib/colors.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Paleta heatmap: verde → amarillo → rojo
|
||||
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
|
||||
|
||||
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})`
|
||||
}
|
||||
|
||||
export function heatmapColorHex(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 `#${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 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)
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────
|
||||
|
||||
export type HeatmapMode = 'relative' | 'absolute'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): 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))
|
||||
}
|
||||
|
||||
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 heatmapColorHex(computeActivityT(activity, allActivities, mode))
|
||||
}
|
||||
|
||||
// Valores de los extremos para la leyenda del heatmap
|
||||
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' }
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
|
||||
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
|
||||
}
|
||||
|
||||
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' }
|
||||
}
|
||||
Reference in New Issue
Block a user