Skip to content
Snippets Groups Projects
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>
  );
}