tremorlabs / tremor

React components to build charts and dashboards
https://tremor.so
Apache License 2.0
15.39k stars 446 forks source link

[Feature]: Reversed Y-Axis #1022

Open Dib5pm opened 3 weeks ago

Dib5pm commented 3 weeks ago

What problem does this feature solve?

I want to reverse the y-axis of an area chart so that it goes from low to high instead of high to low. I know this can be done with Recharts, but I'm not sure how to achieve it with Tremor. Does anyone know any solutions for this?

What does the proposed API look like?

No response

Dib5pm commented 2 weeks ago

Solution Reversed Y-Axis

I managed to solve this issue by using the reversed prop on the YAxis component within Recharts to invert the axis. Additionally, I configured the Area component's baseValue to 'dataMax', ensuring that the area fills downward from the maximum data point on the Y-axis. I've already added the code below so you can implement it yourself. I'll make a PR when I can find the time.

Issue

There is a known bug in the Recharts library that affects the visibility of the full Y and X axes. This issue can result in the axes not being fully displayed, which impacts the readability and presentation of the chart data. More details about this issue can be found in the Recharts GitHub repository under Issue #2175.

I managed to solve this by adding:

  margin={{
            top: 10, right: 30, left: 0, bottom: 0,
          }}

Example Implementation

Below is a simple example illustrating how these properties can be integrated in Recharts:

import React, { PureComponent } from 'react';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

const data = [
  { name: 'Page A', uv: 4000 },
  { name: 'Page B', uv: 3000 },
  { name: 'Page C', uv: 2000 },
  { name: 'Page D', uv: 2780 },
  { name: 'Page E', uv: 1890 },
  { name: 'Page F', uv: 2390 },
  { name: 'Page G', uv: 3490 },
];

export default class Example extends PureComponent {
  render() {
    return (
      <ResponsiveContainer width="100%" height="100%">
        <AreaChart
          data={data}
          margin={{
            top: 10, right: 30, left: 0, bottom: 0,
          }}
        >
          <XAxis dataKey="name" />
          <YAxis reversed domain={[Math.max(...data.map(item => item.uv)), Math.min(...data.map(item => item.uv))]} />
          <Tooltip />
          <Area type="monotone" dataKey="uv" baseValue='dataMax' stroke="#8884d8" fill="#8884d8" />
        </AreaChart>
      </ResponsiveContainer>
    );
  }
}

Full Implementation for the Tremor AreaChart Component

