diff --git a/packages/web/src/components/DataPixel.tsx b/packages/web/src/components/DataPixel.tsx index 648055a..f97b54c 100644 --- a/packages/web/src/components/DataPixel.tsx +++ b/packages/web/src/components/DataPixel.tsx @@ -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' @@ -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 @@ -44,15 +49,27 @@ export function DataPixel({ onPixelClick, delayMs, dataOverride, + paused = false, }: DataPixelProps) { const pixelRef = useRef(null) const labelRef = useRef(null) const rafRef = useRef(0) const startTimeRef = useRef(0) + const lastTickTimeRef = useRef(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 @@ -77,15 +94,26 @@ export function DataPixel({ ) if (!edgePath) return - // Get the viewport transform from React Flow - const viewport = container.querySelector('.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) @@ -96,41 +124,36 @@ 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() @@ -138,6 +161,7 @@ export function DataPixel({ } startTimeRef.current = 0 + lastTickTimeRef.current = 0 rafRef.current = requestAnimationFrame(tick) }, [edgeId, reverse, containerRef, delayMs]) diff --git a/packages/web/src/components/FlowCanvas.tsx b/packages/web/src/components/FlowCanvas.tsx index ba78f16..6dcc031 100644 --- a/packages/web/src/components/FlowCanvas.tsx +++ b/packages/web/src/components/FlowCanvas.tsx @@ -685,11 +685,14 @@ function FlowCanvasInner({ 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) => ( ) {
) { )}
- {/* Label — floats below building, no background */} - - {label} - + + {label} + + {/* Progress bar — 56px wide, matches building width */} {progressTotal > 0 && ( diff --git a/packages/web/src/hooks/useFlowAnimation.ts b/packages/web/src/hooks/useFlowAnimation.ts index 05f12cf..b87deec 100644 --- a/packages/web/src/hooks/useFlowAnimation.ts +++ b/packages/web/src/hooks/useFlowAnimation.ts @@ -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(0) + const pauseOffsetMsRef = useRef(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) @@ -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(), - activeEdgeFlows: [], - activeFromIds: new Set(), - activeToIds: new Set(), - 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(), + activeEdgeFlows: [], + activeFromIds: new Set(), + activeToIds: new Set(), + 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(), - activeEdgeFlows: [], - activeFromIds: new Set(), - activeToIds: new Set(), - activeStep: null, - activeNodes: new Set(), - destroyedNodes: new Set(), - })) + // 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) diff --git a/packages/web/src/lib/flow-layout.ts b/packages/web/src/lib/flow-layout.ts index 06a4840..2a6e904 100644 --- a/packages/web/src/lib/flow-layout.ts +++ b/packages/web/src/lib/flow-layout.ts @@ -854,6 +854,16 @@ function buildElkGraph(topology: FlowTopology, portAssignments?: Map --json` (or `openhop push - --json` for stdin) - `type` must be one of the 12 enum values (see Schema Reference below). `transform`, `validation`, `redis`, `oauth`, etc. are **not** valid types. - `data` is a `string` or an object — never a list. `data: "request"` ✓, `data: { label: "request", fields: [...] }` ✓, `data: [{ name: "request" }]` ✗ - `id` is alphanumeric + hyphens + underscores only. +- **Node `label` must be ≤ 4 words.** Labels render under each node in a fixed-width box; longer labels truncate with "…" and read poorly. Pick the shortest noun phrase that identifies the component. ✓ `Order Service`, `Auth API`, `Stripe`. ✗ `Order Processing Service With Retries`, `User Authentication and Authorization API`. If the validator rejects your flow, **read the error path** — it tells you exactly which field is wrong. @@ -260,7 +261,7 @@ flow: ### Node - `id` (required): alphanumeric + hyphens + underscores -- `label` (required): display name — **freeform**, anything human-readable (`"Stripe Payment Gateway"`, `"Customer #1"`, `"Order Service v2"`) +- `label` (required): display name — short noun phrase, **≤ 4 words** so it fits the fixed-width label slot (`"Stripe"`, `"Order Service"`, `"Auth API"`). Longer labels truncate with "…". - `type`: **closed enum, exactly one of**: `actor | endpoint | auth | database | external | cache | queue | service | docker | k8s | scheduler | custom`. Anything else fails validation. Default if omitted: `service`. - `icon`: Iconify icon ID (e.g. `"logos:postgresql"`) — overlays on top of the node's pixel art. Works on any `type`, not just `custom`. Browse: https://icon-sets.iconify.design/logos/ - `color`: hex color