buckets/resources/js/components/DistributionLines.tsx

150 lines
5.4 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from 'react';
import { type DistributionPreview } from '@/types';
interface Props {
distribution: DistributionPreview;
bucketRefs: React.RefObject<Map<string, HTMLElement> | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
incomeRef: React.RefObject<HTMLDivElement | null>;
}
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>
);
}