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 FunnelNode from './nodes/FunnelNode'
|
||||||
import OutcomeNode from './nodes/OutcomeNode'
|
import OutcomeNode from './nodes/OutcomeNode'
|
||||||
|
import AllocationEdge from './edges/AllocationEdge'
|
||||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||||
|
|
||||||
|
|
@ -26,8 +27,15 @@ const nodeTypes = {
|
||||||
outcome: OutcomeNode,
|
outcome: OutcomeNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
allocation: AllocationEdge,
|
||||||
|
}
|
||||||
|
|
||||||
// Generate edges with proportional Sankey-style widths
|
// 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 edges: FlowEdge[] = []
|
||||||
|
|
||||||
const flowValues: number[] = []
|
const flowValues: number[] = []
|
||||||
|
|
@ -54,6 +62,7 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
const sourceX = node.position.x
|
const sourceX = node.position.x
|
||||||
const rate = data.inflowRate || 1
|
const rate = data.inflowRate || 1
|
||||||
|
|
||||||
|
const overflowCount = data.overflowAllocations?.length ?? 0
|
||||||
data.overflowAllocations?.forEach((alloc) => {
|
data.overflowAllocations?.forEach((alloc) => {
|
||||||
const flowValue = (alloc.percentage / 100) * rate
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
@ -83,27 +92,20 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 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: {
|
data: {
|
||||||
allocation: alloc.percentage,
|
allocation: alloc.percentage,
|
||||||
color: alloc.color,
|
color: alloc.color,
|
||||||
edgeType: 'overflow' as const,
|
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) => {
|
data.spendingAllocations?.forEach((alloc) => {
|
||||||
const flowValue = (alloc.percentage / 100) * rate
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
@ -125,24 +127,16 @@ function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 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: {
|
data: {
|
||||||
allocation: alloc.percentage,
|
allocation: alloc.percentage,
|
||||||
color: alloc.color,
|
color: alloc.color,
|
||||||
edgeType: 'spending' as const,
|
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) {
|
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
|
||||||
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
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 [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
||||||
const edgesRef = useRef(edges)
|
const edgesRef = useRef(edges)
|
||||||
edgesRef.current = edges
|
edgesRef.current = edges
|
||||||
|
|
@ -173,6 +167,54 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
||||||
}
|
}
|
||||||
}, [nodes, onNodesChange])
|
}, [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
|
// Smart edge regeneration
|
||||||
const allocationsKey = useMemo(() => {
|
const allocationsKey = useMemo(() => {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
|
|
@ -191,9 +233,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEdges(generateEdges(nodes as FlowNode[]))
|
setEdges(generateEdges(nodes as FlowNode[], onAdjustAllocation))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [allocationsKey])
|
}, [allocationsKey, onAdjustAllocation])
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(params: Connection) => {
|
(params: Connection) => {
|
||||||
|
|
@ -436,6 +478,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
edgesReconnectable={true}
|
edgesReconnectable={true}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.15 }}
|
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
|
allocation: number // percentage 0-100
|
||||||
color: string
|
color: string
|
||||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
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
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue