mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-07 12:28:57 -05:00
Enhance Sankey chart datamodel, show income and allow layer filtering (#7582)
* Refactor to use directed, weighted graph as datamodel * Fix percentage labels * Reimplement sorting and topN handling * Fix typing. Show toBudget on graph. * Implement better DAG model * Fix Other-grouping with new datamodel * Add global sorting * Reorder spreadsheet code for clarity * Add percentageLabels back * Fix all sorting modes * Better color handling * Handle if overbudgeted * Fix filtering issue related to hidden nodes for Spent report * Implement enums for special names * Linting and typechecking * Add layer selectors * Trim SankeyCard * Fix issue with empty nodes making the graph unreadable * Add release note * Update release note * Reorder code * Address coderabbit comments * Ensure that layer-from and layer-to cannot be equal * Update layer selectors to match selected view mode * Fix wrong graph object reference * Cap regex length * Fixed wrong layer assignment for budget income categories * Make translation not optional in createSpreadsheet * Use predefined suffix for 'Other' * Avoid invalid layer selection for Budgeted * Update VRT screenshots Auto-generated by VRT workflow PR: #7582 * Import translation in spreadsheet, instead of passing as argument * Remove all non-null assertions and handle safely * Fix most uses of 'as' * Fix issues hiding Other categories and giving wrong toBudget value --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
227c995155
commit
686f10247d
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
@@ -13,20 +13,22 @@ import {
|
||||
} from 'recharts';
|
||||
import type { SankeyData } from 'recharts/types/chart/Sankey';
|
||||
|
||||
import { getColorScale } from '#components/reports/chart-theme';
|
||||
import { Container } from '#components/reports/Container';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { usePrivacyMode } from '#hooks/usePrivacyMode';
|
||||
|
||||
type SankeyGraphNode = SankeyData['nodes'][number] & {
|
||||
hasChildren?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
toBudget?: number;
|
||||
isNegative?: boolean;
|
||||
actualValue?: number;
|
||||
value: number;
|
||||
percentageLabel?: string;
|
||||
targetLinks?: Array<Record<string, unknown>>;
|
||||
sourceLinks?: Array<Record<string, unknown>>;
|
||||
key: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type SankeyLinkPayload = {
|
||||
source: SankeyGraphNode;
|
||||
target: SankeyGraphNode;
|
||||
value: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type SankeyLinkProps = {
|
||||
@@ -38,16 +40,10 @@ type SankeyLinkProps = {
|
||||
targetControlX: number;
|
||||
linkWidth: number;
|
||||
index: number;
|
||||
payload: {
|
||||
source: SankeyGraphNode;
|
||||
target: SankeyGraphNode;
|
||||
value: number;
|
||||
isNegative?: boolean;
|
||||
};
|
||||
payload: SankeyLinkPayload;
|
||||
isHovered: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function SankeyLink({
|
||||
@@ -62,9 +58,11 @@ function SankeyLink({
|
||||
isHovered,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
color,
|
||||
}: SankeyLinkProps) {
|
||||
const linkColor = payload.isNegative ? theme.errorText : color;
|
||||
if (payload.value <= 0) {
|
||||
return null;
|
||||
}
|
||||
const linkColor = payload.color ?? theme.reportsGray;
|
||||
const strokeWidth = linkWidth;
|
||||
const strokeOpacity = isHovered ? 1 : 0.6;
|
||||
|
||||
@@ -91,8 +89,8 @@ type SankeyNodeProps = {
|
||||
index: number;
|
||||
payload: SankeyGraphNode;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
showPercentages?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
function SankeyNode({
|
||||
x,
|
||||
@@ -102,21 +100,17 @@ function SankeyNode({
|
||||
index: _index,
|
||||
payload,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
showPercentages,
|
||||
}: SankeyNodeProps) {
|
||||
const privacyMode = usePrivacyMode();
|
||||
const format = useFormat();
|
||||
|
||||
if (payload.value <= 0) {
|
||||
return null;
|
||||
}
|
||||
const isOut = x + width + 6 > containerWidth;
|
||||
|
||||
const fillColor = payload.isNegative ? theme.errorText : theme.reportsBlue;
|
||||
|
||||
const toBudget = payload.toBudget ?? 0;
|
||||
const availableBelow = Math.max(0, containerHeight - 25 - (y + height));
|
||||
const proportionalHeight =
|
||||
toBudget > 0 && payload.value ? height * (toBudget / payload.value) : 0;
|
||||
const isClamped = proportionalHeight > availableBelow;
|
||||
const toBudgetHeight = Math.min(proportionalHeight, availableBelow);
|
||||
const fillColor = payload.color ?? theme.reportsBlue;
|
||||
|
||||
const renderText = (
|
||||
text: string,
|
||||
@@ -142,27 +136,6 @@ function SankeyNode({
|
||||
return (
|
||||
<Layer>
|
||||
<Rectangle x={x} y={y} width={width} height={height} fill={fillColor} />
|
||||
{toBudgetHeight > 0 &&
|
||||
(isClamped ? (
|
||||
<polygon
|
||||
points={`
|
||||
${x},${y + height}
|
||||
${x + width},${y + height}
|
||||
${x + width},${y + height + toBudgetHeight - 8}
|
||||
${x + width / 2},${y + height + toBudgetHeight}
|
||||
${x},${y + height + toBudgetHeight - 8}
|
||||
`}
|
||||
fill={theme.toBudgetPositive}
|
||||
/>
|
||||
) : (
|
||||
<Rectangle
|
||||
x={x}
|
||||
y={y + height}
|
||||
width={width}
|
||||
height={toBudgetHeight}
|
||||
fill={theme.toBudgetPositive}
|
||||
/>
|
||||
))}
|
||||
{renderText(payload.name || '', height / 2)}
|
||||
{renderText(
|
||||
showPercentages && payload.percentageLabel
|
||||
@@ -173,24 +146,6 @@ function SankeyNode({
|
||||
0.5,
|
||||
privacyMode ? t('Redacted Script') : undefined,
|
||||
)}
|
||||
{toBudgetHeight > 0 &&
|
||||
renderText(
|
||||
format(toBudget, 'financial'),
|
||||
toBudgetHeight / 2 + 13,
|
||||
11,
|
||||
0.5,
|
||||
privacyMode ? t('Redacted Script') : undefined,
|
||||
y + height,
|
||||
)}
|
||||
{toBudgetHeight > 0 &&
|
||||
renderText(
|
||||
t('To budget'),
|
||||
toBudgetHeight / 2,
|
||||
13,
|
||||
1,
|
||||
undefined,
|
||||
y + height,
|
||||
)}
|
||||
</Layer>
|
||||
);
|
||||
}
|
||||
@@ -199,7 +154,6 @@ type SankeyGraphProps = {
|
||||
style?: CSSProperties;
|
||||
data: SankeyData;
|
||||
showTooltip?: boolean;
|
||||
collapsedNodes?: string[];
|
||||
showPercentages?: boolean;
|
||||
};
|
||||
export function SankeyGraph({
|
||||
@@ -212,19 +166,6 @@ export function SankeyGraph({
|
||||
const format = useFormat();
|
||||
const [hoveredLinkIndex, setHoveredLinkIndex] = useState<number | null>(null);
|
||||
|
||||
const colors = getColorScale('qualitative');
|
||||
const sourceColorMap = new Map(
|
||||
[
|
||||
...new Set(
|
||||
data.links
|
||||
.filter(l => (l.source as number) !== 0)
|
||||
.map(l => data.nodes[l.source as number]?.name),
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((name, i) => [name, colors[i % colors.length]]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Container style={style}>
|
||||
{(width, height) => (
|
||||
@@ -235,7 +176,6 @@ export function SankeyGraph({
|
||||
<SankeyNode
|
||||
{...props}
|
||||
containerWidth={width}
|
||||
containerHeight={height}
|
||||
showPercentages={showPercentages}
|
||||
/>
|
||||
)}
|
||||
@@ -245,10 +185,6 @@ export function SankeyGraph({
|
||||
isHovered={hoveredLinkIndex === props.index}
|
||||
onMouseEnter={() => setHoveredLinkIndex(props.index)}
|
||||
onMouseLeave={() => setHoveredLinkIndex(null)}
|
||||
color={
|
||||
sourceColorMap.get(props.payload.source.name) ??
|
||||
theme.reportsGray
|
||||
}
|
||||
/>
|
||||
)}
|
||||
sort={false}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useParams } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { SvgArrowDown, SvgList } from '@actual-app/components/icons/v1';
|
||||
import {
|
||||
SvgArrowDown,
|
||||
SvgCheveronRight,
|
||||
SvgLayers,
|
||||
SvgList,
|
||||
SvgRefresh,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
@@ -32,8 +38,9 @@ import { LoadingIndicator } from '#components/reports/LoadingIndicator';
|
||||
import { ModeButton } from '#components/reports/ModeButton';
|
||||
import { calculateTimeRange } from '#components/reports/reportRanges';
|
||||
import {
|
||||
GRAPH_LAYER_ORDER,
|
||||
GraphLayers,
|
||||
createSpreadsheet as sankeySpreadsheet,
|
||||
withPercentageLabels,
|
||||
} from '#components/reports/spreadsheets/sankey-spreadsheet';
|
||||
import { useReport } from '#components/reports/useReport';
|
||||
import { fromDateRepr } from '#components/reports/util';
|
||||
@@ -166,6 +173,91 @@ function CategorySortSelector({ value, onChange }: CategorySortSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type LayerSelectorProps = {
|
||||
direction: 'from' | 'to';
|
||||
value: GraphLayers;
|
||||
otherLayer: GraphLayers | undefined;
|
||||
onChange: (layer: GraphLayers) => void;
|
||||
graphMode: GraphMode;
|
||||
};
|
||||
|
||||
function LayerSelector({
|
||||
direction,
|
||||
value,
|
||||
otherLayer,
|
||||
onChange,
|
||||
graphMode,
|
||||
}: LayerSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const LAYER_LABELS: Record<GraphLayers, string> = {
|
||||
[GraphLayers.IncomePayee]: t('Payee'),
|
||||
[GraphLayers.IncomeCategory]: t('Income category'),
|
||||
[GraphLayers.Account]: t('Account'),
|
||||
[GraphLayers.Budget]: t('Budget'),
|
||||
[GraphLayers.CategoryGroup]: t('Category group'),
|
||||
[GraphLayers.Category]: t('Category'),
|
||||
};
|
||||
|
||||
// Filter available layers based on graph mode
|
||||
const availableLayers: readonly GraphLayers[] =
|
||||
graphMode === 'budgeted'
|
||||
? GRAPH_LAYER_ORDER.filter(
|
||||
layer => layer !== GraphLayers.IncomePayee, // IncomePayee not available in budgeted
|
||||
)
|
||||
: GRAPH_LAYER_ORDER.filter(
|
||||
layer => layer !== GraphLayers.Budget, // Budget not available in spent
|
||||
);
|
||||
|
||||
const otherIndex =
|
||||
otherLayer !== undefined && availableLayers.includes(otherLayer)
|
||||
? availableLayers.indexOf(otherLayer)
|
||||
: direction === 'from'
|
||||
? availableLayers.length - 1
|
||||
: 0;
|
||||
|
||||
const menuItems =
|
||||
direction === 'from'
|
||||
? availableLayers.slice(0, otherIndex)
|
||||
: availableLayers.slice(otherIndex + 1);
|
||||
|
||||
const translatedDirection = direction === 'from' ? t('from') : t('to');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
onPress={() => setIsOpen(true)}
|
||||
aria-label={t('Change layer {{direction}}', {
|
||||
direction: translatedDirection,
|
||||
})}
|
||||
>
|
||||
<span style={{ marginLeft: 5 }}>{LAYER_LABELS[value]}</span>
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
onChange(item as GraphLayers);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
items={menuItems.map(layer => ({
|
||||
name: layer,
|
||||
text: LAYER_LABELS[layer],
|
||||
}))}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type GraphModeSelectorProps = {
|
||||
mode: GraphMode;
|
||||
onChange: (mode: GraphMode) => void;
|
||||
@@ -296,6 +388,62 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
widget?.meta?.showPercentages ?? false,
|
||||
);
|
||||
|
||||
// Determine default layer based on mode
|
||||
const defaultLayerFrom = (mode: GraphMode) =>
|
||||
mode === 'budgeted' ? GraphLayers.IncomeCategory : GraphLayers.IncomePayee;
|
||||
|
||||
const [layerFrom, setLayerFrom] = useState<GraphLayers>(() => {
|
||||
const metaLayer = widget?.meta?.layerFrom as GraphLayers | undefined;
|
||||
if (metaLayer) {
|
||||
// Validate that the layer is valid for the current mode
|
||||
const mode = widget?.meta?.mode ?? 'spent';
|
||||
if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) {
|
||||
return defaultLayerFrom('budgeted');
|
||||
}
|
||||
if (mode === 'spent' && metaLayer === GraphLayers.Budget) {
|
||||
return defaultLayerFrom('spent');
|
||||
}
|
||||
return metaLayer;
|
||||
}
|
||||
return defaultLayerFrom(widget?.meta?.mode ?? 'spent');
|
||||
});
|
||||
|
||||
const [layerTo, setLayerTo] = useState<GraphLayers>(() => {
|
||||
const metaLayer = widget?.meta?.layerTo as GraphLayers | undefined;
|
||||
if (metaLayer) {
|
||||
// Validate that the layer is valid for the current mode
|
||||
const mode = widget?.meta?.mode ?? 'spent';
|
||||
if (mode === 'budgeted' && metaLayer === GraphLayers.IncomePayee) {
|
||||
return GraphLayers.Category;
|
||||
}
|
||||
if (mode === 'spent' && metaLayer === GraphLayers.Budget) {
|
||||
return GraphLayers.Category;
|
||||
}
|
||||
return metaLayer;
|
||||
}
|
||||
return GraphLayers.Category;
|
||||
});
|
||||
|
||||
// Reset invalid layer selections when switching modes
|
||||
useEffect(() => {
|
||||
const availableLayers =
|
||||
graphMode === 'budgeted'
|
||||
? (GRAPH_LAYER_ORDER.filter(
|
||||
layer => layer !== GraphLayers.IncomePayee,
|
||||
) as GraphLayers[])
|
||||
: (GRAPH_LAYER_ORDER.filter(
|
||||
layer => layer !== GraphLayers.Budget,
|
||||
) as GraphLayers[]);
|
||||
|
||||
const fromIndex = availableLayers.indexOf(layerFrom);
|
||||
const toIndex = availableLayers.indexOf(layerTo);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex >= toIndex) {
|
||||
setLayerFrom(defaultLayerFrom(graphMode));
|
||||
setLayerTo(GraphLayers.Category);
|
||||
}
|
||||
}, [graphMode, layerFrom, layerTo]);
|
||||
|
||||
const { data: { grouped: groupedCategories = [] } = { grouped: [] } } =
|
||||
useCategories();
|
||||
|
||||
@@ -313,6 +461,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
graphMode,
|
||||
topNcategories,
|
||||
categorySort,
|
||||
layerFrom,
|
||||
layerTo,
|
||||
);
|
||||
}, [
|
||||
datesInitialized,
|
||||
@@ -324,6 +474,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
graphMode,
|
||||
topNcategories,
|
||||
categorySort,
|
||||
layerFrom,
|
||||
layerTo,
|
||||
]);
|
||||
|
||||
const defaultGetData = async (
|
||||
@@ -418,6 +570,8 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
topNcategories,
|
||||
categorySort,
|
||||
showPercentages,
|
||||
layerFrom,
|
||||
layerTo,
|
||||
timeFrame: {
|
||||
start,
|
||||
end,
|
||||
@@ -549,6 +703,46 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
value={categorySort}
|
||||
onChange={setCategorySort}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: 1,
|
||||
height: 28,
|
||||
backgroundColor: theme.pillBorderDark,
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
/>
|
||||
<SvgLayers style={{ width: 12, height: 12 }} />
|
||||
<LayerSelector
|
||||
direction="from"
|
||||
value={layerFrom}
|
||||
otherLayer={layerTo}
|
||||
onChange={setLayerFrom}
|
||||
graphMode={graphMode}
|
||||
/>
|
||||
<SvgCheveronRight style={{ width: 12, height: 12 }} />
|
||||
<LayerSelector
|
||||
direction="to"
|
||||
value={layerTo}
|
||||
otherLayer={layerFrom}
|
||||
onChange={setLayerTo}
|
||||
graphMode={graphMode}
|
||||
/>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => {
|
||||
if (graphMode === 'budgeted') {
|
||||
setLayerFrom(GraphLayers.IncomeCategory);
|
||||
setLayerTo(GraphLayers.Category);
|
||||
} else {
|
||||
setLayerFrom(GraphLayers.IncomePayee);
|
||||
setLayerTo(GraphLayers.Category);
|
||||
}
|
||||
}}
|
||||
aria-label={t('Reset layers')}
|
||||
>
|
||||
<SvgRefresh style={{ width: 12, height: 12 }} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -606,11 +800,7 @@ function SankeyInner({ widget }: SankeyInnerProps) {
|
||||
{data && data.links && data.links.length > 0 ? (
|
||||
<SankeyGraph
|
||||
style={{ flexGrow: 1 }}
|
||||
data={
|
||||
showPercentages
|
||||
? withPercentageLabels(data as SankeyData)
|
||||
: (data as SankeyData)
|
||||
}
|
||||
data={data}
|
||||
showPercentages={showPercentages}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -14,9 +14,9 @@ import { ReportCard } from '#components/reports/ReportCard';
|
||||
import { ReportCardName } from '#components/reports/ReportCardName';
|
||||
import { calculateTimeRange } from '#components/reports/reportRanges';
|
||||
import {
|
||||
compactSankeyData,
|
||||
GraphLayers,
|
||||
// compactSankeyData,
|
||||
createSpreadsheet as sankeySpreadsheet,
|
||||
withPercentageLabels,
|
||||
} from '#components/reports/spreadsheets/sankey-spreadsheet';
|
||||
import { useDashboardWidgetCopyMenu } from '#components/reports/useDashboardWidgetCopyMenu';
|
||||
import { useReport } from '#components/reports/useReport';
|
||||
@@ -56,6 +56,13 @@ export function SankeyCard({
|
||||
setCardHeight(rect.height);
|
||||
});
|
||||
|
||||
const HEADER_HEIGHT = 82;
|
||||
const PX_PER_NODE = 50;
|
||||
const topN = Math.max(
|
||||
2,
|
||||
Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE),
|
||||
);
|
||||
|
||||
const params = useMemo(
|
||||
() =>
|
||||
sankeySpreadsheet(
|
||||
@@ -65,22 +72,25 @@ export function SankeyCard({
|
||||
meta?.conditions ?? [],
|
||||
meta?.conditionsOp ?? 'and',
|
||||
mode,
|
||||
topN,
|
||||
meta?.categorySort,
|
||||
GraphLayers.IncomePayee,
|
||||
GraphLayers.CategoryGroup,
|
||||
),
|
||||
[start, end, groupedCategories, meta?.conditions, meta?.conditionsOp, mode],
|
||||
[
|
||||
start,
|
||||
end,
|
||||
groupedCategories,
|
||||
meta?.conditions,
|
||||
meta?.conditionsOp,
|
||||
mode,
|
||||
topN,
|
||||
meta?.categorySort,
|
||||
],
|
||||
);
|
||||
const data = useReport('sankey', params);
|
||||
|
||||
const HEADER_HEIGHT = 82;
|
||||
const PX_PER_NODE = 50;
|
||||
const topN = Math.max(
|
||||
2,
|
||||
Math.floor((cardHeight - HEADER_HEIGHT) / PX_PER_NODE),
|
||||
);
|
||||
|
||||
const compactData = useMemo(
|
||||
() => (data ? compactSankeyData(data, topN) : null),
|
||||
[data, topN],
|
||||
);
|
||||
const compactData = useMemo(() => data, [data]);
|
||||
|
||||
const startDate = d.parseISO(start);
|
||||
const endDate = d.parseISO(end);
|
||||
@@ -154,11 +164,7 @@ export function SankeyCard({
|
||||
|
||||
{compactData ? (
|
||||
<SankeyGraph
|
||||
data={
|
||||
meta?.showPercentages
|
||||
? withPercentageLabels(compactData)
|
||||
: compactData
|
||||
}
|
||||
data={compactData}
|
||||
showPercentages={meta?.showPercentages}
|
||||
showTooltip={!isEditing}
|
||||
style={{ height: 'auto', flex: 1 }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -223,5 +223,7 @@ export type SankeyWidget = AbstractWidget<
|
||||
topNcategories?: number;
|
||||
categorySort?: 'per-group' | 'global' | 'budget-order';
|
||||
showPercentages?: boolean;
|
||||
layerFrom?: string;
|
||||
layerTo?: string;
|
||||
} | null
|
||||
>;
|
||||
|
||||
6
upcoming-release-notes/7582.md
Normal file
6
upcoming-release-notes/7582.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [emiltb]
|
||||
---
|
||||
|
||||
Optimize Sankey chart datamodel to include income sources, allow layer filtering and better budget handling
|
||||
Reference in New Issue
Block a user