Vizzuality/landgriffon

View on GitHub
client/src/containers/analysis-chart/impact-chart/component.tsx

Summary

Maintainability
F
4 days
Test Coverage
import { useCallback, useEffect, useMemo, useState } from 'react';
import omit from 'lodash-es/omit';
import chroma from 'chroma-js';
import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Legend,
  Tooltip,
  ResponsiveContainer,
} from 'recharts';

import CustomLegend from './legend';
import CustomTooltip from './tooltip';

import { filtersForTabularAPI } from 'store/features/analysis/selector';
import { useImpactRanking } from 'hooks/impact/ranking';
import { useAppSelector } from 'store/hooks';
import { scenarios } from 'store/features/analysis';
import Loading from 'components/loading';
import { formatNumber } from 'utils/number-format';

import type { ExtendedLegendProps } from './legend/component';
import type { Indicator } from 'types';

type StackedAreaChartProps = {
  indicator: Indicator;
};

const COLORS = ['#1C44C3', '#5DBCC5', '#ED8F23', '#3D8CE7', '#E2564F'];
const COLOR_SCALE = chroma.scale(COLORS);

const defaultOpacity = 1;

const StackedAreaChart: React.FC<StackedAreaChartProps> = ({ indicator }) => {
  const [legendKey, setLegendKey] = useState<string | null>(null);

  const filters = useAppSelector(filtersForTabularAPI);
  const { currentScenario: scenarioId } = useAppSelector(scenarios);

  const params = {
    maxRankingEntities: 5,
    sort: 'DES',
    ...omit(filters, 'indicatorId'),
    indicatorIds: [indicator.id],
    scenarioId,
  };

  const enabled = !!filters.startYear && !!filters.endYear && filters.endYear !== filters.startYear;

  const { data, isFetched, isFetching } = useImpactRanking(
    {
      maxRankingEntities: 5,
      sort: 'DES',
      ...params,
    },
    {
      enabled,
    },
  );

  const chartData = useMemo(() => {
    const {
      indicatorShortName,
      rows,
      yearSum,
      metadata,
      others = {},
    } = data?.impactTable?.[0] || {};

    const { numberOfAggregatedEntities, aggregatedValues } = others;
    const result = [];
    const keys = [];
    let opacities = {};

    yearSum?.forEach(({ year }) => {
      const items = {};

      rows?.forEach((row) => {
        const yearValues = row?.values.find((rowValues) => rowValues?.year === year);
        items[row.name] = yearValues?.value;
        opacities = { ...opacities, [row.name]: 0.9 };
        if (yearValues.isProjected) {
          items[`projected-${row.name}`] = yearValues?.value;
        }
      });

      if (numberOfAggregatedEntities && numberOfAggregatedEntities > 0) {
        items['Others'] = aggregatedValues?.find(
          (aggregatedValue) => aggregatedValue?.year === year,
        )?.value;
        opacities = { ...opacities, Others: 0.9 };
      }
      result.push({ date: year, ...items });
    });

    if (result.length > 0) {
      Object.keys(result[0]).forEach((key) => key !== 'date' && keys.push(key));
    }

    const colorScale =
      keys.filter((k) => k !== 'Others').length > COLORS.length
        ? COLOR_SCALE.colors(keys.length)
        : COLORS;

    return {
      values: result,
      keys: keys.filter((key) => key !== 'date' && !key.startsWith('projected-')),
      name: indicatorShortName,
      unit: metadata?.unit,
      colors: keys.reduce(
        (acc, k, i) => ({
          ...acc,
          [k]: k === 'Other' || k === 'Others' ? '#E4E4E4' : colorScale[i],
        }),
        {},
      ),
      opacities,
    };
  }, [data]);

  const subchartData = useMemo(() => {
    const {
      indicatorShortName,
      rows,
      yearSum,
      metadata,
      others = {},
    } = data?.impactTable?.[0] || {};

    const { numberOfAggregatedEntities, aggregatedValues } = others;
    const result = [];
    const keys = [];
    let opacities = {};

    const LEGEND_INDEX = rows?.findIndex((row) => row.name === legendKey);

    yearSum?.forEach(({ year }) => {
      const items = {};

      const LEGEND_ROW = rows?.find((row) => row.name === legendKey);

      if (legendKey === 'Others' && numberOfAggregatedEntities && numberOfAggregatedEntities > 0) {
        result.push({
          date: year,
          Others: aggregatedValues?.find((aggregatedValue) => aggregatedValue?.year === year)
            ?.value,
          ...(aggregatedValues?.isProjected && {
            [`projected-${LEGEND_ROW.name}`]: aggregatedValues?.value,
          }),
        });
      }
      if (!LEGEND_ROW) return [];

      if (!LEGEND_ROW?.children.length) {
        const yearValues = LEGEND_ROW?.values.find((rowValues) => rowValues?.year === year);

        result.push({
          date: year,
          [LEGEND_ROW.name]: yearValues?.value,
          ...(yearValues?.isProjected && {
            [`projected-${LEGEND_ROW.name}`]: yearValues?.value,
          }),
        });
      }

      if (LEGEND_ROW?.children.length) {
        LEGEND_ROW.children?.forEach((row) => {
          const yearValues = row?.values.find((rowValues) => rowValues?.year === year);
          items[row.name] = yearValues?.value;
          opacities = { ...opacities, [row.name]: 0.9 };
          if (yearValues.isProjected) {
            items[`projected-${row.name}`] = yearValues?.value;
          }
        });

        result.push({ date: year, ...items });
      }
    });

    if (result.length > 0) {
      Object.keys(result[0]).forEach((key) => key !== 'date' && keys.push(key));
    }

    const c = COLORS[LEGEND_INDEX] || '#1C44C3';
    const SUB_COLOR_SCALE = chroma.scale([c, chroma(c).brighten(3)]);

    const colorScale = SUB_COLOR_SCALE.colors(keys.length);

    return {
      values: result,
      keys: keys.filter((key) => key !== 'date' && !key.startsWith('projected-')),
      name: indicatorShortName,
      unit: metadata?.unit,
      colors: keys.reduce(
        (acc, k, i) => ({
          ...acc,
          [k]: k === 'Other' || k === 'Others' ? '#E4E4E4' : colorScale[i],
        }),
        {},
      ),
      opacities,
    };
  }, [data, legendKey]);

  const CHART_DATA = legendKey ? subchartData : chartData;

  /**
   * Toggle legend opacity
   */
  const handleLegendClick = useCallback(
    (obj) => {
      const { id } = obj;

      if (legendKey === id) {
        setLegendKey(null);
        return;
      }

      setLegendKey(id);
    },
    [legendKey],
  );

  useEffect(() => {
    setLegendKey(null);
  }, [filters]);

  const renderLegend = useCallback(
    (props: ExtendedLegendProps) => <CustomLegend {...props} legendKey={legendKey} />,
    [legendKey],
  );

  const renderTooltip = useCallback((props) => {
    if (props && props.active && props.payload && props.payload.length) {
      return <CustomTooltip {...props} />;
    }
  }, []);

  return (
    <div className="rounded-md bg-white p-6 shadow-sm" data-testid="analysis-chart">
      {isFetching && <Loading className="m-auto h-5 w-5 text-navy-400" />}
      {!data && !isFetching && (
        <div className="flex h-[370px] flex-col items-center justify-center">No data</div>
      )}
      {!isFetching && isFetched && data && (
        <div>
          <h2 className="flex-shrink-0 text-base">
            {chartData.name} ({chartData.unit})
          </h2>
          <div className="relative mt-3 flex-grow">
            <div className="h-[370px] text-xs">
              <ResponsiveContainer width="100%" height="100%">
                <AreaChart
                  key={legendKey}
                  data={CHART_DATA.values}
                  margin={{
                    top: 0,
                    right: 20,
                    left: 0,
                    bottom: 0,
                  }}
                >
                  <defs>
                    <pattern
                      id="patternStripe"
                      width="4"
                      height="4"
                      patternUnits="userSpaceOnUse"
                      patternTransform="rotate(45)"
                    >
                      <rect
                        width="1"
                        height="4"
                        transform="translate(0,0)"
                        fill="#15181F"
                        fillOpacity={0.5}
                      ></rect>
                    </pattern>
                  </defs>

                  <Legend
                    verticalAlign="top"
                    content={renderLegend}
                    height={90}
                    payload={chartData.keys.map((key) => ({
                      id: key,
                      value: key,
                      color: chartData.colors[key],
                      fillOpacity: chartData.opacities[key],
                    }))}
                    onClick={handleLegendClick}
                  />

                  <CartesianGrid
                    vertical={false}
                    stroke="#15181F"
                    strokeWidth={1}
                    strokeOpacity={0.15}
                  />
                  <XAxis
                    dataKey="date"
                    axisLine={false}
                    tick={{ fill: '#15181F', fontWeight: 300 }}
                    tickLine={false}
                    tickMargin={8}
                  />
                  <YAxis
                    axisLine={false}
                    label={{ value: chartData.unit, angle: -90, position: 'insideLeft' }}
                    tick={{ fill: '#15181F', fontWeight: 300 }}
                    tickLine={false}
                    tickFormatter={formatNumber}
                  />

                  {CHART_DATA.keys.map((key) => (
                    <Area
                      key={key}
                      type="monotone"
                      dataKey={key}
                      stackId="all"
                      stroke={CHART_DATA.colors[key]}
                      strokeWidth={0}
                      fill={CHART_DATA.colors[key]}
                      fillOpacity={defaultOpacity}
                      animationEasing="ease"
                      animationDuration={500}
                      opacity={defaultOpacity}
                    />
                  ))}
                  {CHART_DATA.keys.map((key) => (
                    <Area
                      key={`projected-${key}`}
                      type="monotone"
                      dataKey={`projected-${key}`}
                      stackId="projected"
                      stroke={CHART_DATA.colors[key]}
                      strokeWidth={0}
                      fill="url(#patternStripe)"
                      fillOpacity={defaultOpacity}
                      animationEasing="ease"
                      animationDuration={500}
                      tooltipType="none"
                      legendType="none"
                    />
                  ))}
                  <Tooltip<number, string>
                    animationDuration={500}
                    contentStyle={{ borderRadius: '8px', borderColor: '#D1D5DB' }}
                    wrapperStyle={{ zIndex: 1000 }}
                    content={renderTooltip}
                  />
                </AreaChart>
              </ResponsiveContainer>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default StackedAreaChart;