Skip to content
14 changes: 14 additions & 0 deletions packages/web/__tests__/flow-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ describe('buildFlowTopology', () => {
['order-service', 'audit'],
])
})

it('assigns the same variant color to first nodes of different sprite pools (matching sprite hue)', () => {
// api is `endpoint`, order-service is `service`. Both are the first node
// in their respective sprite pools, so both render with the original
// (orange) sprite — and both get the same VARIANT_ACCENT[0] accent so
// their animated pixel drop-shadows match the visible sprite hue.
const topology = buildFlowTopology(orderFlow)
const apiVariant = topology.nodeVariants.get('api')
const svcVariant = topology.nodeVariants.get('order-service')
expect(apiVariant?.color).toBeDefined()
expect(svcVariant?.color).toBe(apiVariant?.color)
expect(apiVariant?.filter).toBeUndefined()
expect(svcVariant?.filter).toBeUndefined()
})
})

describe('computeElkLayout', () => {
Expand Down
92 changes: 92 additions & 0 deletions packages/web/__tests__/pixel-palette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
VARIANT_ACCENT,
assignNodeVariants,
resolvePixelStyle,
stepPixelColor,
stepPixelFilter,
} from '../src/lib/pixel-palette'

describe('assignNodeVariants', () => {
it('gives the first node of each independent type the original (orange) accent', () => {
const variants = assignNodeVariants([
{ id: 'api', type: 'endpoint' },
{ id: 'db', type: 'database' },
])
expect(variants.get('api')?.color).toBe(VARIANT_ACCENT[0])
expect(variants.get('db')?.color).toBe(VARIANT_ACCENT[0])
})

it('cycles successive same-type-pool nodes through the palette', () => {
const variants = assignNodeVariants([
{ id: 'svc-a', type: 'service' },
{ id: 'svc-b', type: 'service' },
{ id: 'svc-c', type: 'service' },
])
expect(variants.get('svc-a')?.color).toBe(VARIANT_ACCENT[0])
expect(variants.get('svc-b')?.color).toBe(VARIANT_ACCENT[1])
expect(variants.get('svc-c')?.color).toBe(VARIANT_ACCENT[2])
})

it('shares a counter between service and custom (they fall back to the same sprite)', () => {
const variants = assignNodeVariants([
{ id: 'svc', type: 'service' },
{ id: 'cust', type: 'custom' },
])
expect(variants.get('svc')?.color).toBe(VARIANT_ACCENT[0])
expect(variants.get('cust')?.color).toBe(VARIANT_ACCENT[1])
})

it('first variant has no filter, subsequent variants get a hue-rotate filter', () => {
const variants = assignNodeVariants([
{ id: 'a', type: 'service' },
{ id: 'b', type: 'service' },
])
expect(variants.get('a')?.filter).toBeUndefined()
expect(variants.get('b')?.filter).toBeTruthy()
})
})

describe('stepPixelColor', () => {
it('returns palette[index] and wraps around at the end', () => {
expect(stepPixelColor(0)).toBe(VARIANT_ACCENT[0])
expect(stepPixelColor(1)).toBe(VARIANT_ACCENT[1])
expect(stepPixelColor(VARIANT_ACCENT.length)).toBe(VARIANT_ACCENT[0])
})
})

describe('stepPixelFilter', () => {
it('returns undefined for index 0 (the original orange sprite, no filter)', () => {
expect(stepPixelFilter(0)).toBeUndefined()
})

it('returns a hue-rotate filter for subsequent indices', () => {
expect(stepPixelFilter(1)).toMatch(/hue-rotate/)
expect(stepPixelFilter(2)).toMatch(/hue-rotate/)
})
})

describe('resolvePixelStyle', () => {
it('cycles palette per pixel when the step has 2+ carrots and no explicit data color', () => {
expect(resolvePixelStyle(true, 0)).toEqual({
pixelColor: VARIANT_ACCENT[0],
pixelFilter: undefined,
})
expect(resolvePixelStyle(true, 1).pixelColor).toBe(VARIANT_ACCENT[1])
expect(resolvePixelStyle(true, 1).pixelFilter).toMatch(/hue-rotate/)
})

it('returns no overrides for single-carrot steps so DataPixel falls back to the variant color', () => {
expect(resolvePixelStyle(false, 0)).toEqual({
pixelColor: undefined,
pixelFilter: undefined,
})
})

it('respects an explicit data.color and suppresses the sprite filter', () => {
expect(resolvePixelStyle(true, 1, '#123456')).toEqual({
pixelColor: '#123456',
pixelFilter: undefined,
})
})
})
39 changes: 20 additions & 19 deletions packages/web/src/components/DataPixel.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import type { FlowStep, FlowData } from '../types'
import { DataTooltip } from './DataTooltip'
const CARROT_SPRITE = '/sprites/carrot_pixels.svg'

/** Node type colors — must match FlowNode.tsx NODE_STYLES */
const NODE_COLORS: Record<string, string> = {
actor: '#4a9eff',
endpoint: '#4a9eff',
transform: '#b47aff',
database: '#4aff7a',
external: '#ff8a4a',
cache: '#4affee',
queue: '#4aeeff',
service: '#888',
}
const CARROT_SPRITE = '/sprites/carrot_pixels.svg'
const DEFAULT_PIXEL_COLOR = '#ff8a4a' // VARIANT_ACCENT[0] — sprite's original orange.

interface DataPixelProps {
edgeId: string
reverse?: boolean
sourceNodeType: string
/** Source node's variant color from topology — drives the pixel
* drop-shadow when pixelColor isn't set. */
sourceNodeColor?: string
/** Per-pixel color override (palette cycling, or explicit data.color).
* Wins over sourceNodeColor. */
pixelColor?: string
/** Optional CSS hue-rotate applied to the carrot sprite so its body
* recolors to match pixelColor — without it, the orange sprite
* visually dominates the colored drop-shadow. */
pixelFilter?: string
step: FlowStep
containerRef: React.RefObject<HTMLDivElement | null>
isManual?: boolean
Expand All @@ -36,8 +34,9 @@ const ANIMATION_DURATION_BASE = 1800
export function DataPixel({
edgeId,
reverse = false,
sourceNodeType,
sourceNodeColor,
pixelColor,
pixelFilter,
step,
containerRef,
isManual,
Expand All @@ -56,11 +55,9 @@ export function DataPixel({
}, [onAnimationComplete])
const [hovered, setHovered] = useState(false)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
// Determine pixel color from source node type
const color =
sourceNodeType === 'custom' && sourceNodeColor
? sourceNodeColor
: (NODE_COLORS[sourceNodeType] ?? '#888')
// pixelColor (palette cycling / data.color) > sourceNodeColor (variant
// color from topology, keeps shadow in lockstep with sprite hue) > orange.
const color = pixelColor ?? sourceNodeColor ?? DEFAULT_PIXEL_COLOR

const dataLabel = dataOverride
? dataOverride.label
Expand Down Expand Up @@ -196,6 +193,10 @@ export function DataPixel({
height: '100%',
imageRendering: 'pixelated',
display: 'block',
// Hue-rotate is applied directly to the <img> so the carrot's
// own pixel colors shift (the wrapper's drop-shadow then reads
// from the rotated alpha, keeping the glow in sync).
filter: pixelFilter,
}}
/>
</div>
Expand Down
134 changes: 97 additions & 37 deletions packages/web/src/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,55 @@
import { useFlowGraphLayout } from '../hooks/useFlowGraphLayout'
import { DataPixel } from './DataPixel'
import { DataPopup } from './DataPopup'
import type { Flow, FlowStep } from '../types'
import { buildFlowTopology } from '../lib/flow-layout'
import { resolvePixelStyle, type ResolvedStepPixel } from '../lib/pixel-palette'
import type { Flow, FlowStep, FlowData } from '../types'

/** One carrot to render for a step. `dataObj` is set only for multi-data
* steps (one item per data entry). Cycling is applied across the WHOLE
* step so multi-data + broadcast + parallel carrots share one global
* index — any 2+ carrots in a step render distinct hues. */
interface StepPixelPlan extends ResolvedStepPixel {
edgeFlow: EdgeFlowRef
edgeFlowIndex: number
dataObj?: FlowData
dataIndex?: number
delayMs: number
}

function planStepPixels(edgeFlows: EdgeFlowRef[], fallbackStep?: FlowStep): StepPixelPlan[] {
const totalPixels = edgeFlows.reduce((sum, ef) => {
const data = (ef.step ?? fallbackStep)?.data
return sum + (Array.isArray(data) ? data.length : 1)
}, 0)
const cycle = totalPixels >= 2
const plans: StepPixelPlan[] = []
let pixelIdx = 0
edgeFlows.forEach((edgeFlow, edgeFlowIndex) => {
const data = (edgeFlow.step ?? fallbackStep)?.data
if (Array.isArray(data)) {
data.forEach((dataObj, dataIndex) => {
plans.push({
edgeFlow,
edgeFlowIndex,
dataObj,
dataIndex,
delayMs: dataIndex * 280,
...resolvePixelStyle(cycle, pixelIdx++, dataObj.color),
})
})
} else {
const singleColor = data && typeof data === 'object' ? data.color : undefined
plans.push({
edgeFlow,
edgeFlowIndex,
delayMs: 0,
...resolvePixelStyle(cycle, pixelIdx++, singleColor),
})
}
})
return plans
}

const nodeTypes: NodeTypes = {
flowNode: FlowNodeComponent,
Expand Down Expand Up @@ -226,11 +274,26 @@
[pinnedEdge, edgeStepsById]
)

// Build a map from node id to node type for pixel coloring
// Build a map from node id to type and shadow color for animated pixels.
// The shadow color comes from the topology's variant assignment so it
// stays in lockstep with the sprite filter cycle in flow-layout — same
// sprite hue → same pixel shadow. Iterating topology.nodeSnapshots
// (rather than just flow.flow.nodes) also covers dynamic nodes spawned
// by `create:` steps so their pixels get a real variant color too.
const nodeTypeMap = useMemo(() => {
const map = new Map<string, { type: string; color?: string }>()
const topology = buildFlowTopology(flow)
const explicitColors = new Map<string, string>()
for (const n of flow.flow.nodes) {
map.set(n.id, { type: n.type ?? 'service', color: n.color })
// A `custom`-typed node with an explicit hex color keeps that color
// (it already drives the sprite tint via FlowNode).
if (n.type === 'custom' && n.color) explicitColors.set(n.id, n.color)
}
const map = new Map<string, { type: string; color?: string }>()
for (const [id, snapshot] of topology.nodeSnapshots) {
map.set(id, {
type: snapshot.nodeType,
color: explicitColors.get(id) ?? topology.nodeVariants.get(id)?.color,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return map
}, [flow])
Expand Down Expand Up @@ -409,16 +472,20 @@
return // no pixel to fire
}

// Fire a pixel for EACH edge flow in this logical step (broadcast = multiple edges)
for (const edgeFlow of entry.edgeFlows) {
// Click-to-fire uses the same plan as autoplay so manual pixels get
// the same multi-data expansion + palette cycling.
for (const plan of planStepPixels(entry.edgeFlows, entry.step)) {
fireManualPixel({
edgeId: edgeFlow.edgeId,
reverse: edgeFlow.reverse,
step: edgeFlow.step,
edgeId: plan.edgeFlow.edgeId,
reverse: plan.edgeFlow.reverse,
step: plan.edgeFlow.step,
sourceNodeId: nodeId,
sourceStepIndex: currentProg,
sourceNodeType: sourceInfo.type,
sourceNodeColor: sourceInfo.color,
pixelColor: plan.pixelColor,
pixelFilter: plan.pixelFilter,
dataOverride: plan.dataObj,
delayMs: plan.delayMs || undefined,
})
}
},
Expand Down Expand Up @@ -487,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 @@ -592,42 +659,32 @@
/>
</ReactFlow>

{/* Data pixel overlay — automatic */}
{/* Data pixel overlay — automatic. planStepPixels handles the cycling
rule: any step emitting 2+ carrots (multi-data, broadcast, or
parallel) cycles the variant palette across the whole set so each
one is distinct; single-carrot steps keep the source node's
variant color via DataPixel's sourceNodeColor fallback. */}
{animState.activeStep &&
activeEdgeFlows.flatMap((edgeFlow, edgeFlowIndex) => {
const sourceInfo = nodeTypeMap.get(edgeFlow.fromId) ?? {
type: 'service',
}
planStepPixels(activeEdgeFlows, animState.activeStep).map((plan) => {
const { edgeFlow, edgeFlowIndex, dataObj, dataIndex } = plan
const sourceInfo = nodeTypeMap.get(edgeFlow.fromId) ?? { type: 'service' }
const edgeStep = edgeFlow.step ?? animState.activeStep!

// If the step has array data, render one pixel per data object with stagger
if (Array.isArray(edgeStep.data)) {
return edgeStep.data.map((dataObj, dataIndex) => (
<DataPixel
key={`${edgeFlow.edgeId}-${edgeFlow.reverse ? 'r' : 'f'}-${edgeFlowIndex}-${dataIndex}`}
edgeId={edgeFlow.edgeId}
reverse={edgeFlow.reverse}
sourceNodeType={sourceInfo.type}
sourceNodeColor={sourceInfo.color}
step={edgeStep}
containerRef={containerRef}
onPixelClick={(s, pos) => handlePinPopup(s, pos, edgeFlow.edgeId)}
delayMs={dataIndex * 120}
dataOverride={dataObj}
/>
))
}

const key =
`${edgeFlow.edgeId}-${edgeFlow.reverse ? 'r' : 'f'}-${edgeFlowIndex}` +
(dataIndex !== undefined ? `-${dataIndex}` : '')
return (
<DataPixel
key={`${edgeFlow.edgeId}-${edgeFlow.reverse ? 'r' : 'f'}-${edgeFlowIndex}`}
key={key}
edgeId={edgeFlow.edgeId}
reverse={edgeFlow.reverse}
sourceNodeType={sourceInfo.type}
sourceNodeColor={sourceInfo.color}
pixelColor={plan.pixelColor}
pixelFilter={plan.pixelFilter}
step={edgeStep}
containerRef={containerRef}
onPixelClick={(s, pos) => handlePinPopup(s, pos, edgeFlow.edgeId)}
delayMs={plan.delayMs || undefined}
dataOverride={dataObj}
/>
)
})}
Expand All @@ -638,8 +695,11 @@
key={mp.id}
edgeId={mp.edgeId}
reverse={mp.reverse}
sourceNodeType={mp.sourceNodeType}
sourceNodeColor={mp.sourceNodeColor}
pixelColor={mp.pixelColor}
pixelFilter={mp.pixelFilter}
dataOverride={mp.dataOverride}
delayMs={mp.delayMs}
step={mp.step}
containerRef={containerRef}
isManual
Expand Down
Loading
Loading