Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions packages/web/src/components/DataPixel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useViewport } from '@xyflow/react'
import type { FlowStep, FlowData } from '../types'
import { DataTooltip } from './DataTooltip'

Expand All @@ -25,6 +26,10 @@ interface DataPixelProps {
onPixelClick?: (step: FlowStep, position: { x: number; y: number }) => void
delayMs?: number
dataOverride?: FlowData
/** When true, freeze the pixel mid-flight: each tick re-anchors the
* start time so `elapsed` doesn't advance, leaving the pixel rendered
* at its current position. On resume the pixel continues from there. */
paused?: boolean
}

const PIXEL_SIZE = 28
Expand All @@ -44,15 +49,27 @@ export function DataPixel({
onPixelClick,
delayMs,
dataOverride,
paused = false,
}: DataPixelProps) {
const pixelRef = useRef<HTMLDivElement>(null)
const labelRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number>(0)
const startTimeRef = useRef<number>(0)
const lastTickTimeRef = useRef<number>(0)
const pausedRef = useRef(paused)
pausedRef.current = paused
const onCompleteRef = useRef(onAnimationComplete)
useEffect(() => {
onCompleteRef.current = onAnimationComplete
}, [onAnimationComplete])
// Reactive viewport from React Flow's store. Without this we were regex-
// parsing `viewport.style.transform`, which fails when RF emits a `matrix(…)`
// form — `scale` fell back to 1, so the carrot stayed at its base size and
// drifted off the path on zoom. Using the store directly keeps the carrot
// anchored and sized in sync with the rest of the canvas.
const viewport = useViewport()
const viewportRef = useRef(viewport)
viewportRef.current = viewport
const [hovered, setHovered] = useState(false)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
// pixelColor (palette cycling / data.color) > sourceNodeColor (variant
Expand All @@ -77,15 +94,26 @@ export function DataPixel({
)
if (!edgePath) return

// Get the viewport transform from React Flow
const viewport = container.querySelector<HTMLDivElement>('.react-flow__viewport')
if (!viewport) return

const totalLength = edgePath.getTotalLength()
if (totalLength === 0) return

const tick = (timestamp: number) => {
// Always initialize startTimeRef on the first tick — without this,
// a manual pixel fired while paused saw startTimeRef=0 and elapsed
// computed to a huge timestamp, snapping the carrot to the edge end
// (visually: a pixel hovering on the next node instead of starting
// at the source).
const wasPaused = pausedRef.current
if (!startTimeRef.current) startTimeRef.current = timestamp + (delayMs ?? 0)
// Pause: shift startTimeRef forward by however long this paused tick
// covered, so `elapsed` stays constant. The position-update logic
// below STILL runs each frame so the carrot can react to zoom changes
// while paused (without this, zooming during pause left the carrot
// glued to its pre-pause screen position + size).
if (wasPaused && lastTickTimeRef.current > 0) {
startTimeRef.current += timestamp - lastTickTimeRef.current
}
lastTickTimeRef.current = timestamp

const elapsed = timestamp - startTimeRef.current
const progress = Math.min(elapsed / (ANIMATION_DURATION_BASE / getSpeed()), 1)
Expand All @@ -96,48 +124,44 @@ export function DataPixel({

const point = edgePath.getPointAtLength((reverse ? 1 - eased : eased) * totalLength)

// Get the viewport transform to convert SVG coordinates to screen coordinates
const viewportTransform = viewport.style.transform
const match = viewportTransform.match(
/translate\(([^,]+)px,\s*([^)]+)px\)\s*scale\(([^)]+)\)/
)

let tx = 0
let ty = 0
let scale = 1
if (match) {
tx = parseFloat(match[1])
ty = parseFloat(match[2])
scale = parseFloat(match[3])
}

const screenX = point.x * scale + tx
const screenY = point.y * scale + ty
const scaledPixelSize = PIXEL_SIZE * scale
const scaledLabelFontSize = 11 * scale
// Read the live viewport (x, y, zoom) from the React Flow store. This
// replaces a regex-parse of `viewport.style.transform` which didn't
// handle every transform format RF emits — when the regex missed, zoom
// fell back to 1, the carrot stayed at base size and drifted off the
// (zoomed) path. Reading from the store keeps everything in lockstep
// with the rest of the canvas.
const { x: tx, y: ty, zoom } = viewportRef.current
const screenX = point.x * zoom + tx
const screenY = point.y * zoom + ty
const pixelSize = PIXEL_SIZE * zoom
const labelFontSize = 11 * zoom

setPosition({ x: screenX, y: screenY })

if (pixelRef.current) {
pixelRef.current.style.width = `${scaledPixelSize}px`
pixelRef.current.style.height = `${scaledPixelSize}px`
pixelRef.current.style.transform = `translate(${screenX - scaledPixelSize / 2}px, ${screenY - scaledPixelSize / 2}px)`
pixelRef.current.style.width = `${pixelSize}px`
pixelRef.current.style.height = `${pixelSize}px`
pixelRef.current.style.transform = `translate(${screenX - pixelSize / 2}px, ${screenY - pixelSize / 2}px)`
pixelRef.current.style.opacity = '1'
}
if (labelRef.current) {
labelRef.current.style.fontSize = `${scaledLabelFontSize}px`
labelRef.current.style.transform = `translate(${screenX + scaledPixelSize / 2 + 4 * scale}px, ${screenY - 6 * scale}px)`
labelRef.current.style.fontSize = `${labelFontSize}px`
labelRef.current.style.transform = `translate(${screenX + pixelSize / 2 + 4 * zoom}px, ${screenY - 6 * zoom}px)`
labelRef.current.style.opacity = '1'
}

if (progress < 1) {
// Keep ticking while paused (so zoom updates land), and while still
// animating. Only fire the completion callback when truly done AND not
// paused — otherwise a paused-at-edge-end pixel would get destroyed.
if (wasPaused || progress < 1) {
rafRef.current = requestAnimationFrame(tick)
} else if (onCompleteRef.current) {
onCompleteRef.current()
}
}

startTimeRef.current = 0
lastTickTimeRef.current = 0
rafRef.current = requestAnimationFrame(tick)
}, [edgeId, reverse, containerRef, delayMs])

Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@
},
}
})
}, [

Check warning on line 557 in packages/web/src/components/FlowCanvas.tsx

View workflow job for this annotation

GitHub Actions / build & test (node 20)

React Hook useMemo has a missing dependency: 'nodeOutgoingSteps'. Either include it or remove the dependency array

Check warning on line 557 in packages/web/src/components/FlowCanvas.tsx

View workflow job for this annotation

GitHub Actions / build & test (node 22)

React Hook useMemo has a missing dependency: 'nodeOutgoingSteps'. Either include it or remove the dependency array
baseNodes,
animState.activeFromIds,
animState.activeToIds,
Expand Down Expand Up @@ -685,11 +685,14 @@
onPixelClick={(s, pos) => handlePinPopup(s, pos, edgeFlow.edgeId)}
delayMs={plan.delayMs || undefined}
dataOverride={dataObj}
paused={!playing}
/>
)
})}

