feat: add +/- buttons on flow edges for inline allocation adjustment

Custom AllocationEdge component renders clickable +/- controls at each
edge's midpoint, allowing users to increment/decrement allocation
percentages by 5% without opening the funnel edit modal. Sibling
allocations are automatically renormalized to sum to 100%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 09:25:11 -07:00
parent 4cf1e7279b
commit 6b29141d1a
3 changed files with 214 additions and 30 deletions

View File

@ -18,6 +18,7 @@ import '@xyflow/react/dist/style.css'
import FunnelNode from './nodes/FunnelNode'
import OutcomeNode from './nodes/OutcomeNode'
import AllocationEdge from './edges/AllocationEdge'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
@ -26,8 +27,15 @@ const nodeTypes = {
outcome: OutcomeNode,
}
const edgeTypes = {
allocation: AllocationEdge,
}
// Generate edges with proportional Sankey-style widths
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
function generateEdges(
nodes: FlowNode[],
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
): FlowEdge[] {
const edges: FlowEdge[] = []
const flowValues: number[] = []
@ -54,6 +62,7 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
const sourceX = node.position.x
const rate = data.inflowRate || 1
const overflowCount = data.overflowAllocations?.length ?? 0
data.overflowAllocations?.forEach((alloc) => {
const flowValue = (alloc.percentage / 100) * rate
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
@ -83,27 +92,20 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
width: 16,
height: 16,
},
label: `${alloc.percentage}%`,
labelStyle: {
fontSize: 11,
fontWeight: 600,
fill: alloc.color,
},
labelBgStyle: {
fill: 'white',
fillOpacity: 0.9,
},
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
data: {
allocation: alloc.percentage,
color: alloc.color,
edgeType: 'overflow' as const,
sourceId: node.id,
targetId: alloc.targetId,
siblingCount: overflowCount,
onAdjust,
},
type: 'smoothstep',
type: 'allocation',
})
})
const spendingCount = data.spendingAllocations?.length ?? 0
data.spendingAllocations?.forEach((alloc) => {
const flowValue = (alloc.percentage / 100) * rate
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
@ -125,24 +127,16 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
width: 16,
height: 16,
},
label: `${alloc.percentage}%`,
labelStyle: {
fontSize: 11,
fontWeight: 600,
fill: alloc.color,
},
labelBgStyle: {
fill: 'white',
fillOpacity: 0.9,
},
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
data: {
allocation: alloc.percentage,
color: alloc.color,
edgeType: 'spending' as const,
sourceId: node.id,
targetId: alloc.targetId,
siblingCount: spendingCount,
onAdjust,
},
type: 'smoothstep',
type: 'allocation',
})
})
})
@ -158,7 +152,7 @@ interface FlowCanvasInnerProps {
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initNodes))
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
const edgesRef = useRef(edges)
edgesRef.current = edges
@ -173,6 +167,54 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
}
}, [nodes, onNodesChange])
// Adjust allocation percentage inline from edge +/- buttons
const onAdjustAllocation = useCallback(
(sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => {
setNodes((nds) => nds.map((node) => {
if (node.id !== sourceId || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations'
const allocs = [...data[allocKey]]
if (allocs.length <= 1) return node
const idx = allocs.findIndex(a => a.targetId === targetId)
if (idx === -1) return node
const current = allocs[idx].percentage
const newPct = Math.max(5, Math.min(95, current + delta))
const actualDelta = newPct - current
if (actualDelta === 0) return node
// Apply delta to target, distribute inverse across siblings
const siblings = allocs.filter((_, i) => i !== idx)
const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0)
const updated = allocs.map((a, i) => {
if (i === idx) return { ...a, percentage: newPct }
// Proportionally adjust siblings
const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length
return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) }
})
// Normalize to exactly 100
const sum = updated.reduce((s, a) => s + a.percentage, 0)
if (sum !== 100 && updated.length > 1) {
const diff = 100 - sum
// Apply rounding correction to largest sibling
const largestSibIdx = updated.reduce((best, a, i) =>
i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0)
updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff }
}
return {
...node,
data: { ...data, [allocKey]: updated },
}
}))
},
[setNodes]
)
// Smart edge regeneration
const allocationsKey = useMemo(() => {
return JSON.stringify(
@ -191,9 +233,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
}, [nodes])
useEffect(() => {
setEdges(generateEdges(nodes as FlowNode[]))
setEdges(generateEdges(nodes as FlowNode[], onAdjustAllocation))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allocationsKey])
}, [allocationsKey, onAdjustAllocation])
const onConnect = useCallback(
(params: Connection) => {
@ -436,6 +478,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
onConnect={onConnect}
onReconnect={onReconnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
edgesReconnectable={true}
fitView
fitViewOptions={{ padding: 0.15 }}

View File

@ -0,0 +1,137 @@
'use client'
import { useCallback } from 'react'
import {
getSmoothStepPath,
EdgeLabelRenderer,
BaseEdge,
type EdgeProps,
} from '@xyflow/react'
import type { FlowEdgeData } from '@/lib/types'
export default function AllocationEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
style,
markerEnd,
}: EdgeProps) {
const edgeData = data as FlowEdgeData | undefined
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
})
const allocation = edgeData?.allocation ?? 0
const color = edgeData?.color ?? '#94a3b8'
const hasSiblings = (edgeData?.siblingCount ?? 1) > 1
const handleAdjust = useCallback(
(delta: number) => {
if (!edgeData?.onAdjust || !edgeData.sourceId || !edgeData.targetId || !edgeData.edgeType) return
edgeData.onAdjust(edgeData.sourceId, edgeData.targetId, edgeData.edgeType, delta)
},
[edgeData]
)
return (
<>
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
<EdgeLabelRenderer>
<div
className="nodrag nopan"
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
display: 'flex',
alignItems: 'center',
gap: '2px',
}}
>
{hasSiblings && (
<button
onClick={() => handleAdjust(-5)}
disabled={allocation <= 5}
style={{
width: 18,
height: 18,
borderRadius: 4,
border: `1.5px solid ${color}`,
background: 'white',
color,
fontSize: 13,
fontWeight: 700,
lineHeight: '14px',
cursor: allocation <= 5 ? 'not-allowed' : 'pointer',
opacity: allocation <= 5 ? 0.4 : 0.85,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={(e) => { if (allocation > 5) e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = allocation <= 5 ? '0.4' : '0.85' }}
>
</button>
)}
<span
style={{
fontSize: 11,
fontWeight: 600,
color,
background: 'rgba(255,255,255,0.92)',
padding: '1px 4px',
borderRadius: 4,
minWidth: 32,
textAlign: 'center',
userSelect: 'none',
}}
>
{Math.round(allocation)}%
</span>
{hasSiblings && (
<button
onClick={() => handleAdjust(5)}
disabled={allocation >= 95}
style={{
width: 18,
height: 18,
borderRadius: 4,
border: `1.5px solid ${color}`,
background: 'white',
color,
fontSize: 13,
fontWeight: 700,
lineHeight: '14px',
cursor: allocation >= 95 ? 'not-allowed' : 'pointer',
opacity: allocation >= 95 ? 0.4 : 0.85,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={(e) => { if (allocation < 95) e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = allocation >= 95 ? '0.4' : '0.85' }}
>
+
</button>
)}
</div>
</EdgeLabelRenderer>
</>
)
}

View File

@ -43,6 +43,10 @@ export interface FlowEdgeData {
allocation: number // percentage 0-100
color: string
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
sourceId: string
targetId: string
siblingCount: number // how many allocations in this group
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
[key: string]: unknown
}