From 7b92423fb92e6991e6bdae41cf13c380a3d9f3a0 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 22 Mar 2026 15:19:49 +0100 Subject: [PATCH] 2 - Create DistributionLines SVG connector component --- resources/js/components/DistributionLines.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 resources/js/components/DistributionLines.tsx diff --git a/resources/js/components/DistributionLines.tsx b/resources/js/components/DistributionLines.tsx new file mode 100644 index 0000000..a805683 --- /dev/null +++ b/resources/js/components/DistributionLines.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; +import { type DistributionPreview } from '@/types'; + +interface Props { + distribution: DistributionPreview; + bucketRefs: React.RefObject>; + containerRef: React.RefObject; + incomeRef: React.RefObject; +} + +interface LinePosition { + bucketId: string; + amount: number; + bucketY: number; + bucketRight: number; + incomeLeft: number; + incomeY: number; +} + +const centsToDollars = (cents: number): string => `$${(cents / 100).toFixed(0)}`; + +export default function DistributionLines({ distribution, bucketRefs, containerRef, incomeRef }: Props) { + const [positions, setPositions] = useState([]); + const svgRef = useRef(null); + + useEffect(() => { + const measure = () => { + const container = containerRef.current; + const income = incomeRef.current; + if (!container || !income) return; + + const containerRect = container.getBoundingClientRect(); + const incomeRect = income.getBoundingClientRect(); + const incomeLeft = incomeRect.left - containerRect.left; + const incomeCenterY = incomeRect.top + incomeRect.height / 2 - containerRect.top; + + const lines: LinePosition[] = []; + + for (const alloc of distribution.allocations) { + const el = bucketRefs.current?.get(alloc.bucket_id); + if (!el) continue; + + const bucketRect = el.getBoundingClientRect(); + const bucketRight = bucketRect.right - containerRect.left; + const bucketY = bucketRect.top + bucketRect.height / 2 - containerRect.top; + + lines.push({ + bucketId: alloc.bucket_id, + amount: alloc.allocated_amount, + bucketY, + bucketRight, + incomeLeft, + incomeY: incomeCenterY, + }); + } + + setPositions(lines); + }; + + requestAnimationFrame(measure); + + const observer = new ResizeObserver(measure); + if (containerRef.current) observer.observe(containerRef.current); + + return () => observer.disconnect(); + }, [distribution, bucketRefs, containerRef, incomeRef]); + + if (positions.length === 0) return null; + + // Trunk X position: midpoint between rightmost bucket and income panel + const trunkX = positions.length > 0 + ? (Math.max(...positions.map(p => p.bucketRight)) + positions[0].incomeLeft) / 2 + : 0; + const incomeY = positions[0]?.incomeY ?? 0; + const allYs = [...positions.map(p => p.bucketY), incomeY]; + const trunkTop = Math.min(...allYs); + const trunkBottom = Math.max(...allYs); + + return ( + + {/* Vertical trunk from income to span all bucket Y positions */} + + + {/* Horizontal line from trunk to income panel */} + + + {/* Branch lines from trunk to each bucket */} + {positions.map((pos) => { + const isZero = pos.amount === 0; + const opacity = isZero ? 0.2 : 0.8; + const label = centsToDollars(pos.amount); + + return ( + + {/* Horizontal branch: trunk → bucket */} + + {/* Arrow tip */} + + {/* Amount label */} + + {label} + + + ); + })} + + ); +}