- 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>
218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
import { useEffect, useRef, useCallback, type CSSProperties } from 'react'
|
|
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
|
import { Maximize2, ZoomIn, ZoomOut } from 'lucide-react'
|
|
|
|
const ZOOM_STEP = 1.2
|
|
const ZOOM_MIN = 0.1
|
|
const ZOOM_MAX = 4
|
|
|
|
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
|
|
|
const ACTIVITY_TYPES = new Set([
|
|
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
|
|
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
|
|
])
|
|
|
|
const GATEWAY_TYPES = new Set([
|
|
'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway',
|
|
'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway',
|
|
])
|
|
|
|
function classifyElement(type: string): BpmnElementCategory {
|
|
if (ACTIVITY_TYPES.has(type)) return 'activity'
|
|
if (GATEWAY_TYPES.has(type)) return 'gateway'
|
|
return 'other'
|
|
}
|
|
|
|
// Badge de automatización — identidad InQ naranja sólido (Etapa 9 Sprint 1.5).
|
|
// Usa var(--inq-orange) definido en globals.css :root para consistencia con el sistema de design.
|
|
// Emoji ⚡ como símbolo provisional — reemplazo por <Bot /> agendado Sprint 2-3.
|
|
function makeBadgeHtml(): string {
|
|
return `<div class="automatable-badge" title="Actividad marcada como automatizable" style="
|
|
width:20px;height:20px;
|
|
border-radius:50%;
|
|
background:var(--inq-orange);
|
|
border:1.5px solid white;
|
|
box-shadow:0 1px 2px rgba(0,0,0,0.10);
|
|
display:flex;align-items:center;justify-content:center;
|
|
color:white;
|
|
font-size:11px;font-weight:700;
|
|
line-height:20px;
|
|
cursor:default;
|
|
pointer-events:none;
|
|
z-index:10;
|
|
">⚡</div>`
|
|
}
|
|
|
|
// Identificador del tipo de overlay para poder remover solo los nuestros.
|
|
const OVERLAY_TYPE = 'automation-indicator'
|
|
|
|
interface BpmnCanvasProps {
|
|
xml: string
|
|
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
|
|
selectedElementId?: string | null
|
|
automatableElementIds?: string[] // IDs de actividades marcadas como automatizables
|
|
className?: string
|
|
}
|
|
|
|
export function BpmnCanvas({
|
|
xml,
|
|
onElementClick,
|
|
selectedElementId,
|
|
automatableElementIds = [],
|
|
className,
|
|
}: BpmnCanvasProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
|
|
const isImportedRef = useRef(false)
|
|
|
|
// ─── Inicializar viewer ────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!containerRef.current) return
|
|
const viewer = new BpmnViewer({ container: containerRef.current })
|
|
viewerRef.current = viewer
|
|
return () => {
|
|
isImportedRef.current = false
|
|
viewer.destroy()
|
|
viewerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
// ─── Sincronizar overlays de automatización ───────────────────────────────
|
|
const syncAutomationOverlays = useCallback(() => {
|
|
const viewer = viewerRef.current
|
|
if (!viewer || !isImportedRef.current) return
|
|
|
|
const overlays = viewer.get('overlays') as any
|
|
const reg = viewer.get('elementRegistry') as any
|
|
|
|
// Remover overlays anteriores del tipo propio antes de re-agregar
|
|
overlays.remove({ type: OVERLAY_TYPE })
|
|
|
|
for (const elementId of automatableElementIds) {
|
|
try {
|
|
// Leer el ancho real del elemento para posicionar el badge en la
|
|
// esquina superior derecha con overlap: left = width - 12 pone el
|
|
// borde derecho del badge 8px fuera del borde derecho del nodo.
|
|
// (right: -8 en la API tiene semántica distinta: left = -right + width
|
|
// con -8 daría left = 8 + width, enteramente fuera del nodo.)
|
|
const el = reg?.get(elementId)
|
|
const width = el?.width ?? 100
|
|
overlays.add(elementId, OVERLAY_TYPE, {
|
|
position: { top: -8, left: width - 12 },
|
|
html: makeBadgeHtml(),
|
|
})
|
|
} catch {
|
|
// El elemento puede no existir en el diagrama actual — ignorar
|
|
}
|
|
}
|
|
}, [automatableElementIds])
|
|
|
|
// ─── Importar XML ──────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const viewer = viewerRef.current
|
|
if (!viewer || !xml) return
|
|
isImportedRef.current = false
|
|
viewer.importXML(xml).then(({ warnings }) => {
|
|
// Si el viewer fue destruido y reemplazado (p.ej. React Strict Mode), ignorar
|
|
if (viewerRef.current !== viewer) return
|
|
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
|
|
const canvas = viewer.get('canvas') as any
|
|
canvas.zoom('fit-viewport', 'auto')
|
|
isImportedRef.current = true
|
|
syncAutomationOverlays()
|
|
}).catch((err: Error) => {
|
|
if (viewerRef.current !== viewer) return
|
|
console.error('Error cargando BPMN:', err)
|
|
})
|
|
}, [xml, syncAutomationOverlays])
|
|
|
|
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────
|
|
useEffect(() => {
|
|
syncAutomationOverlays()
|
|
}, [syncAutomationOverlays])
|
|
|
|
// ─── Click handler ─────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const viewer = viewerRef.current
|
|
if (!viewer || !onElementClick) return
|
|
const eventBus = viewer.get('eventBus') as any
|
|
|
|
const handler = (event: any) => {
|
|
const el = event.element
|
|
if (!el) return
|
|
const category = classifyElement(el.type)
|
|
if (category === 'other') return
|
|
onElementClick(el.id, el.businessObject?.name ?? el.id, category)
|
|
}
|
|
|
|
eventBus.on('element.click', handler)
|
|
return () => eventBus.off('element.click', handler)
|
|
}, [onElementClick])
|
|
|
|
// ─── Highlight del elemento seleccionado ──────────────────────────────────
|
|
useEffect(() => {
|
|
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 (selectedElementId) {
|
|
try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ }
|
|
}
|
|
}, [selectedElementId])
|
|
|
|
function handleZoomIn() {
|
|
const canvas = viewerRef.current?.get('canvas') as any
|
|
if (!canvas) return
|
|
canvas.zoom(Math.min(canvas.zoom() * ZOOM_STEP, ZOOM_MAX))
|
|
}
|
|
|
|
function handleZoomOut() {
|
|
const canvas = viewerRef.current?.get('canvas') as any
|
|
if (!canvas) return
|
|
canvas.zoom(Math.max(canvas.zoom() / ZOOM_STEP, ZOOM_MIN))
|
|
}
|
|
|
|
const controlButtonStyle: CSSProperties = {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 6,
|
|
background: 'hsl(var(--background))',
|
|
border: '1px solid hsl(var(--border))',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
|
}
|
|
|
|
return (
|
|
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
|
|
|
{/* Encuadrar — esquina superior derecha */}
|
|
<button
|
|
onClick={() => {
|
|
const canvas = viewerRef.current?.get('canvas') as any
|
|
canvas?.zoom('fit-viewport', 'auto')
|
|
}}
|
|
title="Encuadrar diagrama"
|
|
style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, ...controlButtonStyle }}
|
|
>
|
|
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
|
|
{/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
|
|
<div style={{ position: 'absolute', bottom: 12, left: 12, zIndex: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
<button onClick={handleZoomIn} title="Acercar" style={controlButtonStyle}>
|
|
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
<button onClick={handleZoomOut} title="Alejar" style={controlButtonStyle}>
|
|
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|