209 lines
5.6 KiB
TypeScript
209 lines
5.6 KiB
TypeScript
import { SankeyCategory } from '@/hooks/use-cashflow-data';
|
|
import { groupSmallCategories } from '@/lib/sankey-utils';
|
|
import { CategoryColor } from '@/types/category';
|
|
import { __ } from '@/utils/i18n';
|
|
|
|
export type DonutDirection = 'income' | 'expense';
|
|
|
|
export interface DonutSegment {
|
|
/** Stable identity for React keys and cross-ring arc alignment. */
|
|
key: string;
|
|
name: string;
|
|
value: number;
|
|
/** Real category id to link to, or null when not navigable. */
|
|
categoryId: string | null;
|
|
color: CategoryColor | null;
|
|
isDirect: boolean;
|
|
/** 1-based depth of the resolved node in its tree. */
|
|
depth: number;
|
|
direction: DonutDirection;
|
|
}
|
|
|
|
export interface DonutRing {
|
|
/** 1-based level of the tree this ring renders. */
|
|
level: number;
|
|
direction: DonutDirection;
|
|
/** Sum of the segment values; equals the direction total for every ring. */
|
|
total: number;
|
|
segments: DonutSegment[];
|
|
}
|
|
|
|
interface Leaf {
|
|
path: SankeyCategory[];
|
|
value: number;
|
|
}
|
|
|
|
const DEFAULT_THRESHOLD = 0.03;
|
|
|
|
function nodeId(node: SankeyCategory): string {
|
|
if (node.is_direct) {
|
|
return `${node.category_id}:direct`;
|
|
}
|
|
|
|
return node.category_id ?? `unknown:${node.category.name}`;
|
|
}
|
|
|
|
/**
|
|
* Fold small sibling categories into a synthetic "Other" node at every level,
|
|
* so no ring drowns in unreadable slivers. Mirrors the sankey grouping.
|
|
*/
|
|
function groupTree(
|
|
nodes: SankeyCategory[],
|
|
total: number,
|
|
threshold: number,
|
|
keyPrefix: string,
|
|
): SankeyCategory[] {
|
|
const { main, other } = groupSmallCategories(nodes, total, threshold);
|
|
|
|
const grouped = main.map(
|
|
(node): SankeyCategory =>
|
|
node.children && node.children.length > 0
|
|
? {
|
|
...node,
|
|
children: groupTree(
|
|
node.children,
|
|
node.amount,
|
|
threshold,
|
|
`${keyPrefix}/${nodeId(node)}`,
|
|
),
|
|
}
|
|
: node,
|
|
);
|
|
|
|
if (other) {
|
|
grouped.push({
|
|
category: {
|
|
...main[0].category,
|
|
name: __('Other'),
|
|
color: 'gray',
|
|
icon: 'HelpCircle',
|
|
},
|
|
category_id: `${keyPrefix}/other`,
|
|
amount: other.total,
|
|
is_direct: false,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
function collectLeaves(
|
|
nodes: SankeyCategory[],
|
|
prefix: SankeyCategory[] = [],
|
|
): Leaf[] {
|
|
const leaves: Leaf[] = [];
|
|
|
|
for (const node of nodes) {
|
|
const path = [...prefix, node];
|
|
|
|
if (node.children && node.children.length > 0) {
|
|
leaves.push(...collectLeaves(node.children, path));
|
|
} else {
|
|
leaves.push({ path, value: node.amount });
|
|
}
|
|
}
|
|
|
|
return leaves;
|
|
}
|
|
|
|
function maxDepth(leaves: Leaf[]): number {
|
|
return leaves.reduce((max, leaf) => Math.max(max, leaf.path.length), 0);
|
|
}
|
|
|
|
/**
|
|
* Build one ring's arcs. A leaf shorter than `level` is rendered as its own
|
|
* terminal node (it visually extends outward); adjacent leaves resolving to the
|
|
* same ancestor merge into a single arc. Every ring sums to the same total, so
|
|
* arcs stay angularly aligned with the rings inside and outside them.
|
|
*/
|
|
function ringSegments(
|
|
leaves: Leaf[],
|
|
level: number,
|
|
direction: DonutDirection,
|
|
): DonutSegment[] {
|
|
const segments: DonutSegment[] = [];
|
|
|
|
for (const leaf of leaves) {
|
|
const index = Math.min(level, leaf.path.length) - 1;
|
|
const node = leaf.path[index];
|
|
const key = `${direction}:${level}:${nodeId(node)}`;
|
|
const last = segments[segments.length - 1];
|
|
|
|
if (last && last.key === key) {
|
|
last.value += leaf.value;
|
|
|
|
continue;
|
|
}
|
|
|
|
segments.push({
|
|
key,
|
|
name: node.category.name,
|
|
value: leaf.value,
|
|
categoryId: node.synthetic ? null : node.category_id,
|
|
color: node.category.color ?? null,
|
|
isDirect: !!node.is_direct,
|
|
depth: index + 1,
|
|
direction,
|
|
});
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
function directionRings(
|
|
nodes: SankeyCategory[],
|
|
direction: DonutDirection,
|
|
threshold: number,
|
|
): DonutRing[] {
|
|
const total = nodes.reduce((sum, node) => sum + node.amount, 0);
|
|
|
|
if (total <= 0) {
|
|
return [];
|
|
}
|
|
|
|
const grouped = groupTree(nodes, total, threshold, direction);
|
|
const leaves = collectLeaves(grouped);
|
|
const depth = maxDepth(leaves);
|
|
const rings: DonutRing[] = [];
|
|
|
|
for (let level = 1; level <= depth; level++) {
|
|
const segments = ringSegments(leaves, level, direction);
|
|
|
|
if (segments.length > 0) {
|
|
rings.push({ level, direction, total, segments });
|
|
}
|
|
}
|
|
|
|
return rings;
|
|
}
|
|
|
|
export interface BuildDonutRingsOptions {
|
|
showIncome: boolean;
|
|
threshold?: number;
|
|
}
|
|
|
|
/**
|
|
* Assemble the concentric ring stack, innermost first.
|
|
*
|
|
* Combined: income grows toward the centre (deepest income level innermost),
|
|
* expense grows outward (deepest expense level at the rim). With `showIncome`
|
|
* off only the expense rings remain, its top level innermost.
|
|
*/
|
|
export function buildDonutRings(
|
|
income: SankeyCategory[],
|
|
expense: SankeyCategory[],
|
|
{ showIncome, threshold = DEFAULT_THRESHOLD }: BuildDonutRingsOptions,
|
|
): DonutRing[] {
|
|
const rings: DonutRing[] = [];
|
|
|
|
if (showIncome) {
|
|
// Reverse so the deepest income level sits innermost.
|
|
rings.push(...directionRings(income, 'income', threshold).reverse());
|
|
}
|
|
|
|
rings.push(...directionRings(expense, 'expense', threshold));
|
|
|
|
return rings;
|
|
}
|