AreaGraph.tsx 7.37 KiB
// @ts-strict-ignore
import React from 'react';
import { css } from 'glamor';
import {
AreaChart,
Area,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
LabelList,
ResponsiveContainer,
} from 'recharts';
import { usePrivacyMode } from 'loot-core/src/client/privacy';
import {
amountToCurrency,
amountToCurrencyNoDecimal,
} from 'loot-core/src/shared/util';
import { type GroupedEntity } from 'loot-core/src/types/models/reports';
import { theme } from '../../../style';
import { type CSSProperties } from '../../../style';
import { AlignedText } from '../../common/AlignedText';
import { PrivacyFilter } from '../../PrivacyFilter';
import { Container } from '../Container';
import { numberFormatterTooltip } from '../numberFormatter';
import { adjustTextSize } from './adjustTextSize';
import { renderCustomLabel } from './renderCustomLabel';
type PayloadItem = {
payload: {
date: string;
totalAssets: number | string;
totalDebts: number | string;
totalTotals: number | string;
};
};
type CustomTooltipProps = {
active?: boolean;
payload?: PayloadItem[];
balanceTypeOp?: string;
};
const CustomTooltip = ({
active,
payload,
balanceTypeOp,
}: CustomTooltipProps) => {
if (active && payload && payload.length) {
return (
<div
className={`${css({
zIndex: 1000,
pointerEvents: 'none',
borderRadius: 2,
boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
padding: 10,
})}`}
>
<div>
<div style={{ marginBottom: 10 }}>
<strong>{payload[0].payload.date}</strong>
</div>
<div style={{ lineHeight: 1.5 }}>
<PrivacyFilter>
{['totalAssets', 'totalTotals'].includes(balanceTypeOp) && (
<AlignedText
left="Assets:"
right={amountToCurrency(payload[0].payload.totalAssets)}
/>
)}
{['totalDebts', 'totalTotals'].includes(balanceTypeOp) && (
<AlignedText
left="Debt:"
right={amountToCurrency(payload[0].payload.totalDebts)}
/>
)}
{['totalTotals'].includes(balanceTypeOp) && (
<AlignedText
left="Net:"
right={
<strong>
{amountToCurrency(payload[0].payload.totalTotals)}
</strong>
}
/>
)}
</PrivacyFilter>
</div>
</div>
</div>
);
}
};
const customLabel = (props, width, end) => {
//Add margin to first and last object
const calcX =
props.x + (props.index === end ? -10 : props.index === 0 ? 5 : 0);
const calcY = props.y - (props.value > 0 ? 10 : -10);
const textAnchor = props.index === 0 ? 'left' : 'middle';
const display =
props.value !== 0 && `${amountToCurrencyNoDecimal(props.value)}`;
const textSize = adjustTextSize(width, 'area');
return renderCustomLabel(calcX, calcY, textAnchor, display, textSize);
};
type AreaGraphProps = {
style?: CSSProperties;
data: GroupedEntity;
balanceTypeOp: string;
compact?: boolean;
viewLabels: boolean;
};
export function AreaGraph({
style,
data,
balanceTypeOp,
compact,
viewLabels,
}: AreaGraphProps) {
const privacyMode = usePrivacyMode();
const dataMax = Math.max(...data.monthData.map(i => i[balanceTypeOp]));
const dataMin = Math.min(...data.monthData.map(i => i[balanceTypeOp]));
const labelsMargin = viewLabels ? 30 : 0;
const dataDiff = dataMax - dataMin;
//Calculate how much to add to max and min values for graph range
const extendRangeAmount = Math.floor(dataDiff / 20);
const labelsMin =
//If min is zero or graph range passes zero then set it to zero
dataMin === 0 || Math.abs(dataMin) <= extendRangeAmount
? 0
: //Else add the range and round to nearest 100s
Math.floor((dataMin - extendRangeAmount) / 100) * 100;
//Same as above but for max
const labelsMax =
dataMax === 0 || Math.abs(dataMax) <= extendRangeAmount
? 0
: Math.ceil((dataMax + extendRangeAmount) / 100) * 100;
const lastLabel = data.monthData.length - 1;
const tickFormatter = tick => {
if (!privacyMode) return `${Math.round(tick).toLocaleString()}`; // Formats the tick values as strings with commas
return '...';
};
const gradientOffset = () => {
if (dataMax <= 0) {
return 0;
}
if (dataMin >= 0) {
return 1;
}
return dataMax / (dataMax - dataMin);
};
const off = gradientOffset();
return (
<Container
style={{
...style,
...(compact && { height: 'auto' }),
}}
>
{(width, height, portalHost) =>
data.monthData && (
<ResponsiveContainer>
<div>
{!compact && <div style={{ marginTop: '15px' }} />}
<AreaChart
width={width}
height={height}
data={data.monthData}
margin={{ top: 0, right: labelsMargin, left: 0, bottom: 0 }}
>
{compact ? null : (
<CartesianGrid strokeDasharray="3 3" vertical={false} />
)}
{compact ? null : (
<XAxis
dataKey="date"
tick={{ fill: theme.pageText }}
tickLine={{ stroke: theme.pageText }}
/>
)}
{compact ? null : (
<YAxis
dataKey={balanceTypeOp}
domain={[
viewLabels ? labelsMin : 'auto',
viewLabels ? labelsMax : 'auto',
]}
tickFormatter={tickFormatter}
tick={{ fill: theme.pageText }}
tickLine={{ stroke: theme.pageText }}
/>
)}
<Tooltip
content={<CustomTooltip balanceTypeOp={balanceTypeOp} />}
formatter={numberFormatterTooltip}
isAnimationActive={false}
/>
<defs>
<linearGradient id="splitColor" x1="0" y1="0" x2="0" y2="1">
<stop
offset={off}
stopColor={theme.reportsBlue}
stopOpacity={0.2}
/>
<stop
offset={off}
stopColor={theme.reportsRed}
stopOpacity={0.2}
/>
</linearGradient>
</defs>
<Area
type="linear"
dot={false}
activeDot={false}
animationDuration={0}
dataKey={balanceTypeOp}
stroke={theme.reportsBlue}
fill="url(#splitColor)"
fillOpacity={1}
>
{viewLabels && (
<LabelList
dataKey={balanceTypeOp}
content={e => customLabel(e, width, lastLabel)}
/>
)}
</Area>
</AreaChart>
</div>
</ResponsiveContainer>
)
}
</Container>
);
}