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:
parent
4cf1e7279b
commit
6b29141d1a
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue