Redesign funnel to ovary shape with three distinct zones
- Bulbous top section (overflow zone) with curved sides - Straight middle section (healthy operating zone between min/max) - Angled/tapered bottom section narrowing to spout (outcomes) - Side handles positioned at overflow zone for funnel-to-funnel flows - Bottom handle at narrow spout for outcome/deliverable flows - Animated overflow arrows when overflowing - Threshold lines with MIN/MAX labels - Fluid fill with ripple effect animation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4882977fb
commit
29b68d5770
|
|
@ -34,11 +34,23 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
const isCritical = currentValue < minThreshold
|
const isCritical = currentValue < minThreshold
|
||||||
const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100)
|
const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100)
|
||||||
|
|
||||||
// Simplified funnel dimensions
|
// Funnel dimensions - ovary-like shape
|
||||||
const width = 140
|
const width = 160
|
||||||
const height = 100
|
const height = 140
|
||||||
const topWidth = 120
|
|
||||||
const bottomWidth = 40
|
// Calculate zone heights based on thresholds
|
||||||
|
const minPercent = minThreshold / maxCapacity
|
||||||
|
const maxPercent = maxThreshold / maxCapacity
|
||||||
|
|
||||||
|
// Zone heights (from top to bottom)
|
||||||
|
const overflowZoneHeight = (1 - maxPercent) * height * 0.4 + 15 // Bulbous top
|
||||||
|
const healthyZoneHeight = (maxPercent - minPercent) * height * 0.8 + 30 // Straight middle
|
||||||
|
const drainZoneHeight = height - overflowZoneHeight - healthyZoneHeight // Angled bottom
|
||||||
|
|
||||||
|
// Widths
|
||||||
|
const topWidth = 130 // Bulbous overflow area
|
||||||
|
const midWidth = 100 // Straight healthy zone
|
||||||
|
const bottomWidth = 30 // Narrow spout for outcomes
|
||||||
|
|
||||||
// Double-click to edit
|
// Double-click to edit
|
||||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
|
@ -331,7 +343,7 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||||
${isEditing ? 'ring-2 ring-blue-400 ring-offset-2' : ''}
|
${isEditing ? 'ring-2 ring-blue-400 ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
style={{ width: width + 40 }}
|
style={{ width: width + 60 }}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{/* TOP Handle - INFLOWS */}
|
{/* TOP Handle - INFLOWS */}
|
||||||
|
|
@ -354,50 +366,86 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simplified Funnel View */}
|
{/* Ovary-shaped Funnel View */}
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{/* Inflow indicator */}
|
{/* Inflow indicator */}
|
||||||
<div className="flex items-center justify-center gap-1 mb-2">
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
<span className="text-[9px] text-emerald-600 uppercase">In</span>
|
|
||||||
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 4l-8 8h5v8h6v-8h5z"/>
|
<path d="M12 2l-6 6h4v6h4v-6h4z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[9px] text-emerald-600 uppercase font-medium">Inflow</span>
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2l-6 6h4v6h4v-6h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simple funnel shape with fill */}
|
{/* Ovary-shaped funnel with three zones */}
|
||||||
<svg width={width} height={height} className="mx-auto">
|
<svg width={width} height={height} className="mx-auto" style={{ overflow: 'visible' }}>
|
||||||
<defs>
|
<defs>
|
||||||
|
{/* Gradient for fluid fill */}
|
||||||
<linearGradient id={`fill-${id}`} x1="0%" y1="100%" x2="0%" y2="0%">
|
<linearGradient id={`fill-${id}`} x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||||
<stop offset="100%" stopColor={isOverflowing ? '#fde68a' : isCritical ? '#fca5a5' : '#6ee7b7'} />
|
<stop offset="100%" stopColor={isOverflowing ? '#fde68a' : isCritical ? '#fca5a5' : '#a7f3d0'} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<clipPath id={`funnel-${id}`}>
|
|
||||||
|
{/* Clip path for the ovary funnel shape */}
|
||||||
|
<clipPath id={`funnel-clip-${id}`}>
|
||||||
<path d={`
|
<path d={`
|
||||||
M ${(width - topWidth) / 2} 0
|
M ${width/2 - topWidth/2} 0
|
||||||
L ${(width + topWidth) / 2} 0
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
L ${(width + bottomWidth) / 2} ${height}
|
L ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
L ${(width - bottomWidth) / 2} ${height}
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
Z
|
Z
|
||||||
`} />
|
`} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Funnel background */}
|
{/* Zone backgrounds */}
|
||||||
|
{/* Overflow zone (bulbous top) - amber */}
|
||||||
<path
|
<path
|
||||||
d={`
|
d={`
|
||||||
M ${(width - topWidth) / 2} 0
|
M ${width/2 - topWidth/2} 0
|
||||||
L ${(width + topWidth) / 2} 0
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
L ${(width + bottomWidth) / 2} ${height}
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
L ${(width - bottomWidth) / 2} ${height}
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
Z
|
Z
|
||||||
`}
|
`}
|
||||||
fill="#f1f5f9"
|
fill="#fef3c7"
|
||||||
stroke="#94a3b8"
|
stroke="#f59e0b"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fill level */}
|
{/* Healthy zone (straight middle) - green */}
|
||||||
<g clipPath={`url(#funnel-${id})`}>
|
<rect
|
||||||
|
x={width/2 - midWidth/2}
|
||||||
|
y={overflowZoneHeight}
|
||||||
|
width={midWidth}
|
||||||
|
height={healthyZoneHeight}
|
||||||
|
fill="#d1fae5"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drain zone (angled bottom) - darker */}
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="#e2e8f0"
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fluid fill - animated */}
|
||||||
|
<g clipPath={`url(#funnel-clip-${id})`}>
|
||||||
<rect
|
<rect
|
||||||
x={0}
|
x={0}
|
||||||
y={height - (height * fillPercent / 100)}
|
y={height - (height * fillPercent / 100)}
|
||||||
|
|
@ -407,26 +455,110 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
>
|
>
|
||||||
<animate
|
<animate
|
||||||
attributeName="y"
|
attributeName="y"
|
||||||
values={`${height - (height * fillPercent / 100)};${height - (height * fillPercent / 100) - 2};${height - (height * fillPercent / 100)}`}
|
values={`${height - (height * fillPercent / 100)};${height - (height * fillPercent / 100) - 3};${height - (height * fillPercent / 100)}`}
|
||||||
dur="2s"
|
dur="2s"
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
</rect>
|
</rect>
|
||||||
|
|
||||||
|
{/* Ripple effect at top of fluid */}
|
||||||
|
<ellipse
|
||||||
|
cx={width/2}
|
||||||
|
cy={height - (height * fillPercent / 100)}
|
||||||
|
rx={30}
|
||||||
|
ry={3}
|
||||||
|
fill="rgba(255,255,255,0.4)"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="rx"
|
||||||
|
values="25;35;25"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Funnel outline */}
|
{/* Threshold lines */}
|
||||||
|
{/* Max threshold line */}
|
||||||
|
<line
|
||||||
|
x1={width/2 - midWidth/2 - 5}
|
||||||
|
y1={overflowZoneHeight}
|
||||||
|
x2={width/2 + midWidth/2 + 5}
|
||||||
|
y2={overflowZoneHeight}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={width/2 + midWidth/2 + 8}
|
||||||
|
y={overflowZoneHeight + 4}
|
||||||
|
fontSize="8"
|
||||||
|
fill="#f59e0b"
|
||||||
|
fontWeight="bold"
|
||||||
|
>MAX</text>
|
||||||
|
|
||||||
|
{/* Min threshold line */}
|
||||||
|
<line
|
||||||
|
x1={width/2 - midWidth/2 - 5}
|
||||||
|
y1={overflowZoneHeight + healthyZoneHeight}
|
||||||
|
x2={width/2 + midWidth/2 + 5}
|
||||||
|
y2={overflowZoneHeight + healthyZoneHeight}
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={width/2 + midWidth/2 + 8}
|
||||||
|
y={overflowZoneHeight + healthyZoneHeight + 4}
|
||||||
|
fontSize="8"
|
||||||
|
fill="#ef4444"
|
||||||
|
fontWeight="bold"
|
||||||
|
>MIN</text>
|
||||||
|
|
||||||
|
{/* Outline stroke for whole shape */}
|
||||||
<path
|
<path
|
||||||
d={`
|
d={`
|
||||||
M ${(width - topWidth) / 2} 0
|
M ${width/2 - topWidth/2} 0
|
||||||
L ${(width + topWidth) / 2} 0
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
L ${(width + bottomWidth) / 2} ${height}
|
L ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
L ${(width - bottomWidth) / 2} ${height}
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
Z
|
Z
|
||||||
`}
|
`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#64748b"
|
stroke="#475569"
|
||||||
strokeWidth="2"
|
strokeWidth="2.5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Overflow arrows on sides */}
|
||||||
|
{isOverflowing && hasOverflow && (
|
||||||
|
<>
|
||||||
|
{/* Left overflow arrow */}
|
||||||
|
<g transform={`translate(${width/2 - topWidth/2 - 20}, ${overflowZoneHeight/2})`}>
|
||||||
|
<path d="M 0 0 L -10 -5 L -10 5 Z" fill="#f59e0b">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="1s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
{/* Right overflow arrow */}
|
||||||
|
<g transform={`translate(${width/2 + topWidth/2 + 20}, ${overflowZoneHeight/2})`}>
|
||||||
|
<path d="M 0 0 L 10 -5 L 10 5 Z" fill="#f59e0b">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="1s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outcome flow arrow at bottom */}
|
||||||
|
{hasSpending && (
|
||||||
|
<g transform={`translate(${width/2}, ${height + 5})`}>
|
||||||
|
<path d="M 0 0 L -5 -8 L 5 -8 Z" fill="#3b82f6">
|
||||||
|
<animate attributeName="opacity" values="1;0.5;1" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Value */}
|
{/* Value */}
|
||||||
|
|
@ -438,27 +570,27 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simplified allocation bars */}
|
{/* Flow indicators */}
|
||||||
<div className="flex items-center justify-between mt-3 gap-2">
|
<div className="mt-3 space-y-2">
|
||||||
{/* Outflow (sides) */}
|
{/* Outflows (sides) */}
|
||||||
<div className="flex flex-col items-center flex-1">
|
{hasOverflow && (
|
||||||
<span className="text-[8px] text-amber-600 uppercase mb-1">Out</span>
|
<div className="flex items-center gap-2">
|
||||||
{hasOverflow ? (
|
<span className="text-[8px] text-amber-600 uppercase w-12">← Out →</span>
|
||||||
renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')
|
<div className="flex-1">
|
||||||
) : (
|
{renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')}
|
||||||
<div className="h-2 w-full bg-slate-100 rounded" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Outcomes (bottom) */}
|
{/* Outcomes (bottom) */}
|
||||||
<div className="flex flex-col items-center flex-1">
|
{hasSpending && (
|
||||||
<span className="text-[8px] text-blue-600 uppercase mb-1">Spend</span>
|
<div className="flex items-center gap-2">
|
||||||
{hasSpending ? (
|
<span className="text-[8px] text-blue-600 uppercase w-12">↓ Fund</span>
|
||||||
renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')
|
<div className="flex-1">
|
||||||
) : (
|
{renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')}
|
||||||
<div className="h-2 w-full bg-slate-100 rounded" />
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-2">
|
<div className="text-center mt-2">
|
||||||
|
|
@ -466,20 +598,20 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIDE Handles - OUTFLOWS to other funnels */}
|
{/* SIDE Handles - OUTFLOWS at bulbous overflow zone */}
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="outflow-left"
|
id="outflow-left"
|
||||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
className="!w-4 !h-4 !bg-amber-500 !border-2 !border-white !rounded-full"
|
||||||
style={{ top: '50%' }}
|
style={{ top: '30%' }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="outflow-right"
|
id="outflow-right"
|
||||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-white"
|
className="!w-4 !h-4 !bg-amber-500 !border-2 !border-white !rounded-full"
|
||||||
style={{ top: '50%' }}
|
style={{ top: '30%' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* BOTTOM Handle - OUTCOMES/DELIVERABLES */}
|
{/* BOTTOM Handle - OUTCOMES/DELIVERABLES */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue