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
50 changes: 50 additions & 0 deletions packages/web/__tests__/pixel-palette.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { VARIANT_ACCENT, assignNodeVariants, multiDataPixelColor } 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('multiDataPixelColor', () => {
it('returns palette[index] and wraps around at the end', () => {
expect(multiDataPixelColor(0)).toBe(VARIANT_ACCENT[0])
expect(multiDataPixelColor(1)).toBe(VARIANT_ACCENT[1])
expect(multiDataPixelColor(VARIANT_ACCENT.length)).toBe(VARIANT_ACCENT[0])
})
})
17 changes: 12 additions & 5 deletions packages/web/src/components/DataPixel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ interface DataPixelProps {
edgeId: string
reverse?: boolean
sourceNodeType: string
/** Source node's variant color — drives the pixel drop-shadow when
* pixelColor is not set. */
sourceNodeColor?: string
/** Per-pixel override (multi-data palette cycling, or explicit
* data.color). Wins over sourceNodeColor and the type fallback. */
pixelColor?: string
step: FlowStep
containerRef: React.RefObject<HTMLDivElement | null>
isManual?: boolean
Expand All @@ -38,6 +43,7 @@ export function DataPixel({
reverse = false,
sourceNodeType,
sourceNodeColor,
pixelColor,
step,
containerRef,
isManual,
Expand All @@ -56,11 +62,12 @@ 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')
// Pixel drop-shadow color resolution order:
// 1. explicit pixelColor (multi-data palette cycling, or data.color)
// 2. sourceNodeColor (variant color computed from topology — keeps the
// shadow in lockstep with the sprite hue)
// 3. legacy NODE_COLORS by type, kept as a fallback
const color = pixelColor ?? sourceNodeColor ?? NODE_COLORS[sourceNodeType] ?? '#888'

const dataLabel = dataOverride
? dataOverride.label
Expand Down
30 changes: 27 additions & 3 deletions packages/web/src/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import { useFlowGraphLayout } from '../hooks/useFlowGraphLayout'
import { DataPixel } from './DataPixel'
import { DataPopup } from './DataPopup'
import { buildFlowTopology } from '../lib/flow-layout'
import { multiDataPixelColor } from '../lib/pixel-palette'
import type { Flow, FlowStep } from '../types'

const nodeTypes: NodeTypes = {
Expand Down Expand Up @@ -226,11 +228,22 @@
[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.
const nodeTypeMap = useMemo(() => {
const topology = buildFlowTopology(flow)
const map = new Map<string, { type: string; color?: 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). Other nodes use
// the variant accent color from the shared palette.
const explicit = n.type === 'custom' && n.color ? n.color : undefined
map.set(n.id, {
type: n.type ?? 'service',
color: explicit ?? topology.nodeVariants.get(n.id)?.color,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return map
}, [flow])
Expand Down Expand Up @@ -487,7 +500,7 @@
},
}
})
}, [

Check warning on line 503 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

Check warning on line 503 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
baseNodes,
animState.activeFromIds,
animState.activeToIds,
Expand Down Expand Up @@ -600,7 +613,9 @@
}
const edgeStep = edgeFlow.step ?? animState.activeStep!

// If the step has array data, render one pixel per data object with stagger
// Multi-data: render one pixel per data object with stagger AND a
// distinct shadow hue cycling through the variant palette. An
// explicit `data[i].color` always wins.
if (Array.isArray(edgeStep.data)) {
return edgeStep.data.map((dataObj, dataIndex) => (
<DataPixel
Expand All @@ -609,6 +624,7 @@
reverse={edgeFlow.reverse}
sourceNodeType={sourceInfo.type}
sourceNodeColor={sourceInfo.color}
pixelColor={dataObj.color ?? multiDataPixelColor(dataIndex)}
step={edgeStep}
containerRef={containerRef}
onPixelClick={(s, pos) => handlePinPopup(s, pos, edgeFlow.edgeId)}
Expand All @@ -618,13 +634,21 @@
))
}

// Single-object data: respect explicit `data.color`; otherwise the
// pixel falls back to the source node's variant color via the
// sourceNodeColor pipe.
const singleColor =
edgeStep.data && typeof edgeStep.data === 'object' && !Array.isArray(edgeStep.data)
? edgeStep.data.color
: undefined
return (
<DataPixel
key={`${edgeFlow.edgeId}-${edgeFlow.reverse ? 'r' : 'f'}-${edgeFlowIndex}`}
edgeId={edgeFlow.edgeId}
reverse={edgeFlow.reverse}
sourceNodeType={sourceInfo.type}
sourceNodeColor={sourceInfo.color}
pixelColor={singleColor}
step={edgeStep}
containerRef={containerRef}
onPixelClick={(s, pos) => handlePinPopup(s, pos, edgeFlow.edgeId)}
Expand Down
49 changes: 15 additions & 34 deletions packages/web/src/lib/flow-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ELK from 'elkjs'
import type { Edge, Node } from '@xyflow/react'
import type { Flow } from '../types'
import type { FlowNodeData } from '../components/nodes/FlowNode'
import { assignNodeVariants, type NodeVariant } from './pixel-palette'
Comment thread
naorsabag marked this conversation as resolved.

const elk = new ELK()

Expand Down Expand Up @@ -37,6 +38,9 @@ export type FlowTopology = {
nodeSnapshots: Map<string, NodeSnapshot>
displayEdges: DisplayEdgeSpec[]
layoutEdges: Array<[string, string]>
/** Per-node sprite + accent variant. Computed once over orderedIds so any
* consumer (sprite filter, animated pixel shadow) lands on the same hue. */
nodeVariants: Map<string, NodeVariant>
}

export type ElKLayoutResult = {
Expand Down Expand Up @@ -246,11 +250,16 @@ export function buildFlowTopology(flow: Flow): FlowTopology {
layoutNodeIds.add(source)
}

const nodeVariants = assignNodeVariants(
ordered.map((id) => ({ id, type: nodeSnapshots.get(id)?.nodeType ?? 'service' }))
)

return {
orderedIds: ordered,
nodeSnapshots,
displayEdges,
layoutEdges,
nodeVariants,
}
}

Expand Down Expand Up @@ -710,44 +719,16 @@ export function buildReactFlowGraph(
incomingByTarget.set(edge.target, incoming)
}

// Color-variant cycle: when several nodes share the same sprite and have no
// custom icon, each successive one gets a different CSS filter so viewers
// can tell them apart at a glance. The accent palette parallels the sprite
// filter so the label, drop-shadow, and progress bar match the sprite's
// visible hue.
const VARIANT_CYCLE: string[] = [
'', // original (orange)
'hue-rotate(210deg)', // purple
'hue-rotate(90deg)', // green
'hue-rotate(140deg)', // blue
'hue-rotate(320deg)', // red
'hue-rotate(60deg) saturate(1.2)', // yellow (was grey)
]
const VARIANT_ACCENT: string[] = [
'#ff8a4a', // orange
'#b47aff', // purple
'#4aff7a', // green
'#4a9eff', // blue
'#ff6b6b', // red
'#ffd84a', // yellow
]
const spriteVariantCounters = new Map<string, number>()

// Nodes that fall back to the service sprite (e.g. `custom`) share the same
// variant counter, so five `custom` + five `service` nodes cycle through
// the six colors as a single pool instead of restarting per type.
const FALLBACK_SPRITE_KEY = 'service'
const TYPES_SHARING_SERVICE_SPRITE = new Set(['service', 'custom'])

// Per-node variant: precomputed once on the topology so the sprite filter
// here and the data-pixel drop-shadow in FlowCanvas read from the same
// palette index.
const nodes: Node<FlowNodeData>[] = topology.orderedIds.map((id) => {
const snapshot = topology.nodeSnapshots.get(id)
const position = positionMap.get(id) ?? defaultPosition()
const nodeType = snapshot?.nodeType ?? 'service'
const counterKey = TYPES_SHARING_SERVICE_SPRITE.has(nodeType) ? FALLBACK_SPRITE_KEY : nodeType
const n = spriteVariantCounters.get(counterKey) ?? 0
const variantFilter = VARIANT_CYCLE[n % VARIANT_CYCLE.length] || undefined
const variantColor = VARIANT_ACCENT[n % VARIANT_ACCENT.length]
spriteVariantCounters.set(counterKey, n + 1)
const variant = topology.nodeVariants.get(id)
const variantFilter = variant?.filter
const variantColor = variant?.color ?? '#ff8a4a'

return {
id,
Expand Down
65 changes: 65 additions & 0 deletions packages/web/src/lib/pixel-palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Shared color-variant palette for sprite filters and animated pixel shadows.
* When several nodes resolve to the same fallback sprite, each successive
* one cycles to a different hue — both the node's CSS filter (sprite tint)
* and the data pixel's drop-shadow read from this palette so they always
* agree.
*/

export const VARIANT_FILTER: readonly string[] = [
'', // original (orange)
'hue-rotate(210deg)', // purple
'hue-rotate(90deg)', // green
'hue-rotate(140deg)', // blue
'hue-rotate(320deg)', // red
'hue-rotate(60deg) saturate(1.2)', // yellow
]

export const VARIANT_ACCENT: readonly string[] = [
'#ff8a4a', // orange
'#b47aff', // purple
'#4aff7a', // green
'#4a9eff', // blue
'#ff6b6b', // red
'#ffd84a', // yellow
]

const FALLBACK_SPRITE_KEY = 'service'
const TYPES_SHARING_SERVICE_SPRITE = new Set(['service', 'custom'])

export interface NodeVariant {
filter: string | undefined
color: string
}

/**
* Compute per-node variant assignments. `nodes` MUST be passed in the same
* canonical order across all callers (this is what `topology.orderedIds`
* provides) so flow-layout's sprite filter and FlowCanvas's pixel shadow
* land on the same palette index for the same node.
*/
export function assignNodeVariants(
nodes: ReadonlyArray<{ id: string; type: string }>
): Map<string, NodeVariant> {
const counters = new Map<string, number>()
const out = new Map<string, NodeVariant>()
for (const node of nodes) {
const counterKey = TYPES_SHARING_SERVICE_SPRITE.has(node.type) ? FALLBACK_SPRITE_KEY : node.type
const n = counters.get(counterKey) ?? 0
counters.set(counterKey, n + 1)
out.set(node.id, {
filter: VARIANT_FILTER[n % VARIANT_FILTER.length] || undefined,
color: VARIANT_ACCENT[n % VARIANT_ACCENT.length],
})
}
return out
}

/**
* Default color for the index-th pixel in a multi-data step. Each pixel
* cycles through VARIANT_ACCENT so multi-payload steps render with
* distinguishable shadows.
*/
export function multiDataPixelColor(index: number): string {
return VARIANT_ACCENT[index % VARIANT_ACCENT.length]
}
Loading