import React, { Fragment, useState } from 'react';
import {
  Area,
  AreaChart as ReChartsAreaChart,
  CartesianGrid,
  Dot,
  Legend,
  Line,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from 'recharts';

import BaseChartProps from './common/BaseChartProps';
import { CurveType } from './lib/inputTypes';
import { colorPalette, themeColorRange } from './lib/theme';
import {
  constructCategoryColors,
  defaultValueFormatter,
  getColorClassNames,
  getYAxisDomain,
  hasOnlyOneValueForThisKey,
} from './lib/utils';
import { tremorTwMerge } from './lib/tremorTwMerge';
import ChartTooltip from './common/ChartTooltip';
import { BaseColors } from './lib/constants';
import ChartLegend from './common/ChartLegend';
import NoData from './common/NoData';
import { AxisDomain } from 'recharts/types/util/types';

export interface AreaChartProps extends BaseChartProps {
  stack?: boolean;
  curveType?: CurveType;
  connectNulls?: boolean;
  yAxisReversed?: boolean;
  showGradient?: boolean;
}

interface ActiveDot {
  index?: number;
  dataKey?: string;
}

const AreaChart = React.forwardRef<HTMLDivElement, AreaChartProps>(
  (props, ref) => {
    const {
      data = [],
      categories = [],
      index,

      stack = false,
      colors = themeColorRange,
      valueFormatter = defaultValueFormatter,
      startEndOnly = false,
      showXAxis = true,
      showYAxis = true,
      yAxisWidth = 56,
      yAxisReversed = false,
      intervalType = 'equidistantPreserveStart',
      showAnimation = false,
      animationDuration = 900,
      showTooltip = true,
      showLegend = true,
      showGridLines = true,
      showGradient = true,
      autoMinValue = false,
      curveType = 'linear',
      minValue,
      maxValue,
      connectNulls = false,
      allowDecimals = true,
      noDataText,
      className,
      onValueChange,
      enableLegendSlider = false,
      customTooltip,
      rotateLabelX,
      tickGap = 5,
      ...other
    } = props;
    const CustomTooltip = customTooltip;
    const paddingValue =
      (!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20;
    const [legendHeight, setLegendHeight] = useState(60);
    const [activeDot, setActiveDot] = useState<ActiveDot | undefined>(
      undefined
    );
    const [activeLegend, setActiveLegend] = useState<string | undefined>(
      undefined
    );
    const categoryColors = constructCategoryColors(categories, colors);

    const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
    const yAxisProps = yAxisReversed && { reversed: true };
    const areaProps = yAxisReversed && { baseValue: 'dataMax' as const };
    const hasOnValueChange = !!onValueChange;

    function onDotClick(itemData: any, event: React.MouseEvent) {
      event.stopPropagation();

      if (!hasOnValueChange) return;
      if (
        (itemData.index === activeDot?.index &&
          itemData.dataKey === activeDot?.dataKey) ||
        (hasOnlyOneValueForThisKey(data, itemData.dataKey) &&
          activeLegend &&
          activeLegend === itemData.dataKey)
      ) {
        setActiveLegend(undefined);
        setActiveDot(undefined);
        onValueChange?.(null);
      } else {
        setActiveLegend(itemData.dataKey);
        setActiveDot({
          index: itemData.index,
          dataKey: itemData.dataKey,
        });
        onValueChange?.({
          eventType: 'dot',
          categoryClicked: itemData.dataKey,
          ...itemData.payload,
        });
      }
    }

    function onCategoryClick(dataKey: string) {
      if (!hasOnValueChange) return;
      if (
        (dataKey === activeLegend && !activeDot) ||
        (hasOnlyOneValueForThisKey(data, dataKey) &&
          activeDot &&
          activeDot.dataKey === dataKey)
      ) {
        setActiveLegend(undefined);
        onValueChange?.(null);
      } else {
        setActiveLegend(dataKey);
        onValueChange?.({
          eventType: 'category',
          categoryClicked: dataKey,
        });
      }
      setActiveDot(undefined);
    }
    return (
      <div
        ref={ref}
        className={tremorTwMerge('w-full h-80', className)}
        {...other}
      >
        <ResponsiveContainer className="h-full w-full">
          {data?.length ? (
            <ReChartsAreaChart
              {...(yAxisReversed && {
                margin: {
                  top: 10,
                  right: 30,
                  left: 0,
                  bottom: 0,
                },
              })}
              data={data}
              onClick={
                hasOnValueChange && (activeLegend || activeDot)
                  ? () => {
                      setActiveDot(undefined);
                      setActiveLegend(undefined);
                      onValueChange?.(null);
                    }
                  : undefined
              }
            >
              {showGridLines ? (
                <CartesianGrid
                  className={tremorTwMerge(
                    // common
                    'stroke-1',
                    // light
                    'stroke-tremor-border',
                    // dark
                    'dark:stroke-dark-tremor-border'
                  )}
                  horizontal={true}
                  vertical={false}
                />
              ) : null}
              <XAxis
                padding={{ left: paddingValue, right: paddingValue }}
                hide={!showXAxis}
                dataKey={index}
                tick={{ transform: 'translate(0, 6)' }}
                ticks={
                  startEndOnly
                    ? [data[0][index], data[data.length - 1][index]]
                    : undefined
                }
                fill=""
                stroke=""
                className={tremorTwMerge(
                  // common
                  'text-tremor-label',
                  // light
                  'fill-tremor-content',
                  // dark
                  'dark:fill-dark-tremor-content'
                )}
                interval={startEndOnly ? 'preserveStartEnd' : intervalType}
                tickLine={false}
                axisLine={false}
                minTickGap={tickGap}
                angle={rotateLabelX?.angle}
                dy={rotateLabelX?.verticalShift}
                height={rotateLabelX?.xAxisHeight}
              />
              <YAxis
                width={yAxisWidth}
                hide={!showYAxis}
                axisLine={false}
                tickLine={false}
                type="number"
                {...yAxisProps}
                domain={yAxisDomain as AxisDomain}
                tick={{ transform: 'translate(-3, 0)' }}
                fill=""
                stroke=""
                className={tremorTwMerge(
                  // common
                  'text-tremor-label',
                  // light
                  'fill-tremor-content',
                  // dark
                  'dark:fill-dark-tremor-content'
                )}
                tickFormatter={valueFormatter}
                allowDecimals={allowDecimals}
              />
              <Tooltip
                wrapperStyle={{ outline: 'none' }}
                isAnimationActive={false}
                cursor={{ stroke: '#d1d5db', strokeWidth: 1 }}
                content={
                  showTooltip ? (
                    ({ active, payload, label }) =>
                      CustomTooltip ? (
                        <CustomTooltip
                          payload={payload?.map((payloadItem: any) => ({
                            ...payloadItem,
                            color:
                              categoryColors.get(payloadItem.dataKey) ??
                              BaseColors.Gray,
                          }))}
                          active={active}
                          label={label}
                        />
                      ) : (
                        <ChartTooltip
                          active={active}
                          payload={payload}
                          label={label}
                          valueFormatter={valueFormatter}
                          categoryColors={categoryColors}
                        />
                      )
                  ) : (
                    <></>
                  )
                }
                position={{ y: 0 }}
              />
              {showLegend ? (
                <Legend
                  verticalAlign="top"
                  height={legendHeight}
                  content={({ payload }) =>
                    ChartLegend(
                      { payload },
                      categoryColors,
                      setLegendHeight,
                      activeLegend,
                      hasOnValueChange
                        ? (clickedLegendItem: string) =>
                            onCategoryClick(clickedLegendItem)
                        : undefined,
                      enableLegendSlider
                    )
                  }
                />
              ) : null}
              {categories.map((category) => {
                return (
                  <defs key={category}>
                    {showGradient ? (
                      <linearGradient
                        className={
                          getColorClassNames(
                            categoryColors.get(category) ?? BaseColors.Gray,
                            colorPalette.text
                          ).textColor
                        }
                        id={categoryColors.get(category)}
                        x1="0"
                        y1="0"
                        x2="0"
                        y2="1"
                      >
                        <stop
                          offset="5%"
                          stopColor="currentColor"
                          stopOpacity={
                            activeDot ||
                            (activeLegend && activeLegend !== category)
                              ? 0.15
                              : 0.4
                          }
                        />
                        <stop
                          offset="95%"
                          stopColor="currentColor"
                          stopOpacity={0}
                        />
                      </linearGradient>
                    ) : (
                      <linearGradient
                        className={
                          getColorClassNames(
                            categoryColors.get(category) ?? BaseColors.Gray,
                            colorPalette.text
                          ).textColor
                        }
                        id={categoryColors.get(category)}
                        x1="0"
                        y1="0"
                        x2="0"
                        y2="1"
                      >
                        <stop
                          stopColor="currentColor"
                          stopOpacity={
                            activeDot ||
                            (activeLegend && activeLegend !== category)
                              ? 0.1
                              : 0.3
                          }
                        />
                      </linearGradient>
                    )}
                  </defs>
                );
              })}
              {categories.map((category) => (
                <Area
                  {...areaProps}
                  className={
                    getColorClassNames(
                      categoryColors.get(category) ?? BaseColors.Gray,
                      colorPalette.text
                    ).strokeColor
                  }
                  strokeOpacity={
                    activeDot || (activeLegend && activeLegend !== category)
                      ? 0.3
                      : 1
                  }
                  // eslint-disable-next-line @typescript-eslint/no-shadow
                  activeDot={(props: any) => {
                    const {
                      cx,
                      cy,
                      stroke,
                      strokeLinecap,
                      strokeLinejoin,
                      strokeWidth,
                      dataKey,
                    } = props;
                    return (
                      <Dot
                        className={tremorTwMerge(
                          'stroke-tremor-background dark:stroke-dark-tremor-background',
                          onValueChange ? 'cursor-pointer' : '',
                          getColorClassNames(
                            categoryColors.get(dataKey) ?? BaseColors.Gray,
                            colorPalette.text
                          ).fillColor
                        )}
                        cx={cx}
                        cy={cy}
                        r={5}
                        fill=""
                        stroke={stroke}
                        strokeLinecap={strokeLinecap}
                        strokeLinejoin={strokeLinejoin}
                        strokeWidth={strokeWidth}
                        onClick={(dotProps: any, event) =>
                          onDotClick(props, event)
                        }
                      />
                    );
                  }}
                  // eslint-disable-next-line @typescript-eslint/no-shadow
                  dot={(props: any) => {
                    const {
                      stroke,
                      strokeLinecap,
                      strokeLinejoin,
                      strokeWidth,
                      cx,
                      cy,
                      dataKey,
                      // eslint-disable-next-line @typescript-eslint/no-shadow
                      index,
                    } = props;

                    if (
                      (hasOnlyOneValueForThisKey(data, category) &&
                        !(
                          activeDot ||
                          (activeLegend && activeLegend !== category)
                        )) ||
                      (activeDot?.index === index &&
                        activeDot?.dataKey === category)
                    ) {
                      return (
                        <Dot
                          key={index}
                          cx={cx}
                          cy={cy}
                          r={5}
                          stroke={stroke}
                          fill=""
                          strokeLinecap={strokeLinecap}
                          strokeLinejoin={strokeLinejoin}
                          strokeWidth={strokeWidth}
                          className={tremorTwMerge(
                            'stroke-tremor-background dark:stroke-dark-tremor-background',
                            onValueChange ? 'cursor-pointer' : '',
                            getColorClassNames(
                              categoryColors.get(dataKey) ?? BaseColors.Gray,
                              colorPalette.text
                            ).fillColor
                          )}
                        />
                      );
                    }
                    return <Fragment key={index}></Fragment>;
                  }}
                  key={category}
                  name={category}
                  type={curveType}
                  dataKey={category}
                  stroke=""
                  fill={`url(#${categoryColors.get(category)})`}
                  strokeWidth={2}
                  strokeLinejoin="round"
                  strokeLinecap="round"
                  isAnimationActive={showAnimation}
                  animationDuration={animationDuration}
                  stackId={stack ? 'a' : undefined}
                  connectNulls={connectNulls}
                />
              ))}
              {onValueChange
                ? categories.map((category) => (
                    <Line
                      className={tremorTwMerge('cursor-pointer')}
                      strokeOpacity={0}
                      key={category}
                      name={category}
                      type={curveType}
                      dataKey={category}
                      stroke="transparent"
                      fill="transparent"
                      legendType="none"
                      tooltipType="none"
                      strokeWidth={12}
                      connectNulls={connectNulls}
                      // eslint-disable-next-line @typescript-eslint/no-shadow
                      onClick={(props: any, event) => {
                        event.stopPropagation();
                        const { name } = props;
                        onCategoryClick(name);
                      }}
                    />
                  ))
                : null}
            </ReChartsAreaChart>
          ) : (
            <NoData noDataText={noDataText} />
          )}
        </ResponsiveContainer>
      </div>
    );
  }
);

AreaChart.displayName = 'AreaChart';

export default AreaChart;
severinlandolt commented 2 weeks ago

Thank you for reporting back!