2 - Create DistributionLines SVG connector component
This commit is contained in:
parent
5ac8d48727
commit
7b92423fb9
1 changed files with 149 additions and 0 deletions
149
resources/js/components/DistributionLines.tsx
Normal file
149
resources/js/components/DistributionLines.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { type DistributionPreview } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
distribution: DistributionPreview;
|
||||||
|
bucketRefs: React.RefObject<Map<string, HTMLElement>>;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
incomeRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LinePosition[]>([]);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(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 (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ zIndex: 10 }}
|
||||||
|
>
|
||||||
|
{/* Vertical trunk from income to span all bucket Y positions */}
|
||||||
|
<line
|
||||||
|
x1={trunkX}
|
||||||
|
y1={trunkTop}
|
||||||
|
x2={trunkX}
|
||||||
|
y2={trunkBottom}
|
||||||
|
stroke="rgb(239, 68, 68)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeOpacity={0.6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Horizontal line from trunk to income panel */}
|
||||||
|
<line
|
||||||
|
x1={trunkX}
|
||||||
|
y1={positions[0].incomeY}
|
||||||
|
x2={positions[0].incomeLeft}
|
||||||
|
y2={positions[0].incomeY}
|
||||||
|
stroke="rgb(239, 68, 68)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeOpacity={0.6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<g key={pos.bucketId}>
|
||||||
|
{/* Horizontal branch: trunk → bucket */}
|
||||||
|
<line
|
||||||
|
x1={trunkX}
|
||||||
|
y1={pos.bucketY}
|
||||||
|
x2={pos.bucketRight + 8}
|
||||||
|
y2={pos.bucketY}
|
||||||
|
stroke="rgb(239, 68, 68)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeOpacity={opacity}
|
||||||
|
/>
|
||||||
|
{/* Arrow tip */}
|
||||||
|
<polygon
|
||||||
|
points={`${pos.bucketRight + 8},${pos.bucketY - 4} ${pos.bucketRight},${pos.bucketY} ${pos.bucketRight + 8},${pos.bucketY + 4}`}
|
||||||
|
fill="rgb(239, 68, 68)"
|
||||||
|
fillOpacity={opacity}
|
||||||
|
/>
|
||||||
|
{/* Amount label */}
|
||||||
|
<text
|
||||||
|
x={(trunkX + pos.bucketRight) / 2}
|
||||||
|
y={pos.bucketY - 8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="rgb(239, 68, 68)"
|
||||||
|
fillOpacity={opacity}
|
||||||
|
fontSize={12}
|
||||||
|
fontFamily="monospace"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue