Refactor ActionGraph component to include ReactFlowProvider and enhance structure

- Introduced ActionGraphInner as a separate component to utilize ReactFlow hooks.
- Added ChaserCrab overlay to display additional node information.
- Wrapped ActionGraph in ReactFlowProvider for improved context management.
- Updated layout and styling for better visual consistency.
This commit is contained in:
luccast
2026-01-26 08:49:44 -05:00
parent d044a3dc4d
commit 36f806897c
2 changed files with 172 additions and 2 deletions
+19 -2
View File
@@ -10,11 +10,13 @@ import {
type Edge,
type NodeTypes,
MarkerType,
ReactFlowProvider,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { SessionNode } from './SessionNode'
import { ActionNode } from './ActionNode'
import { CrabNode } from './CrabNode'
import { ChaserCrab } from './ChaserCrab'
import { layoutGraph } from '~/lib/graph-layout'
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
@@ -34,7 +36,8 @@ const nodeTypes: NodeTypes = {
crab: CrabNode as any,
}
export function ActionGraph({
// Inner component that uses ReactFlow hooks
function ActionGraphInner({
sessions,
actions,
selectedSession,
@@ -178,6 +181,9 @@ export function ActionGraph({
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
// Get node IDs for the chaser crab
const nodeIds = useMemo(() => nodes.map((n) => n.id), [nodes])
// Update nodes when layout changes
useEffect(() => {
setNodes(layoutedNodes)
@@ -195,7 +201,7 @@ export function ActionGraph({
)
return (
<div className="w-full h-full bg-shell-950 texture-grid">
<div className="w-full h-full bg-shell-950 texture-grid relative">
<ReactFlow
nodes={nodes}
edges={edges}
@@ -220,6 +226,17 @@ export function ActionGraph({
maskColor="rgba(10, 10, 15, 0.8)"
/>
</ReactFlow>
{/* Chaser crab overlay */}
<ChaserCrab nodeIds={nodeIds} />
</div>
)
}
// Wrapper that provides ReactFlowProvider
export function ActionGraph(props: ActionGraphProps) {
return (
<ReactFlowProvider>
<ActionGraphInner {...props} />
</ReactFlowProvider>
)
}
+153
View File
@@ -0,0 +1,153 @@
import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react'
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
type CrabState = 'idle' | 'running' | 'attacking'
interface Position {
x: number
y: number
}
interface ChaserCrabProps {
nodeIds: string[]
}
const CRAB_SIZE = 20
const CRAB_SPEED = 3 // pixels per frame
const ATTACK_DISTANCE = 30 // how close before attacking
const ATTACK_CHANCE = 0.4 // 40% chance to attack when close
export function ChaserCrab({ nodeIds }: ChaserCrabProps) {
const { getNodes, getViewport } = useReactFlow()
const [position, setPosition] = useState<Position>({ x: 0, y: 0 })
const [target, setTarget] = useState<Position | null>(null)
const [crabState, setCrabState] = useState<CrabState>('idle')
const [facingLeft, setFacingLeft] = useState(false)
const prevNodeIdsRef = useRef<Set<string>>(new Set())
const animationFrameRef = useRef<number>(undefined)
const attackTimeoutRef = useRef<NodeJS.Timeout>(undefined)
// Detect new nodes and set them as targets
useEffect(() => {
const currentIds = new Set(nodeIds)
const prevIds = prevNodeIdsRef.current
// Find newly added nodes (excluding the central crab node)
for (const id of currentIds) {
if (!prevIds.has(id) && !id.includes('crab-origin')) {
// New node found! Get its position
const nodes = getNodes()
const newNode = nodes.find((n) => n.id === id)
if (newNode) {
const nodeCenter = {
x: newNode.position.x + 100, // Approximate center
y: newNode.position.y + 40,
}
setTarget(nodeCenter)
setCrabState('running')
// Clear any existing attack timeout
if (attackTimeoutRef.current) {
clearTimeout(attackTimeoutRef.current)
}
}
break // Only chase one new node at a time
}
}
prevNodeIdsRef.current = currentIds
}, [nodeIds, getNodes])
// Animation loop for movement
useEffect(() => {
if (!target || crabState === 'attacking') return
const animate = () => {
setPosition((current) => {
const dx = target.x - current.x
const dy = target.y - current.y
const distance = Math.sqrt(dx * dx + dy * dy)
// Check if we're close enough
if (distance < ATTACK_DISTANCE) {
// Randomly decide to attack or go idle
if (Math.random() < ATTACK_CHANCE) {
setCrabState('attacking')
// Return to idle after attack animation (4 frames at 10fps = 400ms)
attackTimeoutRef.current = setTimeout(() => {
setCrabState('idle')
setTarget(null)
}, 400)
} else {
setCrabState('idle')
setTarget(null)
}
return current
}
// Move towards target
const vx = (dx / distance) * CRAB_SPEED
const vy = (dy / distance) * CRAB_SPEED
// Update facing direction
if (Math.abs(vx) > 0.1) {
setFacingLeft(vx < 0)
}
return {
x: current.x + vx,
y: current.y + vy,
}
})
animationFrameRef.current = requestAnimationFrame(animate)
}
animationFrameRef.current = requestAnimationFrame(animate)
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [target, crabState])
// Cleanup on unmount
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
if (attackTimeoutRef.current) {
clearTimeout(attackTimeoutRef.current)
}
}
}, [])
// Get viewport for proper positioning
const viewport = getViewport()
// Transform crab position to screen coordinates
const screenX = position.x * viewport.zoom + viewport.x
const screenY = position.y * viewport.zoom + viewport.y
return (
<div
className="absolute pointer-events-none z-50 transition-transform"
style={{
left: screenX - CRAB_SIZE / 2,
top: screenY - CRAB_SIZE / 2,
width: CRAB_SIZE,
height: CRAB_SIZE,
transform: `scaleX(${facingLeft ? -1 : 1})`,
}}
>
{crabState === 'idle' && <CrabIdleAnimation className="w-full h-full" />}
{crabState === 'running' && <CrabJumpAnimation className="w-full h-full" />}
{crabState === 'attacking' && <CrabAttackAnimation className="w-full h-full" />}
</div>
)
}