diff --git a/packages/web/__tests__/flow-layout.test.ts b/packages/web/__tests__/flow-layout.test.ts index ae1034a..9bdd810 100644 --- a/packages/web/__tests__/flow-layout.test.ts +++ b/packages/web/__tests__/flow-layout.test.ts @@ -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', () => { diff --git a/packages/web/__tests__/pixel-palette.test.ts b/packages/web/__tests__/pixel-palette.test.ts new file mode 100644 index 0000000..7082674 --- /dev/null +++ b/packages/web/__tests__/pixel-palette.test.ts @@ -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, + }) + }) +}) diff --git a/packages/web/src/components/DataPixel.tsx b/packages/web/src/components/DataPixel.tsx index dabf31d..648055a 100644 --- a/packages/web/src/components/DataPixel.tsx +++ b/packages/web/src/components/DataPixel.tsx @@ -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 = { - 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 isManual?: boolean @@ -36,8 +34,9 @@ const ANIMATION_DURATION_BASE = 1800 export function DataPixel({ edgeId, reverse = false, - sourceNodeType, sourceNodeColor, + pixelColor, + pixelFilter, step, containerRef, isManual, @@ -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 @@ -196,6 +193,10 @@ export function DataPixel({ height: '100%', imageRendering: 'pixelated', display: 'block', + // Hue-rotate is applied directly to the 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, }} /> diff --git a/packages/web/src/components/FlowCanvas.tsx b/packages/web/src/components/FlowCanvas.tsx index 9b09cf5..ba78f16 100644 --- a/packages/web/src/components/FlowCanvas.tsx +++ b/packages/web/src/components/FlowCanvas.tsx @@ -19,7 +19,55 @@ import { useFlowAnimation, type EdgeFlowRef, type StepEdgeMapping } from '../hoo 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, @@ -226,11 +274,26 @@ function FlowCanvasInner({ [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() + const topology = buildFlowTopology(flow) + const explicitColors = new Map() 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() + for (const [id, snapshot] of topology.nodeSnapshots) { + map.set(id, { + type: snapshot.nodeType, + color: explicitColors.get(id) ?? topology.nodeVariants.get(id)?.color, + }) } return map }, [flow]) @@ -409,16 +472,20 @@ function FlowCanvasInner({ 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, }) } }, @@ -592,42 +659,32 @@ function FlowCanvasInner({ /> - {/* 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) => ( - handlePinPopup(s, pos, edgeFlow.edgeId)} - delayMs={dataIndex * 120} - dataOverride={dataObj} - /> - )) - } - + const key = + `${edgeFlow.edgeId}-${edgeFlow.reverse ? 'r' : 'f'}-${edgeFlowIndex}` + + (dataIndex !== undefined ? `-${dataIndex}` : '') return ( handlePinPopup(s, pos, edgeFlow.edgeId)} + delayMs={plan.delayMs || undefined} + dataOverride={dataObj} /> ) })} @@ -638,8 +695,11 @@ function FlowCanvasInner({ 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 diff --git a/packages/web/src/hooks/useFlowAnimation.ts b/packages/web/src/hooks/useFlowAnimation.ts index 93dd7d2..a803857 100644 --- a/packages/web/src/hooks/useFlowAnimation.ts +++ b/packages/web/src/hooks/useFlowAnimation.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import type { FlowStep } from '../types' +import type { FlowStep, FlowData } from '../types' export interface EdgeFlowRef { edgeId: string @@ -16,8 +16,18 @@ export interface ManualPixel { step: FlowStep sourceNodeId: string sourceStepIndex: number - sourceNodeType: string sourceNodeColor?: string + /** Per-pixel color override (used when expanding a multi-data step into + * one pixel per data entry, so each click-to-fire carrot gets a distinct + * hue from the variant palette). */ + pixelColor?: string + /** CSS filter to stack on the sprite , paired with pixelColor. */ + pixelFilter?: string + /** Single data object to render this pixel for (set when expanding a + * multi-data step). */ + dataOverride?: FlowData + /** Render delay in ms — used to stagger multi-data carrots. */ + delayMs?: number } export interface AnimationState { diff --git a/packages/web/src/lib/flow-layout.ts b/packages/web/src/lib/flow-layout.ts index 274e87e..06a4840 100644 --- a/packages/web/src/lib/flow-layout.ts +++ b/packages/web/src/lib/flow-layout.ts @@ -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' const elk = new ELK() @@ -37,6 +38,9 @@ export type FlowTopology = { nodeSnapshots: Map 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 } export type ElKLayoutResult = { @@ -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, } } @@ -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() - - // 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[] = 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, diff --git a/packages/web/src/lib/pixel-palette.ts b/packages/web/src/lib/pixel-palette.ts new file mode 100644 index 0000000..4f64c08 --- /dev/null +++ b/packages/web/src/lib/pixel-palette.ts @@ -0,0 +1,107 @@ +/** + * 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 +] + +export const VARIANT_ACCENT: readonly string[] = [ + '#ff8a4a', // orange + '#b47aff', // purple + '#4aff7a', // green + '#4a9eff', // blue + '#ff6b6b', // red +] + +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 { + const counters = new Map() + const out = new Map() + 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 when a step emits multiple carrots + * (multi-data, broadcast, or parallel). Each pixel cycles through + * VARIANT_ACCENT so they render with distinguishable shadows. + */ +export function stepPixelColor(index: number): string { + return VARIANT_ACCENT[index % VARIANT_ACCENT.length] +} + +/** + * Sprite-hue filter that pairs with `stepPixelColor(index)`. Apply this to + * the carrot so the visible sprite tint matches its drop-shadow — + * without it, all carrots stay orange (the sprite's original hue) and only + * the surrounding glow cycles. + */ +export function stepPixelFilter(index: number): string | undefined { + return VARIANT_FILTER[index % VARIANT_FILTER.length] || undefined +} + +/** + * Resolved per-carrot styling for one pixel within a step. + * `pixelColor`/`pixelFilter` are undefined when the step emits a single + * carrot (the source node's variant color is used instead) or when the + * data entry has its own explicit `color` (the filter is suppressed so + * the user's color isn't tinted further). + */ +export interface ResolvedStepPixel { + pixelColor: string | undefined + pixelFilter: string | undefined +} + +/** + * Resolve the carrot styling for one pixel given its global index in the + * step and the data entry it represents (if any). + * + * - When `cycle` is true (the step emits 2+ carrots) and the data entry + * has no explicit color, both pixelColor and pixelFilter come from the + * variant palette so each carrot in the step looks distinct. + * - An explicit `data.color` always wins; the sprite filter is dropped + * so we don't tint over the user-chosen color. + * - When `cycle` is false (single-carrot step) we leave both undefined, + * so DataPixel falls back to the source node's variant color. + */ +export function resolvePixelStyle( + cycle: boolean, + index: number, + dataColor?: string +): ResolvedStepPixel { + if (dataColor) return { pixelColor: dataColor, pixelFilter: undefined } + if (!cycle) return { pixelColor: undefined, pixelFilter: undefined } + return { pixelColor: stepPixelColor(index), pixelFilter: stepPixelFilter(index) } +}