import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react' import BpmnViewer from 'bpmn-js/lib/Viewer' import { activityColor } from '@/lib/colors' import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format' import type { HeatmapMode } from '@/lib/colors' import type { ActivitySimResult } from '@/domain/types' interface TooltipData { visible: boolean x: number y: number activity: ActivitySimResult | null } interface HeatmapCanvasProps { xml: string perActivity: ActivitySimResult[] mode: HeatmapMode currency: string selectedId: string | null onActivityClick: (bpmnElementId: string) => void minHeight?: number } export interface HeatmapCanvasHandle { highlightElement: (bpmnElementId: string | null) => void captureImage: () => Promise } function applyHeatmapToViewer( viewer: any, perActivity: ActivitySimResult[], mode: HeatmapMode ) { const elementRegistry = viewer.get('elementRegistry') as any if (!elementRegistry) return for (const act of perActivity) { const gfx: SVGElement | null = elementRegistry.getGraphics(act.bpmnElementId) if (!gfx) continue // activityColor devuelve NEUTRAL_HEATMAP_COLOR cuando no hay varianza significativa, // evitando una falsa diferenciación en procesos con costos uniformes. const color = activityColor(act, perActivity, mode) const shape = gfx.querySelector('.djs-visual rect') as SVGElement | null if (shape) { // Usar style.fill (inline CSS) en lugar de setAttribute: el CSS inline tiene // mayor especificidad que los estilos de bpmn-js, garantizando que html2canvas // capture el color real (getAttribute devuelve el atributo, pero el estilo // computado — que usa html2canvas — podría estar sobreescrito por CSS de bpmn-js). ;(shape as any).style.fill = color ;(shape as any).style.fillOpacity = '0.82' ;(shape as any).style.transition = 'fill 0.35s ease, fill-opacity 0.35s ease' } } } export const HeatmapCanvas = forwardRef( ({ xml, perActivity, mode, currency, selectedId, onActivityClick, minHeight = 500 }, ref) => { const containerRef = useRef(null) const viewerRef = useRef(null) const [isImported, setIsImported] = useState(false) // Ref que espeja isImported — permite que captureImage() lea el valor live // desde dentro de una closure asíncrona (las closures capturan state por valor). const isImportedRef = useRef(false) const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, activity: null }) const activityMap = useRef(new Map()) useEffect(() => { activityMap.current = new Map(perActivity.map((a) => [a.bpmnElementId, a])) }, [perActivity]) // Exponer métodos para resaltar y capturar imagen (para el PDF) useImperativeHandle(ref, () => ({ highlightElement: (bpmnElementId: string | null) => { const viewer = viewerRef.current if (!viewer) return const canvas = viewer.get('canvas') as any const reg = viewer.get('elementRegistry') as any reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected')) if (bpmnElementId) { try { canvas.addMarker(bpmnElementId, 'bpmn-selected') } catch { /* ignorar */ } } }, captureImage: async () => { if (!containerRef.current) return null // Esperar a que bpmn-js termine de importar y renderizar (máx. 5 s). // isImportedRef.current se actualiza sincrónicamente en el .then() de importXML, // por lo que es visible aquí aunque esta closure se haya creado antes. if (!isImportedRef.current) { const TIMEOUT_MS = 5_000 const POLL_MS = 100 let elapsed = 0 while (!isImportedRef.current && elapsed < TIMEOUT_MS) { await new Promise((resolve) => setTimeout(resolve, POLL_MS)) elapsed += POLL_MS } if (!isImportedRef.current) { throw new Error( 'El diagrama aún no terminó de cargar. Esperá unos segundos y reintentá.' ) } } // Esperar a que React corra el useEffect de applyHeatmapToViewer Y que la // transición CSS de los fills termine (fill 0.35s ease → necesitamos > 350ms). await new Promise((resolve) => setTimeout(resolve, 500)) const { default: html2canvas } = await import('html2canvas') try { const canvas = await html2canvas(containerRef.current, { scale: 1.5, useCORS: true, allowTaint: true, backgroundColor: '#f8fafc', logging: false, }) return canvas.toDataURL('image/jpeg', 0.82) } catch (err) { console.warn('html2canvas error:', err) return null } }, })) // Crear viewer useEffect(() => { if (!containerRef.current) return const viewer = new BpmnViewer({ container: containerRef.current }) viewerRef.current = viewer return () => { viewer.destroy() viewerRef.current = null isImportedRef.current = false setIsImported(false) } }, []) // Importar XML. // bpmn-js 18 puede rechazar la promesa de importXML (error interno de canvas) // pero igualmente renderiza el diagrama parcialmente y dispara 'import.done' // en el eventBus antes de rechazar. Escuchamos 'import.done' para setear // isImported de forma confiable sin depender de que la promesa resuelva. useEffect(() => { const viewer = viewerRef.current if (!viewer || !xml) return isImportedRef.current = false setIsImported(false) const eventBus = viewer.get('eventBus') as any const onImportDone = () => { eventBus.off('import.done', onImportDone) const canvas = viewer.get('canvas') as any try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ } isImportedRef.current = true setIsImported(true) } // Usamos .on + .off manual en lugar de .once para compatibilidad con mocks de test. eventBus.on('import.done', onImportDone) viewer.importXML(xml).catch((err: unknown) => { // Puede rechazar aunque el diagrama ya se haya renderizado (error de root layer en bpmn-js 18). // 'import.done' ya habrá disparado antes de este catch, por lo que isImported ya es true. console.warn('HeatmapCanvas importXML error (diagrama puede estar parcialmente renderizado):', err) }) return () => { eventBus.off('import.done', onImportDone) } }, [xml]) // Aplicar heatmap cuando el XML está listo o el modo cambia useEffect(() => { const viewer = viewerRef.current if (!isImported || !viewer || perActivity.length === 0) return applyHeatmapToViewer(viewer, perActivity, mode) }, [isImported, perActivity, mode]) // Resaltar elemento seleccionado externamente useEffect(() => { const viewer = viewerRef.current if (!isImported || !viewer) return const canvas = viewer.get('canvas') as any const reg = viewer.get('elementRegistry') as any reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected')) if (selectedId) { try { canvas.addMarker(selectedId, 'bpmn-selected') } catch { /* ignorar */ } } }, [isImported, selectedId]) // Event listeners de bpmn-js para click y hover useEffect(() => { const viewer = viewerRef.current if (!isImported || !viewer) return const eventBus = viewer.get('eventBus') as any const TASK_TYPES = new Set([ 'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask', 'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess', ]) const onHover = (event: any) => { const el = event.element if (!TASK_TYPES.has(el?.type)) { setTooltip((t) => ({ ...t, visible: false })); return } const act = activityMap.current.get(el.id) if (!act) return setTooltip((t) => ({ ...t, visible: true, activity: act })) } const onOut = () => setTooltip((t) => ({ ...t, visible: false })) const onClick = (event: any) => { const el = event.element if (!el || !TASK_TYPES.has(el.type)) return onActivityClick(el.id) } eventBus.on('element.hover', onHover) eventBus.on('element.out', onOut) eventBus.on('element.click', onClick) return () => { eventBus.off('element.hover', onHover) eventBus.off('element.out', onOut) eventBus.off('element.click', onClick) } }, [isImported, onActivityClick]) const onMouseMove = useCallback((e: React.MouseEvent) => { setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY })) }, []) return (
{/* Canvas bpmn-js */}
{/* Tooltip custom */} {tooltip.visible && tooltip.activity && (

{tooltip.activity.activityName}

Costo esperado {formatCurrency(tooltip.activity.expectedTotalCost, currency)}
% del total {formatPercent(tooltip.activity.percentOfTotal)}
Prob. ejecución {formatPercent(tooltip.activity.executionProbability * 100)}
{tooltip.activity.executionTimeMinutes > 0 && (
Tiempo {formatMinutes(tooltip.activity.executionTimeMinutes)}
)}
)}
) } ) HeatmapCanvas.displayName = 'HeatmapCanvas'