feat: add glow edge system with directional selection animation
Custom bezier edges with gradient glow for the flow editor: - Default: subtle white/gray gradient with soft glow - Downstream (cyan): animated flowing dashes from selected node through subtree - Upstream (amber): animated flow from selected node back to root - Cross-reference: dashed cyan with arrow markers - SVG gradient + filter defs for performant rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
48
docs/plans/2026-03-09-glow-edge-design.md
Normal file
48
docs/plans/2026-03-09-glow-edge-design.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Glow Edge System — Flow Editor
|
||||
|
||||
> **Date:** 2026-03-09
|
||||
|
||||
## Overview
|
||||
|
||||
Replace flat `smoothstep` edges in the troubleshooting flow editor with custom bezier edges featuring gradient strokes, soft glow, and directional animation on node selection.
|
||||
|
||||
## Default Edges (no selection)
|
||||
|
||||
- **Curve type:** Bezier (replacing right-angled `smoothstep`)
|
||||
- **Stroke:** ~1.5px, subtle white/gray gradient with soft `drop-shadow` glow
|
||||
- **Feel:** Clean, understated, dark-mode friendly
|
||||
|
||||
## Selected Node — Downstream Edges
|
||||
|
||||
- **Color:** Cyan brand gradient (`#06b6d4` → `#22d3ee`)
|
||||
- **Animation:** Flowing dash animation moving downward (`stroke-dashoffset` keyframe)
|
||||
- **Scope:** All edges from selected node through entire subtree (children, grandchildren, etc.)
|
||||
- **Glow:** Soft cyan `drop-shadow` filter
|
||||
- **Stroke:** 2px
|
||||
|
||||
## Selected Node — Upstream Edges
|
||||
|
||||
- **Color:** Amber gradient (`#f59e0b` → `#fbbf24`)
|
||||
- **Animation:** Softer pulse/breathing opacity animation moving upward toward root
|
||||
- **Scope:** All edges from selected node back to root
|
||||
- **Glow:** Soft amber `drop-shadow` filter
|
||||
- **Stroke:** 2px
|
||||
|
||||
## Cross-reference Edges
|
||||
|
||||
- Keep dashed + animated + cyan with arrows
|
||||
- Use new bezier curves + glow treatment
|
||||
|
||||
## Implementation
|
||||
|
||||
- One custom `GlowEdge` component registered as the default edge type in React Flow
|
||||
- `useTreeLayout` passes `edgeState: 'default' | 'downstream' | 'upstream'` in edge data based on `selectedNodeId`
|
||||
- SVG `linearGradient` + `filter` defined once in a `<defs>` block
|
||||
- CSS keyframe animation for flowing dash effect
|
||||
|
||||
## Files Touched
|
||||
|
||||
- **New:** `frontend/src/components/tree-editor/GlowEdge.tsx` (~60 lines)
|
||||
- **Modified:** `useTreeLayout.ts` — add ancestor/descendant calculation, edge state
|
||||
- **Modified:** `FlowCanvas.tsx` — register custom edge type
|
||||
- **Modified:** `index.css` — keyframe animation for flowing dashes
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
PanOnScrollMode,
|
||||
type NodeMouseHandler,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
|
||||
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
|
||||
import { GlowEdge, GlowEdgeDefs } from './GlowEdge'
|
||||
import { useTreeLayout } from './useTreeLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Map as MapIcon, MapPinOff } from 'lucide-react'
|
||||
@@ -27,6 +27,10 @@ const nodeTypes = {
|
||||
answerStub: FlowCanvasAnswerNode,
|
||||
}
|
||||
|
||||
const edgeTypes = {
|
||||
glowEdge: GlowEdge,
|
||||
}
|
||||
|
||||
interface FlowCanvasProps {
|
||||
selectedNodeId: string | null
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
@@ -36,7 +40,7 @@ interface FlowCanvasProps {
|
||||
|
||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||
const { fitView, setCenter } = useReactFlow()
|
||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout(selectedNodeId)
|
||||
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||
|
||||
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
|
||||
@@ -124,6 +128,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
fitView
|
||||
@@ -139,6 +144,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="dark bg-accent/30"
|
||||
>
|
||||
<GlowEdgeDefs />
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="oklch(0.63 0.02 260 / 0.25)" />
|
||||
<Controls showInteractive={false} className="bg-card! border-border! shadow-lg!" />
|
||||
{minimapVisible && (
|
||||
|
||||
188
frontend/src/components/tree-editor/GlowEdge.tsx
Normal file
188
frontend/src/components/tree-editor/GlowEdge.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { memo } from 'react'
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react'
|
||||
|
||||
export type EdgeState = 'default' | 'downstream' | 'upstream' | 'crossref'
|
||||
|
||||
export interface GlowEdgeData {
|
||||
edgeState?: EdgeState
|
||||
label?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const EDGE_CONFIGS: Record<EdgeState, {
|
||||
gradientId: string
|
||||
strokeWidth: number
|
||||
filterClass: string
|
||||
animationClass: string
|
||||
}> = {
|
||||
default: {
|
||||
gradientId: 'edge-gradient-default',
|
||||
strokeWidth: 1.5,
|
||||
filterClass: 'glow-edge-default',
|
||||
animationClass: '',
|
||||
},
|
||||
downstream: {
|
||||
gradientId: 'edge-gradient-downstream',
|
||||
strokeWidth: 2,
|
||||
filterClass: 'glow-edge-downstream',
|
||||
animationClass: 'glow-edge-flow-downstream',
|
||||
},
|
||||
upstream: {
|
||||
gradientId: 'edge-gradient-upstream',
|
||||
strokeWidth: 2,
|
||||
filterClass: 'glow-edge-upstream',
|
||||
animationClass: 'glow-edge-flow-upstream',
|
||||
},
|
||||
crossref: {
|
||||
gradientId: 'edge-gradient-downstream',
|
||||
strokeWidth: 2,
|
||||
filterClass: 'glow-edge-downstream',
|
||||
animationClass: 'glow-edge-flow-downstream',
|
||||
},
|
||||
}
|
||||
|
||||
/** Shared SVG <defs> — render once inside the ReactFlow container. */
|
||||
export function GlowEdgeDefs() {
|
||||
return (
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
{/* Default: subtle white/gray */}
|
||||
<linearGradient id="edge-gradient-default" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(255,255,255,0.25)" />
|
||||
<stop offset="100%" stopColor="rgba(255,255,255,0.08)" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Downstream: cyan brand */}
|
||||
<linearGradient id="edge-gradient-downstream" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Upstream: amber */}
|
||||
<linearGradient id="edge-gradient-upstream" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stopColor="#f59e0b" />
|
||||
<stop offset="100%" stopColor="#fbbf24" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filters */}
|
||||
<filter id="glow-default" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-cyan" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-amber" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GlowEdgeComponent({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
label,
|
||||
labelStyle,
|
||||
labelBgStyle,
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const edgeState = (data as GlowEdgeData)?.edgeState ?? 'default'
|
||||
const config = EDGE_CONFIGS[edgeState]
|
||||
const isCrossref = edgeState === 'crossref'
|
||||
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const filterId = edgeState === 'default' ? 'glow-default'
|
||||
: edgeState === 'upstream' ? 'glow-amber'
|
||||
: 'glow-cyan'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Glow layer (wider, blurred) */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={`url(#${config.gradientId})`}
|
||||
strokeWidth={config.strokeWidth + 2}
|
||||
filter={`url(#${filterId})`}
|
||||
opacity={edgeState === 'default' ? 0.3 : 0.5}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
|
||||
{/* Main edge path */}
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
stroke: `url(#${config.gradientId})`,
|
||||
strokeWidth: config.strokeWidth,
|
||||
strokeDasharray: isCrossref ? '6 3' : undefined,
|
||||
}}
|
||||
className={config.animationClass || undefined}
|
||||
markerEnd={markerEnd}
|
||||
/>
|
||||
|
||||
{/* Animated dash overlay for downstream/upstream */}
|
||||
{(edgeState === 'downstream' || edgeState === 'upstream' || isCrossref) && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={`url(#${config.gradientId})`}
|
||||
strokeWidth={config.strokeWidth}
|
||||
strokeDasharray="8 12"
|
||||
className={config.animationClass}
|
||||
opacity={0.9}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<g transform={`translate(${labelX}, ${labelY})`}>
|
||||
<rect
|
||||
x={-((String(label).length * 5.5 + 8) / 2)}
|
||||
y={-9}
|
||||
width={String(label).length * 5.5 + 8}
|
||||
height={18}
|
||||
rx={4}
|
||||
style={labelBgStyle as React.CSSProperties ?? { fill: 'var(--color-card)', fillOpacity: 0.9 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={labelStyle as React.CSSProperties ?? { fill: 'var(--color-muted-foreground)', fontSize: 11 }}
|
||||
>
|
||||
{String(label)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const GlowEdge = memo(GlowEdgeComponent)
|
||||
@@ -69,6 +69,41 @@ function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; targ
|
||||
return refs
|
||||
}
|
||||
|
||||
/** Collect all descendant node IDs of a given node (not including itself). */
|
||||
function collectDescendantIds(root: TreeStructure, targetId: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
function findAndCollect(node: TreeStructure): boolean {
|
||||
if (node.id === targetId) {
|
||||
// Found — collect all children recursively
|
||||
function collectAll(n: TreeStructure) {
|
||||
n.children?.forEach(c => { ids.add(c.id); collectAll(c) })
|
||||
}
|
||||
collectAll(node)
|
||||
return true
|
||||
}
|
||||
return node.children?.some(findAndCollect) ?? false
|
||||
}
|
||||
findAndCollect(root)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Collect all ancestor node IDs from a node up to root. */
|
||||
function collectAncestorIds(root: TreeStructure, targetId: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
function walk(node: TreeStructure, path: string[]): boolean {
|
||||
if (node.id === targetId) {
|
||||
path.forEach(id => ids.add(id))
|
||||
return true
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
if (walk(child, [...path, node.id])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
walk(root, [])
|
||||
return ids
|
||||
}
|
||||
|
||||
interface UseTreeLayoutResult {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
@@ -77,7 +112,7 @@ interface UseTreeLayoutResult {
|
||||
onNodesMeasured: (measuredNodes: Node[]) => void
|
||||
}
|
||||
|
||||
export function useTreeLayout(): UseTreeLayoutResult {
|
||||
export function useTreeLayout(selectedNodeId?: string | null): UseTreeLayoutResult {
|
||||
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
||||
const validationErrors = useTreeEditorStore(s => s.validationErrors)
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||
@@ -93,6 +128,15 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Compute ancestor/descendant sets for selected node
|
||||
const { descendantIds, ancestorIds } = useMemo(() => {
|
||||
if (!treeStructure || !selectedNodeId) return { descendantIds: new Set<string>(), ancestorIds: new Set<string>() }
|
||||
return {
|
||||
descendantIds: collectDescendantIds(treeStructure, selectedNodeId),
|
||||
ancestorIds: collectAncestorIds(treeStructure, selectedNodeId),
|
||||
}
|
||||
}, [treeStructure, selectedNodeId])
|
||||
|
||||
// Convert tree structure to flat nodes and edges
|
||||
const { rawNodes, rawEdges } = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
@@ -100,6 +144,21 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
|
||||
if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }
|
||||
|
||||
/** Determine edge state based on source/target relation to selected node */
|
||||
function getEdgeState(sourceId: string, targetId: string): 'default' | 'downstream' | 'upstream' {
|
||||
if (!selectedNodeId) return 'default'
|
||||
// Downstream: selected node → child, or descendant → descendant's child
|
||||
if ((sourceId === selectedNodeId && descendantIds.has(targetId)) ||
|
||||
(descendantIds.has(sourceId) && descendantIds.has(targetId))) {
|
||||
return 'downstream'
|
||||
}
|
||||
// Upstream: ancestor → ancestor, or ancestor → selected node
|
||||
if ((ancestorIds.has(sourceId) && (ancestorIds.has(targetId) || targetId === selectedNodeId))) {
|
||||
return 'upstream'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function walk(node: TreeStructure, _parentId?: string | null) {
|
||||
const isCollapsed = collapsedNodeIds.has(node.id)
|
||||
@@ -148,17 +207,18 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
|
||||
for (const { child, optionLabel } of orderedChildren) {
|
||||
const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined
|
||||
const edgeState = getEdgeState(node.id, child.id)
|
||||
|
||||
edges.push({
|
||||
id: `${node.id}->${child.id}`,
|
||||
source: node.id,
|
||||
target: child.id,
|
||||
type: 'smoothstep',
|
||||
type: 'glowEdge',
|
||||
label: edgeLabel,
|
||||
labelStyle: { fill: 'var(--color-muted-foreground)', fontSize: 11 },
|
||||
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.9 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
style: { stroke: 'var(--color-border)' },
|
||||
data: { edgeState },
|
||||
})
|
||||
|
||||
walk(child, node.id)
|
||||
@@ -168,11 +228,10 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
|
||||
walk(treeStructure, null)
|
||||
|
||||
// Add cross-reference edges (dashed, purple)
|
||||
// Add cross-reference edges
|
||||
if (treeStructure) {
|
||||
const crossRefs = collectCrossRefEdges(treeStructure)
|
||||
for (const ref of crossRefs) {
|
||||
// Only add if both source and target nodes are visible (not collapsed away)
|
||||
const sourceVisible = nodes.some(n => n.id === ref.source)
|
||||
const targetVisible = nodes.some(n => n.id === ref.target)
|
||||
if (sourceVisible && targetVisible) {
|
||||
@@ -180,17 +239,12 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
id: `xref-${ref.source}->${ref.target}`,
|
||||
source: ref.source,
|
||||
target: ref.target,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
type: 'glowEdge',
|
||||
label: ref.label ? truncateLabel(ref.label) : undefined,
|
||||
labelStyle: { fill: 'var(--color-primary)', fontSize: 10, fontWeight: 500 },
|
||||
labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.95 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
style: {
|
||||
stroke: 'var(--color-primary)',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 3',
|
||||
},
|
||||
data: { edgeState: 'crossref' as const },
|
||||
markerEnd: {
|
||||
type: 'arrowclosed' as const,
|
||||
color: 'var(--color-primary)',
|
||||
@@ -203,7 +257,7 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
}
|
||||
|
||||
return { rawNodes: nodes, rawEdges: edges }
|
||||
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
|
||||
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights, selectedNodeId, descendantIds, ancestorIds])
|
||||
|
||||
// Run dagre layout
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user