108 lines
5.3 KiB
TypeScript
108 lines
5.3 KiB
TypeScript
import { useState } from "react";
|
|
import type { MonthViewModel } from "../../domain/views.ts";
|
|
import { monthPieData, type MonthPieMode } from "../monthPie.ts";
|
|
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
|
import { Section } from "./Section.tsx";
|
|
import { Segmented } from "./Segmented.tsx";
|
|
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
|
|
|
|
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
|
|
const OPTIONS = [
|
|
{ value: "spending", label: "Spent" },
|
|
{ value: "allocation", label: "Allocated" },
|
|
] as const;
|
|
|
|
function point(degrees: number): [number, number] {
|
|
const radians = ((degrees - 90) * Math.PI) / 180;
|
|
return [100 + 88 * Math.cos(radians), 100 + 88 * Math.sin(radians)];
|
|
}
|
|
|
|
function wedge(start: number, end: number): string {
|
|
const [startX, startY] = point(start);
|
|
const [endX, endY] = point(end);
|
|
return `M 100 100 L ${startX} ${startY} A 88 88 0 ${end - start > 180 ? 1 : 0} 1 ${endX} ${endY} Z`;
|
|
}
|
|
|
|
export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) {
|
|
const [mode, setMode] = useState<MonthPieMode>("spending");
|
|
const [visible, setVisible] = useDisplayPreference("budget2:show-month-breakdown", true);
|
|
const fmt = useMoneyFormat();
|
|
const chart = monthPieData(view, mode);
|
|
let angle = 0;
|
|
const label = mode === "spending" ? "spending" : "allocation";
|
|
|
|
return (
|
|
<Section
|
|
title="Monthly breakdown"
|
|
hint={visible ? `Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.` : undefined}
|
|
action={<>
|
|
<label className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={visible} onChange={event => setVisible(event.target.checked)} className="size-4 accent-primary" />
|
|
Show chart
|
|
</label>
|
|
{visible && <Segmented value={mode} options={OPTIONS} onChange={setMode} label="Pie chart view" />}
|
|
</>}
|
|
>
|
|
{visible && <div className="grid items-center gap-5 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-8">
|
|
<div className="mx-auto w-full max-w-56">
|
|
<svg viewBox="0 0 200 200" role="img" aria-label={`${label} pie chart`} className="aspect-square w-full">
|
|
{chart.positiveTotal === 0 ? (
|
|
<circle cx="100" cy="100" r="88" fill="var(--budget-tint)" />
|
|
) : chart.positive.length === 1 ? (
|
|
<circle cx="100" cy="100" r="88" fill={COLORS[0]} />
|
|
) : chart.positive.map((item, index) => {
|
|
const start = angle;
|
|
angle += (item.amount / chart.positiveTotal) * 360;
|
|
return (
|
|
<path
|
|
key={item.id}
|
|
d={wedge(start, index === chart.positive.length - 1 ? 360 : angle)}
|
|
fill={COLORS[index % COLORS.length]}
|
|
stroke="var(--budget-raised)"
|
|
strokeWidth="2"
|
|
>
|
|
<title>{`${item.label}: ${fmt.money(item.amount, baseCurrency)}`}</title>
|
|
</path>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-5 gap-y-1 border-b border-budget-rule pb-3">
|
|
<span className="text-sm text-budget-ink-muted">{mode === "spending" ? "Net spent" : "Total allocated"}</span>
|
|
<strong className="font-money text-xl font-medium tabular-nums">{fmt.money(chart.netTotal, baseCurrency)}</strong>
|
|
</div>
|
|
{chart.positive.length === 0 ? (
|
|
<p className="py-5 text-sm text-budget-ink-muted">
|
|
{mode === "spending" ? "No positive spending to chart." : "No positive allocations to chart."}
|
|
</p>
|
|
) : (
|
|
<ol className="space-y-2.5">
|
|
{chart.positive.map((item, index) => (
|
|
<li key={item.id} className="grid grid-cols-[1rem_minmax(0,1fr)_auto] items-baseline gap-x-2.5 text-sm">
|
|
<span className="inline-block size-2.5 self-center rounded-full" style={{ backgroundColor: COLORS[index % COLORS.length] }} aria-hidden="true" />
|
|
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
<span className="font-money text-right tabular-nums whitespace-nowrap">{fmt.money(item.amount, baseCurrency)} <span className="ml-1 text-xs text-budget-ink-muted">{Math.round((item.amount / chart.positiveTotal) * 100)}%</span></span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
{chart.adjustments.length > 0 && (
|
|
<div className="mt-4 border-t border-budget-rule pt-3">
|
|
<p className="mb-2 text-xs text-budget-ink-muted">Refunds and negative adjustments are excluded from pie slices.</p>
|
|
<ul className="space-y-1.5 text-sm">
|
|
{chart.adjustments.map((item) => (
|
|
<li key={item.id} className="flex justify-between gap-3">
|
|
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
|
<span className="font-money shrink-0 tabular-nums">{fmt.money(item.amount, baseCurrency)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>}
|
|
</Section>
|
|
);
|
|
}
|