feat(cashflow): nest subcategories in treemap + hover tooltip
This commit is contained in:
parent
a58a012c98
commit
edaddc9c90
|
|
@ -1,6 +1,6 @@
|
|||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
|
||||
import { SankeyCategory } from '@/hooks/use-cashflow-data';
|
||||
import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
|
@ -8,8 +8,8 @@ import { formatCurrency } from '@/utils/currency';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import { useMemo } from 'react';
|
||||
import { ResponsiveContainer, Treemap } from 'recharts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ResponsiveContainer, Tooltip, Treemap } from 'recharts';
|
||||
|
||||
interface TreemapChartProps {
|
||||
categories: SankeyCategory[];
|
||||
|
|
@ -26,6 +26,7 @@ interface TreemapDatum {
|
|||
size: number;
|
||||
color: string;
|
||||
categoryId: string;
|
||||
children?: TreemapDatum[];
|
||||
// Satisfies recharts' TreemapDataType index signature.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -72,24 +73,22 @@ function CategoryNode({
|
|||
size = 0,
|
||||
color = 'var(--color-chart-4)',
|
||||
categoryId,
|
||||
children,
|
||||
currency,
|
||||
locale,
|
||||
isPrivacyModeEnabled,
|
||||
}: TreemapNodeProps) {
|
||||
// depth 0 is the invisible root; only leaves carry a category.
|
||||
// depth 0 is the invisible root; only leaves/parents carry a category.
|
||||
if (depth < 1 || !categoryId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showName = width >= 40 && height >= 22;
|
||||
const showAmount = width >= 55 && height >= 40;
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
|
||||
return (
|
||||
<g style={{ cursor: 'pointer' }}>
|
||||
<title>
|
||||
{name}:{' '}
|
||||
{maskIfPrivate(size, currency, locale, isPrivacyModeEnabled)}
|
||||
</title>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
|
|
@ -107,7 +106,7 @@ function CategoryNode({
|
|||
y={y + 18}
|
||||
className="fill-white text-[12px] font-medium"
|
||||
>
|
||||
{truncate(name, width)}
|
||||
{truncate(hasChildren ? `${name} ›` : name, width)}
|
||||
</text>
|
||||
)}
|
||||
{showAmount && (
|
||||
|
|
@ -128,6 +127,42 @@ function CategoryNode({
|
|||
);
|
||||
}
|
||||
|
||||
interface TreemapTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload?: TreemapDatum }>;
|
||||
currency: string;
|
||||
locale: string;
|
||||
isPrivacyModeEnabled: boolean;
|
||||
}
|
||||
|
||||
function TreemapTooltip({
|
||||
active,
|
||||
payload,
|
||||
currency,
|
||||
locale,
|
||||
isPrivacyModeEnabled,
|
||||
}: TreemapTooltipProps) {
|
||||
const node = payload?.[0]?.payload;
|
||||
|
||||
if (!active || !node?.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
|
||||
<div className="font-medium">{node.name}</div>
|
||||
<div className="text-muted-foreground tabular-nums">
|
||||
{maskIfPrivate(
|
||||
node.size,
|
||||
currency,
|
||||
locale,
|
||||
isPrivacyModeEnabled,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreemapChart({
|
||||
categories,
|
||||
total,
|
||||
|
|
@ -141,17 +176,101 @@ export function TreemapChart({
|
|||
const { isPrivacyModeEnabled } = usePrivacyMode();
|
||||
const { categoryBarColor } = useChartColors();
|
||||
|
||||
// Nest mode needs the full tree upfront, so prefetch each parent's children.
|
||||
// ponytail: one level deep — matches the old sankey; deeper nesting would
|
||||
// need recursive fetching and rollUp already caps categories at depth 3.
|
||||
const [childrenByParent, setChildrenByParent] = useState<
|
||||
Record<string, SankeyCategory[]>
|
||||
>({});
|
||||
|
||||
const periodKey = period
|
||||
? `${period.from.getTime()}-${period.to.getTime()}`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!period) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parents = categories.filter(
|
||||
(item) => item.has_children && item.category_id,
|
||||
);
|
||||
|
||||
if (parents.length === 0) {
|
||||
setChildrenByParent({});
|
||||
return;
|
||||
}
|
||||
|
||||
const from = format(period.from, 'yyyy-MM-dd');
|
||||
const to = format(period.to, 'yyyy-MM-dd');
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all(
|
||||
parents.map(async (parent) => {
|
||||
const response = await fetch(
|
||||
`/api/cashflow/sankey?from=${from}&to=${to}&parent=${parent.category_id}`,
|
||||
);
|
||||
const json: SankeyData = await response.json();
|
||||
const kids =
|
||||
mode === 'income'
|
||||
? json.income_categories
|
||||
: json.expense_categories;
|
||||
|
||||
return [parent.category_id, kids] as const;
|
||||
}),
|
||||
)
|
||||
.then((entries) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, SankeyCategory[]> = {};
|
||||
entries.forEach(([id, kids]) => {
|
||||
next[id] = kids;
|
||||
});
|
||||
setChildrenByParent(next);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch subcategories:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// categoryBarColor is intentionally excluded: it changes identity every
|
||||
// render and colours are applied in the memo below, not here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categories, periodKey, mode]);
|
||||
|
||||
const data = useMemo<TreemapDatum[]>(() => {
|
||||
return [...categories]
|
||||
.filter((item) => item.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.map((item, index) => ({
|
||||
name: item.category.name,
|
||||
size: item.amount,
|
||||
color: categoryBarColor(item.category.color, index),
|
||||
categoryId: item.category.id,
|
||||
}));
|
||||
}, [categories, categoryBarColor]);
|
||||
const toDatum = (
|
||||
item: SankeyCategory,
|
||||
index: number,
|
||||
): TreemapDatum => ({
|
||||
name: item.category.name,
|
||||
size: item.amount,
|
||||
color: categoryBarColor(item.category.color, index),
|
||||
categoryId: item.category_id ?? '',
|
||||
});
|
||||
|
||||
const sortPositive = (items: SankeyCategory[]): SankeyCategory[] =>
|
||||
[...items]
|
||||
.filter((item) => item.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return sortPositive(categories).map((item, index) => {
|
||||
const datum = toDatum(item, index);
|
||||
const kids = item.category_id
|
||||
? childrenByParent[item.category_id]
|
||||
: undefined;
|
||||
|
||||
if (kids && kids.length > 0) {
|
||||
datum.children = sortPositive(kids).map(toDatum);
|
||||
}
|
||||
|
||||
return datum;
|
||||
});
|
||||
}, [categories, childrenByParent, categoryBarColor]);
|
||||
|
||||
if (total === 0 || data.length === 0) {
|
||||
return (
|
||||
|
|
@ -168,7 +287,7 @@ export function TreemapChart({
|
|||
}
|
||||
|
||||
const navigate = (categoryId: unknown) => {
|
||||
if (typeof categoryId !== 'string' || !period) {
|
||||
if (!categoryId || typeof categoryId !== 'string' || !period) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -184,15 +303,31 @@ export function TreemapChart({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full',
|
||||
// Recharts renders the nest breadcrumb with hardcoded inline
|
||||
// styles; override them to match the design system.
|
||||
'[&_.recharts-treemap-nest-index-box]:!mr-1 [&_.recharts-treemap-nest-index-box]:!rounded [&_.recharts-treemap-nest-index-box]:!bg-muted [&_.recharts-treemap-nest-index-box]:!px-2 [&_.recharts-treemap-nest-index-box]:!text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Treemap
|
||||
data={data}
|
||||
dataKey="size"
|
||||
type="nest"
|
||||
nodeGap={2}
|
||||
// ponytail: key by mode so a toggle switch re-lays out cleanly
|
||||
key={mode}
|
||||
isAnimationActive={false}
|
||||
onClick={(node) => navigate(node.categoryId)}
|
||||
// In nest mode onClick fires on every node; parents zoom in,
|
||||
// so only leaves navigate to their transactions.
|
||||
onClick={(node) => {
|
||||
if (!node.children) {
|
||||
navigate(node.categoryId);
|
||||
}
|
||||
}}
|
||||
content={
|
||||
<CategoryNode
|
||||
currency={currency}
|
||||
|
|
@ -200,7 +335,17 @@ export function TreemapChart({
|
|||
isPrivacyModeEnabled={isPrivacyModeEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<TreemapTooltip
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
isPrivacyModeEnabled={isPrivacyModeEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue