whisper-money/resources/js/lib/donut-utils.ts

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;
}