Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions frontend/src/components/AllocationChart.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
.allocation-chart {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 1.75rem;
box-shadow: var(--shadow-sm);
margin-top: 2rem;
}

.allocation-chart__title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 1.5rem 0;
letter-spacing: var(--letter-spacing-tight);
}

.allocation-chart__content {
display: flex;
align-items: center;
gap: 2.5rem;
}

/* Donut */
.allocation-chart__donut {
flex-shrink: 0;
width: 200px;
height: 200px;
}

.allocation-chart__svg {
width: 100%;
height: 100%;
}

.allocation-chart__center-label {
font-size: 0.875rem;
font-weight: 600;
fill: var(--text-secondary);
}

.allocation-chart__center-value {
font-size: 1.125rem;
font-weight: 700;
fill: var(--text-primary);
}

/* Legend */
.allocation-chart__legend {
display: flex;
flex-direction: column;
gap: 0.625rem;
flex: 1;
min-width: 0;
}

.allocation-chart__legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}

.allocation-chart__legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}

.allocation-chart__legend-symbol {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
min-width: 4rem;
}

.allocation-chart__legend-pct {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
min-width: 3rem;
text-align: right;
}

.allocation-chart__legend-value {
font-size: 0.875rem;
color: var(--text-secondary);
margin-left: auto;
}

/* Mobile: stack vertically */
@media (max-width: 640px) {
.allocation-chart {
padding: 1.25rem;
}

.allocation-chart__content {
flex-direction: column;
gap: 1.5rem;
}

.allocation-chart__donut {
width: 180px;
height: 180px;
margin: 0 auto;
}
}
133 changes: 133 additions & 0 deletions frontend/src/components/AllocationChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { HoldingInfo } from '../types/api.types';
import './AllocationChart.css';

interface AllocationChartProps {
holdings: HoldingInfo[];
}

/**
* Palette of distinct colors for donut segments.
* Matches the holding colors from PortfolioChart for consistency.
*/
const SEGMENT_COLORS = [
'var(--accent)', // Emerald
'var(--warning)', // Amber
'var(--info)', // Slate
'#64b5a6', // Teal
'#9d7cc7', // Purple
'#f87171', // Rose
'#a3a3a3', // Gray
'#fb923c', // Orange
];

/**
* SVG donut chart showing each holding's percentage of the total portfolio value.
* Renders as a ring with a centered total value.
*/
export function AllocationChart({ holdings }: AllocationChartProps) {
const totalValue = holdings.reduce((sum, h) => sum + h.currentValue, 0);

if (totalValue <= 0 || holdings.length === 0) return null;

// Donut geometry
const size = 200;
const cx = size / 2;
const cy = size / 2;
const radius = 75;
const strokeWidth = 28;

// Build arc segments
const circumference = 2 * Math.PI * radius;
let offset = 0;

const segments = holdings.map((holding, index) => {
const fraction = holding.currentValue / totalValue;
const dashLength = fraction * circumference;
// Small gap between segments (1px visual gap)
const gap = holdings.length > 1 ? 2 : 0;
const segment = {
holding,
fraction,
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
dashArray: `${Math.max(dashLength - gap, 0)} ${circumference - Math.max(dashLength - gap, 0)}`,
dashOffset: -offset,
};
offset += dashLength;
return segment;
});

const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);

const formatPercent = (fraction: number) =>
`${(fraction * 100).toFixed(1)}%`;

return (
<div className="allocation-chart">
<h3 className="allocation-chart__title">Portfolio Allocation</h3>
<div className="allocation-chart__content">
{/* SVG Donut */}
<div className="allocation-chart__donut">
<svg viewBox={`0 0 ${size} ${size}`} className="allocation-chart__svg">
{segments.map((seg) => (
<circle
key={seg.holding.symbol}
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeDasharray={seg.dashArray}
strokeDashoffset={seg.dashOffset}
strokeLinecap="butt"
transform={`rotate(-90 ${cx} ${cy})`}
/>
))}
{/* Center text */}
<text
x={cx}
y={cy - 8}
textAnchor="middle"
dominantBaseline="central"
className="allocation-chart__center-label"
>
Total
</text>
<text
x={cx}
y={cy + 14}
textAnchor="middle"
dominantBaseline="central"
className="allocation-chart__center-value"
>
{formatCurrency(totalValue)}
</text>
</svg>
</div>

{/* Legend */}
<div className="allocation-chart__legend">
{segments.map((seg) => (
<div key={seg.holding.symbol} className="allocation-chart__legend-item">
<span
className="allocation-chart__legend-dot"
style={{ backgroundColor: seg.color }}
/>
<span className="allocation-chart__legend-symbol">{seg.holding.symbol}</span>
<span className="allocation-chart__legend-pct">{formatPercent(seg.fraction)}</span>
<span className="allocation-chart__legend-value">
{formatCurrency(seg.holding.currentValue)}
</span>
</div>
))}
</div>
</div>
</div>
);
}
48 changes: 45 additions & 3 deletions frontend/src/components/PortfolioChart.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@
position: relative;
}

/* Watermark */
.portfolio-chart__watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3rem;
font-weight: 800;
color: var(--text-primary);
opacity: 0.04;
pointer-events: none;
z-index: 1;
letter-spacing: 0.05em;
user-select: none;
}

/* Custom Tooltip */
.portfolio-chart__tooltip {
position: absolute;
Expand All @@ -138,16 +154,42 @@
min-width: 140px;
}

.portfolio-chart__tooltip-entry {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
}

.portfolio-chart__tooltip-entry:last-of-type {
margin-bottom: 0.5rem;
}

.portfolio-chart__tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

.portfolio-chart__tooltip-symbol {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
margin-right: auto;
}

.portfolio-chart__tooltip-value {
font-size: 1rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.25rem;
color: var(--text-primary);
}

.portfolio-chart__tooltip-date {
font-size: 0.8125rem;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
padding-top: 0.375rem;
}

/* Legend for individual holdings view */
Expand Down
Loading