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