mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 06:58:47 -05:00
Adding Concentric Donut Pie Chart type to custom report charts (#7038)
* Initial commit for concentric donut chart implementation * [autofix.ci] apply automated fixes * Update upcoming-release-notes/7038.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix coderabbit comments (Recalculated total to avoid hidden cats, remove tooltip, add proper types) * Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix zero total group * lint issues fix * Fix lint issues * Empty commit to retriger the process * [autofix.ci] apply automated fixes * Removed line betweeen arc and label. I beleive the view is cleaner this way. * Fixed line for outer donut * split active shape for concentric circles to avoid impacting original chart * [autofix.ci] apply automated fixes * Fixing mid point to align with mid point on inner circle * 1- make line always start inside the core circle 2- fix bug where inner circle label was showing below the outer circle * - Fixed Dashboard issue when height too low. - Rewrite of the activeShape part for simplicity. - Centralize radius calculation. - Provide differnt dimensions for compact vs standard rendering - fix mid line point to auto fix at 70% of the inner radius - More readable code * Update VRT screenshots Auto-generated by VRT workflow PR: #7038 * Fixed distance issue for arc on single ring. * [autofix.ci] apply automated fixes * Update VRT screenshots Auto-generated by VRT workflow PR: #7038 * Update packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fixing Code Rabbit Comments * rerunning tests * Added Group click through passing all categories iwth a workaorund on showActivity --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
@@ -53,6 +53,7 @@ const balanceTypeOptions = [
|
||||
const groupByOptions = [
|
||||
{ description: t('Category'), key: 'Category' },
|
||||
{ description: t('Group'), key: 'Group' },
|
||||
{ description: t('Category+Group'), key: 'CategoryGroup' }, // new: two-ring donut support
|
||||
{ description: t('Payee'), key: 'Payee' },
|
||||
{ description: t('Account'), key: 'Account' },
|
||||
{ description: t('Interval'), key: 'Interval' },
|
||||
@@ -220,12 +221,10 @@ const intervalOptions: intervalOptionsProps[] = [
|
||||
format: 'yy-MM-dd',
|
||||
range: 'weekRangeInclusive',
|
||||
},
|
||||
//{ value: 3, description: 'Fortnightly', name: 3},
|
||||
{
|
||||
description: t('Monthly'),
|
||||
key: 'Monthly',
|
||||
name: 'Month',
|
||||
|
||||
format: "MMM ''yy",
|
||||
range: 'rangeInclusive',
|
||||
},
|
||||
@@ -328,17 +327,11 @@ export const categoryLists = (categories: {
|
||||
const categoriesToSort = [...categories.list];
|
||||
const categoryList: UncategorizedEntity[] = [
|
||||
...categoriesToSort.sort((a, b) => {
|
||||
//The point of this sorting is to make the graphs match the "budget" page
|
||||
const catGroupA = categories.grouped.find(f => f.id === a.group);
|
||||
const catGroupB = categories.grouped.find(f => f.id === b.group);
|
||||
//initial check that both a and b have a sort_order and category group
|
||||
return a.sort_order && b.sort_order && catGroupA && catGroupB
|
||||
? /*sorting by "is_income" because sort_order for this group is
|
||||
separate from other groups*/
|
||||
Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
//Next, sorting by group sort_order
|
||||
? Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
(catGroupA.sort_order ?? 0) - (catGroupB.sort_order ?? 0) ||
|
||||
//Finally, sorting by category within each group
|
||||
a.sort_order - b.sort_order
|
||||
: 0;
|
||||
}),
|
||||
@@ -382,6 +375,20 @@ export const groupBySelections = (
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
// CategoryGroup uses category-level data from createCustomSpreadsheet.
|
||||
// The group-level data comes from groupedData (createGroupedSpreadsheet).
|
||||
// This case just prevents the default throw so the spreadsheet doesn't error.
|
||||
case 'CategoryGroup':
|
||||
groupByList = categoryGroup.map(group => {
|
||||
return {
|
||||
...group,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
hidden: group.hidden,
|
||||
};
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
case 'Payee':
|
||||
groupByList = payees.map(payee => {
|
||||
return { id: payee.id, name: payee.name, hidden: false };
|
||||
|
||||
@@ -63,10 +63,12 @@ type graphOptions = {
|
||||
disableLabel?: boolean;
|
||||
disableSort?: boolean;
|
||||
};
|
||||
|
||||
const totalGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -76,7 +78,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'BarGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -84,7 +87,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'AreaGraph',
|
||||
disabledSplit: ['Category', 'Group', 'Payee', 'Account'],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['Category', 'Group', 'CategoryGroup', 'Payee', 'Account'],
|
||||
defaultSplit: 'Interval',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -94,6 +98,7 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'DonutGraph',
|
||||
// CategoryGroup is allowed here — it enables the two-ring concentric donut
|
||||
disabledSplit: [],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net'],
|
||||
@@ -105,7 +110,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
const timeGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode (DonutGraph not available in time mode)
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net Payment', 'Net Deposit'],
|
||||
defaultType: 'Payment',
|
||||
@@ -116,7 +122,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'StackedBarGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -125,7 +132,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'LineGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Pie, PieChart, Sector, Tooltip } from 'recharts';
|
||||
import { Pie, PieChart, Sector } from 'recharts';
|
||||
import type { PieSectorShapeProps } from 'recharts';
|
||||
|
||||
import type {
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DataEntity,
|
||||
GroupedEntity,
|
||||
IntervalEntity,
|
||||
LegendEntity,
|
||||
RuleConditionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
@@ -31,52 +32,162 @@ const RADIAN = Math.PI / 180;
|
||||
|
||||
const canDeviceHover = () => window.matchMedia('(hover: hover)').matches;
|
||||
|
||||
const ActiveShapeMobile = props => {
|
||||
const {
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
format,
|
||||
} = props;
|
||||
const yAxis = payload.name ?? payload.date;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dimension helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sin = Math.sin(-RADIAN * 240);
|
||||
const my = cy + outerRadius * sin;
|
||||
const ey = my - 5;
|
||||
type DonutDimensions = {
|
||||
chartInnerRadius: number;
|
||||
chartMidRadius: number;
|
||||
chartOuterRadius: number;
|
||||
compact: boolean;
|
||||
};
|
||||
|
||||
const getDonutDimensions = (
|
||||
width: number,
|
||||
height: number,
|
||||
twoRings: boolean,
|
||||
): DonutDimensions => {
|
||||
const minDim = Math.min(width, height);
|
||||
const compact = height <= 300 || width <= 300;
|
||||
return {
|
||||
chartInnerRadius: minDim * (twoRings && compact ? 0.16 : 0.2),
|
||||
chartMidRadius: minDim * (compact ? 0.27 : 0.31),
|
||||
chartOuterRadius: minDim * (compact ? 0.36 : 0.42),
|
||||
compact,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveCSSVariable = (color: string): string => {
|
||||
if (!color.startsWith('var(')) return color;
|
||||
const inner = color.slice(4, -1).trim();
|
||||
const varName = inner.split(',')[0].trim();
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
};
|
||||
|
||||
const shadeColor = (resolvedHex: string, percent: number): string => {
|
||||
const { r, g, b } = hexToRgb(resolvedHex);
|
||||
const adjust = (c: number) =>
|
||||
Math.min(255, Math.max(0, Math.round(c + (255 - c) * percent)));
|
||||
return `rgb(${adjust(r)}, ${adjust(g)}, ${adjust(b)})`;
|
||||
};
|
||||
|
||||
const buildColorMap = (
|
||||
groupedData: GroupedEntity[],
|
||||
legend: LegendEntity[],
|
||||
): Map<string, string> => {
|
||||
const legendById = new Map(
|
||||
legend
|
||||
.filter(l => l.id != null)
|
||||
.map(l => [l.id, resolveCSSVariable(l.color)]),
|
||||
);
|
||||
|
||||
return groupedData.reduce((acc, group) => {
|
||||
if (!group.id) return acc;
|
||||
|
||||
const groupColor = legendById.get(group.id);
|
||||
if (!groupColor) return acc;
|
||||
|
||||
acc.set(group.id, groupColor);
|
||||
|
||||
// Fix 1: capture cats once to avoid group.categories.length on undefined
|
||||
const cats = group.categories ?? [];
|
||||
cats.forEach((cat, i) => {
|
||||
if (!cat.id) return;
|
||||
const shade = 0.15 + (i / Math.max(cats.length, 1)) * 0.5;
|
||||
acc.set(cat.id, shadeColor(groupColor, shade));
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, new Map<string, string>());
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ActiveShapeProps = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
fill: string;
|
||||
payload: { name?: string; date?: string };
|
||||
percent: number;
|
||||
value: number;
|
||||
expandInward: boolean;
|
||||
chartInnerRadius: number;
|
||||
chartMidRadius: number;
|
||||
chartOuterRadius: number;
|
||||
};
|
||||
|
||||
const ActiveShapeMobile = ({
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
expandInward,
|
||||
chartInnerRadius,
|
||||
chartMidRadius,
|
||||
chartOuterRadius,
|
||||
}: ActiveShapeProps) => {
|
||||
const format = useFormat();
|
||||
// Fix 2: guard against undefined payload.name and payload.date
|
||||
const yAxis = payload.name ?? payload.date ?? '';
|
||||
|
||||
const expansionInner = expandInward ? chartInnerRadius - 4 : outerRadius + 2;
|
||||
const expansionOuter = expandInward ? chartInnerRadius - 2 : outerRadius + 4;
|
||||
const ey = cy + chartOuterRadius * Math.sin(-RADIAN * 240) - 5;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + outerRadius * Math.sin(-RADIAN * 270) + 15}
|
||||
dy={0}
|
||||
y={cy + chartOuterRadius * Math.sin(-RADIAN * 270) + 17}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
>
|
||||
{`${yAxis}`}
|
||||
{yAxis}
|
||||
</text>
|
||||
<PrivacyFilter>
|
||||
<FinancialText
|
||||
as="text"
|
||||
x={cx + outerRadius * Math.cos(-RADIAN * 240) - 30}
|
||||
x={cx + chartOuterRadius * Math.cos(-RADIAN * 240) - 30}
|
||||
y={ey}
|
||||
dy={0}
|
||||
textAnchor="end"
|
||||
fill={fill}
|
||||
>
|
||||
{`${format(value, 'financial')}`}
|
||||
{format(value, 'financial')}
|
||||
</FinancialText>
|
||||
<text
|
||||
x={cx + outerRadius * Math.cos(-RADIAN * 330) + 10}
|
||||
x={cx + chartOuterRadius * Math.cos(-RADIAN * 330) + 10}
|
||||
y={ey}
|
||||
dy={0}
|
||||
textAnchor="start"
|
||||
fill="#999"
|
||||
>
|
||||
@@ -97,43 +208,52 @@ const ActiveShapeMobile = props => {
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={innerRadius - 8}
|
||||
outerRadius={innerRadius - 6}
|
||||
innerRadius={expansionInner}
|
||||
outerRadius={expansionOuter}
|
||||
fill={fill}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveShapeMobileWithFormat = props => (
|
||||
<ActiveShapeMobile {...props} format={props.format} />
|
||||
);
|
||||
|
||||
const ActiveShape = props => {
|
||||
const {
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
format,
|
||||
} = props;
|
||||
const yAxis = payload.name ?? payload.date;
|
||||
const ActiveShapeDesktop = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
expandInward,
|
||||
chartInnerRadius,
|
||||
chartMidRadius,
|
||||
chartOuterRadius,
|
||||
}: ActiveShapeProps) => {
|
||||
const format = useFormat();
|
||||
// Fix 2: guard against undefined payload.name and payload.date
|
||||
const yAxis = payload.name ?? payload.date ?? '';
|
||||
const sin = Math.sin(-RADIAN * midAngle);
|
||||
const cos = Math.cos(-RADIAN * midAngle);
|
||||
const sx = cx + (innerRadius - 10) * cos;
|
||||
const sy = cy + (innerRadius - 10) * sin;
|
||||
const mx = cx + (innerRadius - 30) * cos;
|
||||
const my = cy + (innerRadius - 30) * sin;
|
||||
|
||||
const expansionInner = expandInward ? chartInnerRadius - 10 : outerRadius + 6;
|
||||
const expansionOuter = expandInward ? chartInnerRadius - 6 : outerRadius + 10;
|
||||
|
||||
const lineStart = expandInward
|
||||
? chartInnerRadius - 20
|
||||
: chartInnerRadius - 10;
|
||||
const lineMid = chartInnerRadius * 0.7;
|
||||
const sx = cx + lineStart * cos;
|
||||
const sy = cy + lineStart * sin;
|
||||
const mx = cx + lineMid * cos;
|
||||
const my = cy + lineMid * sin;
|
||||
const ex = cx + (cos >= 0 ? 1 : -1) * yAxis.length * 4;
|
||||
const ey = cy + 8;
|
||||
const textAnchor = cos <= 0 ? 'start' : 'end';
|
||||
const labelX = ex + (cos <= 0 ? 1 : -1) * 16;
|
||||
|
||||
return (
|
||||
<g>
|
||||
@@ -151,8 +271,8 @@ const ActiveShape = props => {
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius + 6}
|
||||
outerRadius={outerRadius + 10}
|
||||
innerRadius={expansionInner}
|
||||
outerRadius={expansionOuter}
|
||||
fill={fill}
|
||||
/>
|
||||
<path
|
||||
@@ -161,30 +281,21 @@ const ActiveShape = props => {
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={ex} cy={ey} r={3} fill={fill} stroke="none" />
|
||||
<text
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
y={ey}
|
||||
textAnchor={textAnchor}
|
||||
fill={fill}
|
||||
>{`${yAxis}`}</text>
|
||||
<text x={labelX} y={ey} textAnchor={textAnchor} fill={fill}>
|
||||
{yAxis}
|
||||
</text>
|
||||
<PrivacyFilter>
|
||||
<FinancialText
|
||||
as="text"
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
x={labelX}
|
||||
y={ey}
|
||||
dy={18}
|
||||
textAnchor={textAnchor}
|
||||
fill={fill}
|
||||
>
|
||||
{`${format(value, 'financial')}`}
|
||||
{format(value, 'financial')}
|
||||
</FinancialText>
|
||||
<text
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
y={ey}
|
||||
dy={36}
|
||||
textAnchor={textAnchor}
|
||||
fill="#999"
|
||||
>
|
||||
<text x={labelX} y={ey} dy={36} textAnchor={textAnchor} fill="#999">
|
||||
{`(${(percent * 100).toFixed(2)}%)`}
|
||||
</text>
|
||||
</PrivacyFilter>
|
||||
@@ -192,15 +303,14 @@ const ActiveShape = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveShapeWithFormat = props => (
|
||||
<ActiveShape {...props} format={props.format} />
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const customLabel = props => {
|
||||
const radius =
|
||||
props.innerRadius + (props.outerRadius - props.innerRadius) * 0.5;
|
||||
const size = props.cx > props.cy ? props.cy : props.cx;
|
||||
|
||||
const calcX = props.cx + radius * Math.cos(-props.midAngle * RADIAN);
|
||||
const calcY = props.cy + radius * Math.sin(-props.midAngle * RADIAN);
|
||||
const textAnchor = calcX > props.cx ? 'start' : 'end';
|
||||
@@ -209,7 +319,6 @@ const customLabel = props => {
|
||||
const showLabel = props.percent;
|
||||
const showLabelThreshold = 0.05;
|
||||
const fill = theme.reportsInnerLabel;
|
||||
|
||||
return renderCustomLabel(
|
||||
calcX,
|
||||
calcY,
|
||||
@@ -222,6 +331,10 @@ const customLabel = props => {
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DonutGraphProps = {
|
||||
style?: CSSProperties;
|
||||
data: DataEntity;
|
||||
@@ -245,7 +358,6 @@ export function DonutGraph({
|
||||
showOffBudget,
|
||||
showTooltip = true,
|
||||
}: DonutGraphProps) {
|
||||
const format = useFormat();
|
||||
const animationProps = useRechartsAnimation({ isAnimationActive: false });
|
||||
|
||||
const yAxis = groupBy === 'Interval' ? 'date' : 'name';
|
||||
@@ -259,18 +371,248 @@ export function DonutGraph({
|
||||
const getVal = (obj: GroupedEntity | IntervalEntity) => {
|
||||
if (['totalDebts', 'netDebts'].includes(balanceTypeOp)) {
|
||||
return -1 * obj[balanceTypeOp];
|
||||
} else {
|
||||
return obj[balanceTypeOp];
|
||||
}
|
||||
return obj[balanceTypeOp];
|
||||
};
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
|
||||
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
|
||||
const [activeRing, setActiveRing] = useState<'group' | 'category'>(
|
||||
'category',
|
||||
);
|
||||
|
||||
const isCategoryGroup =
|
||||
groupBy === 'CategoryGroup' && !!data.groupedData?.length;
|
||||
|
||||
const { adjustedGroupData, flatCategories } = useMemo(() => {
|
||||
if (!isCategoryGroup || !data.groupedData) {
|
||||
return { adjustedGroupData: [], flatCategories: [] };
|
||||
}
|
||||
|
||||
const adjustedGroups = data.groupedData
|
||||
.map(group => {
|
||||
const visibleCats = group.categories ?? [];
|
||||
return {
|
||||
...group,
|
||||
totalAssets: visibleCats.reduce((sum, c) => sum + c.totalAssets, 0),
|
||||
totalDebts: visibleCats.reduce((sum, c) => sum + c.totalDebts, 0),
|
||||
totalTotals: visibleCats.reduce((sum, c) => sum + c.totalTotals, 0),
|
||||
netAssets: visibleCats.reduce((sum, c) => sum + c.netAssets, 0),
|
||||
netDebts: visibleCats.reduce((sum, c) => sum + c.netDebts, 0),
|
||||
};
|
||||
})
|
||||
.filter(group =>
|
||||
['totalDebts', 'netDebts'].includes(balanceTypeOp)
|
||||
? -1 * group[balanceTypeOp] !== 0
|
||||
: group[balanceTypeOp] !== 0,
|
||||
);
|
||||
|
||||
return {
|
||||
adjustedGroupData: adjustedGroups,
|
||||
flatCategories: data.groupedData.flatMap(g => g.categories ?? []),
|
||||
};
|
||||
}, [isCategoryGroup, data.groupedData, balanceTypeOp]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() =>
|
||||
isCategoryGroup
|
||||
? buildColorMap(data.groupedData ?? [], data.legend ?? [])
|
||||
: new Map<string, string>(),
|
||||
[isCategoryGroup, data.groupedData, data.legend],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container style={style}>
|
||||
{(width, height) => {
|
||||
const compact = height <= 300 || width <= 300;
|
||||
const { chartInnerRadius, chartMidRadius, chartOuterRadius, compact } =
|
||||
getDonutDimensions(width, height, isCategoryGroup);
|
||||
|
||||
const showActiveShape = width >= 220 && height >= 130;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Two-ring concentric donut (CategoryGroup mode)
|
||||
// ---------------------------------------------------------------
|
||||
if (isCategoryGroup) {
|
||||
return (
|
||||
data.groupedData && (
|
||||
<div>
|
||||
{!compact && <div style={{ marginTop: '15px' }} />}
|
||||
<PieChart
|
||||
responsive
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ cursor: pointer }}
|
||||
>
|
||||
{/* Inner ring — Category Groups */}
|
||||
<Pie
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey="name"
|
||||
{...animationProps}
|
||||
data={adjustedGroupData}
|
||||
innerRadius={chartInnerRadius}
|
||||
outerRadius={chartMidRadius}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const item = adjustedGroupData[index];
|
||||
const fill =
|
||||
colorMap.get(item?.id ?? item?.name ?? '') ??
|
||||
props.fill;
|
||||
const isActive =
|
||||
activeRing === 'group' && index === activeGroupIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
return compact ? (
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
}}
|
||||
onMouseLeave={() => setPointer('')}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (canDeviceHover()) {
|
||||
setActiveGroupIndex(index);
|
||||
setActiveRing('group');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveGroupIndex(index);
|
||||
setActiveRing('group');
|
||||
}
|
||||
if (
|
||||
(canDeviceHover() || activeGroupIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
const groupCategoryIds = (
|
||||
data.groupedData?.find(g => g.id === item.id)
|
||||
?.categories ?? []
|
||||
)
|
||||
.map(c => c.id)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
accounts,
|
||||
balanceTypeOp,
|
||||
filters,
|
||||
showHiddenCategories,
|
||||
showOffBudget,
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: 'category',
|
||||
id: groupCategoryIds,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Outer ring — Categories */}
|
||||
<Pie
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey="name"
|
||||
{...animationProps}
|
||||
data={flatCategories}
|
||||
innerRadius={chartMidRadius}
|
||||
outerRadius={chartOuterRadius}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
labelLine={false}
|
||||
label={e =>
|
||||
viewLabels && !compact ? customLabel(e) : null
|
||||
}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const item = flatCategories[index];
|
||||
const fill =
|
||||
colorMap.get(item?.id ?? item?.name ?? '') ??
|
||||
props.fill;
|
||||
const isActive =
|
||||
activeRing === 'category' &&
|
||||
index === activeCategoryIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
return compact ? (
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
}}
|
||||
onMouseLeave={() => setPointer('')}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (canDeviceHover()) {
|
||||
setActiveCategoryIndex(index);
|
||||
setActiveRing('category');
|
||||
setPointer('pointer');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveCategoryIndex(index);
|
||||
setActiveRing('category');
|
||||
}
|
||||
if (
|
||||
(canDeviceHover() || activeCategoryIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
accounts,
|
||||
balanceTypeOp,
|
||||
filters,
|
||||
showHiddenCategories,
|
||||
showOffBudget,
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: 'category',
|
||||
id: item.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Single-ring donut (all other groupBy modes)
|
||||
// ---------------------------------------------------------------
|
||||
return (
|
||||
data[splitData] && (
|
||||
<div>
|
||||
@@ -285,13 +627,8 @@ export function DonutGraph({
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey={yAxis}
|
||||
{...animationProps}
|
||||
data={
|
||||
data[splitData]?.map(item => ({
|
||||
...item,
|
||||
})) ?? []
|
||||
}
|
||||
innerRadius={Math.min(width, height) * 0.2}
|
||||
fill="#8884d8"
|
||||
data={data[splitData]?.map(item => ({ ...item })) ?? []}
|
||||
innerRadius={chartInnerRadius}
|
||||
labelLine={false}
|
||||
label={e =>
|
||||
viewLabels && !compact ? customLabel(e) : <div />
|
||||
@@ -299,15 +636,28 @@ export function DonutGraph({
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const fill = data.legend[index]?.color ?? props.fill;
|
||||
const showActiveShape = width >= 220 && height >= 130;
|
||||
const isActive = props.isActive || index === activeIndex;
|
||||
// Fix 3: optional chain data.legend to guard against undefined
|
||||
const fill = data.legend?.[index]?.color ?? props.fill;
|
||||
const isActive = index === activeIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
const shapeProps = { ...props, fill, format };
|
||||
return compact ? (
|
||||
<ActiveShapeMobileWithFormat {...shapeProps} />
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeWithFormat {...shapeProps} />
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
@@ -325,12 +675,21 @@ export function DonutGraph({
|
||||
if (!canDeviceHover()) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
|
||||
if (
|
||||
!['Group', 'Interval'].includes(groupBy) &&
|
||||
!['Interval'].includes(groupBy) &&
|
||||
(canDeviceHover() || activeIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
const groupCategoryIds =
|
||||
groupBy === 'Group'
|
||||
? (
|
||||
categories.grouped.find(g => g.id === item.id)
|
||||
?.categories ?? []
|
||||
)
|
||||
.map(c => c.id)
|
||||
.filter((c): c is string => c != null)
|
||||
: undefined;
|
||||
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
@@ -342,17 +701,15 @@ export function DonutGraph({
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: groupBy.toLowerCase(),
|
||||
id: item.id,
|
||||
field:
|
||||
groupBy === 'Group'
|
||||
? 'category'
|
||||
: groupBy.toLowerCase(),
|
||||
id: groupBy === 'Group' ? groupCategoryIds : item.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={() => null}
|
||||
defaultIndex={activeIndex}
|
||||
active
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ type showActivityProps = {
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
field?: string;
|
||||
id?: string;
|
||||
id?: string | string[]; // changed: supports array for oneOf
|
||||
interval?: string;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,13 @@ export function showActivity({
|
||||
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
id && { field, op: 'is', value: id, type: 'id' },
|
||||
id && {
|
||||
// changed: use oneOf when id is an array, is when it's a string
|
||||
field,
|
||||
op: Array.isArray(id) ? 'oneOf' : 'is',
|
||||
value: id,
|
||||
type: 'id',
|
||||
},
|
||||
{
|
||||
field: 'date',
|
||||
op: isDateOp ? 'gte' : 'is',
|
||||
@@ -97,6 +103,7 @@ export function showActivity({
|
||||
type: 'id',
|
||||
},
|
||||
].filter(f => f);
|
||||
|
||||
void navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
|
||||
Reference in New Issue
Block a user