{/* Data pixel overlay — manual (click-to-fire) */}
{/* Data pixel overlay — manual (click-to-fire). These animate
unconditionally — pause is for the autoplay loop only; a click
should always play out its single step regardless of global state. */}
{manualPixels.map((mp) => (
<DataPixel
key={mp.id}
Expand Down
63 changes: 50 additions & 13 deletions packages/web/src/components/nodes/FlowNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export function FlowNodeComponent({ data, id }: NodeProps<FlowNodeType>) {
<div
role="group"
aria-label={`Node: ${label}`}
title={label}
data-id={id}
onClick={handleNodeClick}
style={{
Expand Down Expand Up @@ -226,23 +227,59 @@ export function FlowNodeComponent({ data, id }: NodeProps<FlowNodeType>) {
)}
</div>

{/* Label — floats below building, no background */}
<span
{/* Label: a fixed-height flow slot that holds an absolute-positioned
label. The slot reserves vertical room (≈ 2 lines + margin) so
the progress bar below doesn't slide up. The label itself is
absolute + transform-centered so it can render WIDER than the
parent flex column (which auto-sizes to the 108px building, then
constrains every descendant unless we escape the flow). Cap at
2 lines × ~30 chars per line, ellipsis past that. */}
<div
style={{
color: borderColor,
fontSize: 20,
fontWeight: 700,
fontFamily: 'monospace',
textAlign: 'center',
whiteSpace: 'nowrap',
letterSpacing: 0.3,
textShadow: '0 1px 4px #000, 0 0 6px #000, 0 0 2px #000',
position: 'relative',
width: '100%',
height: 48,
marginTop: 4,
display: 'block',
pointerEvents: 'none',
}}
>
{label}
</span>
<span
style={{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
color: borderColor,
fontSize: 20,
fontWeight: 700,
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: 0.3,
textShadow: '0 1px 4px #000, 0 0 6px #000, 0 0 2px #000',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
// Live-tweakable from DevTools:
// document.documentElement.style.setProperty(
// '--openhop-label-max-width', '200px')
maxWidth: 'var(--openhop-label-max-width, 200px)',
width: 'max-content',
lineHeight: 1.1,
wordBreak: 'break-word',
// pointer-events:none — the absolute label can extend past its
// parent node and overlap adjacent buildings; without this,
// clicks intended for the neighboring node land on this label
// and hit handleNodeClick on the WRONG node (or nothing at all
// because the span has no click handler). Building + progress
// bar still receive clicks normally.
pointerEvents: 'none',
}}
>
{label}
</span>
</div>

{/* Progress bar — 56px wide, matches building width */}
{progressTotal > 0 && (
Expand Down
113 changes: 78 additions & 35 deletions packages/web/src/hooks/useFlowAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ export function useFlowAnimation(
const mappingsRef = useRef(stepMappings)
mappingsRef.current = stepMappings

// Phase-aware pause/resume state. Each step plays in two phases:
// 1. 'pixel-active' — pixel travelling along the edge, edges + nodes lit
// 2. 'gap' — pixel cleared, brief delay before next step
// Pause captures phase + elapsed so resume picks up where it left off
// instead of jumping to the next step (the prior bug: the active state
// was cleared on pause and the chain restarted from advanceStep).
const phaseRef = useRef<'pixel-active' | 'gap' | null>(null)
const phaseStartedAtRef = useRef<number>(0)
const pauseOffsetMsRef = useRef<number>(0)
// Stable ref to the latest `advanceStep`, so `enterPhase` (which has [] deps
// to keep the timer chain stable) always invokes the current closure even
// if `steps.length` changes mid-playback.
const advanceStepRef = useRef<() => void>(() => {})

const clearTimers = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
Expand Down Expand Up @@ -181,52 +195,81 @@ export function useFlowAnimation(
destroyedNodes: new Set(destroyedNodesRef.current),
}))

timerRef.current = setTimeout(() => {
if (!playingRef.current) return
enterPhase('pixel-active', 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps.length])

setState((prev) => ({
...prev,
activeEdgeIds: new Set<string>(),
activeEdgeFlows: [],
activeFromIds: new Set<string>(),
activeToIds: new Set<string>(),
activeStep: null,
nodeProgress: new Map(nodeProgressRef.current),
}))
// Keep the ref pointing at the latest advanceStep closure so `enterPhase`'s
// gap-phase timer fires the current implementation, not a stale one captured
// when enterPhase was first created.
advanceStepRef.current = advanceStep

timerRef.current = setTimeout(
() => {
advanceStep()
},
(STEP_DURATION_BASE - PIXEL_DURATION_BASE) / getSpeed()
)
}, PIXEL_DURATION_BASE / getSpeed())
// Schedule the timer for the given phase, accepting an `offset` so we can
// resume mid-phase after a pause. `offset = 0` means the phase just started.
const enterPhase = useCallback((phase: 'pixel-active' | 'gap', offset: number) => {
phaseRef.current = phase
phaseStartedAtRef.current = performance.now() - offset
const speed = getSpeed()

if (phase === 'pixel-active') {
const remaining = Math.max(0, PIXEL_DURATION_BASE / speed - offset)
timerRef.current = setTimeout(() => {
if (!playingRef.current) return
setState((prev) => ({
...prev,
activeEdgeIds: new Set<string>(),
activeEdgeFlows: [],
activeFromIds: new Set<string>(),
activeToIds: new Set<string>(),
activeStep: null,
nodeProgress: new Map(nodeProgressRef.current),
}))
enterPhase('gap', 0)
}, remaining)
} else {
const gapMs = (STEP_DURATION_BASE - PIXEL_DURATION_BASE) / speed
const remaining = Math.max(0, gapMs - offset)
timerRef.current = setTimeout(() => {
if (!playingRef.current) return
phaseRef.current = null
advanceStepRef.current()
}, remaining)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps.length])
}, [])

useEffect(() => {
if (playing) {
// Delay first step so React Flow has time to render edges (important for sub-flow drill-down)
timerRef.current = setTimeout(() => {
advanceStep()
}, 500)
if (phaseRef.current && pauseOffsetMsRef.current > 0) {
// Resuming from a pause mid-step. Re-enter the same phase with the
// captured offset so timer + DataPixel rAF pick up where they left off.
const phase = phaseRef.current
const offset = pauseOffsetMsRef.current
pauseOffsetMsRef.current = 0
setState((prev) => ({ ...prev, playing: true }))
enterPhase(phase, offset)
} else {
// Fresh start (or a previous pause that was already past its phase).
// Delay first step so React Flow has time to render edges (important
// for sub-flow drill-down).
timerRef.current = setTimeout(() => {
advanceStep()
}, 500)
}
} else {
clearTimers()
setState((prev) => ({
...prev,
playing: false,
activeEdgeIds: new Set<string>(),
activeEdgeFlows: [],
activeFromIds: new Set<string>(),
activeToIds: new Set<string>(),
activeStep: null,
activeNodes: new Set<string>(),
destroyedNodes: new Set<string>(),
}))
// Capture how far into the current phase we got, so resume can pick up.
// The active state (activeStep, activeEdgeIds, …) is intentionally NOT
// cleared here — that was the prior pause bug: the highlighted step
// would vanish and the in-flight pixel would disappear.
if (phaseRef.current) {
pauseOffsetMsRef.current = performance.now() - phaseStartedAtRef.current
}
setState((prev) => ({ ...prev, playing: false }))
}

return clearTimers
}, [playing, advanceStep, clearTimers])
}, [playing, advanceStep, clearTimers, enterPhase])

const manualPixelCounter = useRef(0)

Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/lib/flow-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,16 @@ function buildElkGraph(topology: FlowTopology, portAssignments?: Map<string, Edg
'portAlignment.default': 'CENTER',
'elk.spacing.nodeNode': '80',
'elk.layered.spacing.nodeNodeBetweenLayers': '200',
// Pull edge corridors apart so back-edges (e.g. results → api in the
// orion flow) don't share a vertical lane with a forward edge between
// the same source-side node and a different target. Prior to bumping
// these, results → api routed at x=774 while api → jenkins ran at
// x=864 — visually two parallel roads that read as "two edges between
// api and jenkins."
'elk.spacing.edgeEdge': '40',
'elk.spacing.edgeNode': '40',
'elk.layered.spacing.edgeEdgeBetweenLayers': '40',
'elk.layered.spacing.edgeNodeBetweenLayers': '40',
'elk.padding': '[top=40,left=40,bottom=40,right=40]',
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
'elk.layered.crossingMinimization.forceNodeModelOrder': 'true',
Expand Down
Loading
Loading