mango-v4-ui/components/shared/DetailedAreaOrBarChart.tsx

678 lines
26 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { FunctionComponent, useMemo, useState } from 'react'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
ResponsiveContainer,
ReferenceLine,
BarChart,
Bar,
Cell,
Label,
} from 'recharts'
import FlipNumbers from 'react-flip-numbers'
import ContentBox from './ContentBox'
import SheenLoader from './SheenLoader'
import { COLORS } from '../../styles/colors'
import { ArrowsRightLeftIcon, NoSymbolIcon } from '@heroicons/react/20/solid'
import { FadeInFadeOut } from './Transitions'
import ChartRangeButtons from './ChartRangeButtons'
import Change from './Change'
import useLocalStorageState from 'hooks/useLocalStorageState'
import {
ANIMATION_SETTINGS_KEY,
DAILY_MILLISECONDS,
PRIVACY_MODE,
PRIVATE_MODE_STRING,
} from 'utils/constants'
import { formatNumericValue } from 'utils/numbers'
import { INITIAL_ANIMATION_SETTINGS } from '@components/settings/AnimationSettings'
import { AxisDomain } from 'recharts/types/util/types'
import { useTranslation } from 'next-i18next'
import FormatNumericValue from './FormatNumericValue'
import { ContentType } from 'recharts/types/component/Tooltip'
import Tooltip from './Tooltip'
import useThemeWrapper from 'hooks/useThemeWrapper'
dayjs.extend(relativeTime)
const titleClasses = 'mb-0.5 text-base'
interface DetailedAreaOrBarChartProps {
changeAsPercent?: boolean
chartType?: 'area' | 'bar'
customTooltip?: ContentType<number, string>
data: any[] | undefined
daysToShow?: string
domain?: AxisDomain
heightClass?: string
hideChange?: boolean
hideAxis?: boolean
isPrivate?: boolean
loaderHeightClass?: string
loading?: boolean
prefix?: string
setDaysToShow?: (x: string) => void
small?: boolean
suffix?: string
tickFormat?: (x: number) => string
title?: string
tooltipContent?: string
xKey: string
formatXKeyHeading?: (k: string | number) => string
xAxisLabel?: string
xAxisType?: 'number' | 'category' | undefined
yDecimals?: number
yKey: string
showZeroLine?: boolean
tooltipDateFormat?: string
}
export const formatDateAxis = (date: string, days: number) => {
if (days === 1) {
return dayjs(date).format('h:mma')
} else {
return dayjs(date).format('D MMM')
}
}
const DetailedAreaOrBarChart: FunctionComponent<
DetailedAreaOrBarChartProps
> = ({
changeAsPercent,
chartType = 'area',
customTooltip,
data,
daysToShow = '1',
domain,
heightClass,
hideChange,
hideAxis,
isPrivate,
loaderHeightClass,
loading,
prefix = '',
setDaysToShow,
showZeroLine,
small,
suffix = '',
tickFormat,
title,
tooltipContent,
tooltipDateFormat,
xKey,
formatXKeyHeading,
xAxisLabel,
xAxisType,
yDecimals,
yKey,
}) => {
const { t } = useTranslation('common')
const [mouseData, setMouseData] = useState<any>(null)
const [showChangePercentage, setShowChangePercentage] = useState(
changeAsPercent || false,
)
const { theme } = useThemeWrapper()
const [animationSettings] = useLocalStorageState(
ANIMATION_SETTINGS_KEY,
INITIAL_ANIMATION_SETTINGS,
)
const [privacyMode] = useLocalStorageState(PRIVACY_MODE)
const handleMouseMove = (coords: any) => {
if (coords.activePayload) {
setMouseData(coords.activePayload[0].payload)
}
}
const handleMouseLeave = () => {
setMouseData(null)
}
const flipGradientCoords = useMemo(() => {
if (!data || !data.length) return
return data[0][yKey] <= 0 && data[data.length - 1][yKey] < 0
}, [data])
const filteredData = useMemo(() => {
if (!data || !data.length) return []
if (xAxisType === 'number') return data
const start = Number(daysToShow) * DAILY_MILLISECONDS
const filtered = data.filter((d: any) => {
const dataTime = new Date(d[xKey]).getTime()
const now = new Date().getTime()
const limit = now - start
return dataTime >= limit
})
return filtered
}, [data, daysToShow, xAxisType])
const calculateChartChange = () => {
if (filteredData.length) {
let firstValue = filteredData[0][yKey]
if (xAxisType === 'number') {
const minValue = filteredData.reduce(
(min, current) => (current[xKey] < min[xKey] ? current : min),
filteredData[0],
)
firstValue = minValue[yKey]
}
if (mouseData) {
const index = filteredData.findIndex(
(d: any) => d[xKey] === mouseData[xKey],
)
const currentValue = filteredData[index]?.[yKey]
const change =
index >= 0
? showChangePercentage
? ((currentValue - firstValue) / Math.abs(firstValue)) * 100
: currentValue - firstValue
: 0
return isNaN(change) ? 0 : change
} else {
const currentValue = filteredData[filteredData.length - 1][yKey]
return showChangePercentage
? ((currentValue - firstValue) / Math.abs(firstValue)) * 100
: currentValue - firstValue
}
}
return 0
}
return (
<FadeInFadeOut show={true}>
<ContentBox hideBorder hidePadding>
{loading ? (
<SheenLoader className="flex flex-1">
<div
className={`${
loaderHeightClass ? loaderHeightClass : 'h-96'
} w-full rounded-lg bg-th-bkg-2`}
/>
</SheenLoader>
) : (
<div className="relative">
{setDaysToShow ? (
<div className="mb-4 sm:absolute sm:-top-1 sm:right-0 sm:mb-0 sm:flex sm:justify-end">
<ChartRangeButtons
activeValue={daysToShow}
names={['24H', '7D', '30D']}
values={['1', '7', '30']}
onChange={(v) => setDaysToShow(v)}
/>
</div>
) : null}
{title ? (
tooltipContent ? (
<Tooltip content={tooltipContent}>
<p
className={`${titleClasses}
tooltip-underline`}
>
{title}
</p>
</Tooltip>
) : (
<p className={titleClasses}>{title}</p>
)
) : null}
{filteredData.length ? (
<>
<div className="flex items-start justify-between">
<div className="flex flex-col md:flex-row md:items-start md:space-x-6">
<div>
{mouseData ? (
<div>
<div
className={`flex items-end ${
small ? 'h-8 text-2xl' : 'mb-1 text-4xl'
} font-display text-th-fgd-1`}
>
{animationSettings['number-scroll'] ? (
isPrivate && privacyMode ? (
<span>{PRIVATE_MODE_STRING}</span>
) : (
<FlipNumbers
height={small ? 24 : 40}
width={small ? 17 : 30}
play
numbers={`${
mouseData[yKey] < 0 ? '-' : ''
}${prefix}${formatNumericValue(
Math.abs(mouseData[yKey]),
yDecimals,
)}${suffix}`}
/>
)
) : (
<span className="tabular-nums">
{mouseData[yKey] < 0 ? '-' : ''}
{prefix}
<FormatNumericValue
value={Math.abs(mouseData[yKey])}
decimals={yDecimals}
isPrivate={isPrivate}
/>
{suffix}
</span>
)}
{!hideChange ? (
<div
className={`ml-3 flex items-center ${
small ? 'mb-[3px]' : 'mb-0.5'
}`}
>
<Change
change={calculateChartChange()}
decimals={
!showChangePercentage ? yDecimals : 2
}
prefix={!showChangePercentage ? prefix : ''}
suffix={!showChangePercentage ? suffix : '%'}
isPrivate={isPrivate}
/>
{changeAsPercent ? (
<ToggleChangeTypeButton
changeType={showChangePercentage}
setChangeType={setShowChangePercentage}
/>
) : null}
</div>
) : null}
</div>
<p
className={`${
small ? 'text-xs' : 'text-sm'
} text-th-fgd-4`}
>
{formatXKeyHeading
? formatXKeyHeading(mouseData[xKey])
: dayjs(mouseData[xKey]).format(
tooltipDateFormat
? tooltipDateFormat
: 'DD MMM YY, h:mma',
)}
</p>
</div>
) : (
<div>
<div
className={`flex items-end ${
small ? 'h-8 text-2xl' : 'mb-1 text-4xl'
} font-display text-th-fgd-1`}
>
{animationSettings['number-scroll'] ? (
isPrivate && privacyMode ? (
<span>{PRIVATE_MODE_STRING}</span>
) : (
<FlipNumbers
height={small ? 24 : 40}
width={small ? 17 : 30}
play
numbers={`${
filteredData[filteredData.length - 1][
yKey
] < 0
? '-'
: ''
}${prefix}${formatNumericValue(
Math.abs(
filteredData[filteredData.length - 1][
yKey
],
),
yDecimals,
)}${suffix}`}
/>
)
) : (
<span>
{filteredData[filteredData.length - 1][yKey] < 0
? '-'
: ''}
{prefix}
<span className="tabular-nums">
<FormatNumericValue
value={
data
? Math.abs(data[data.length - 1][yKey])
: 0
}
decimals={yDecimals}
isPrivate={isPrivate}
/>
</span>
{suffix}
</span>
)}
{!hideChange ? (
<div
className={`ml-3 flex items-center ${
small ? 'mb-[3px]' : 'mb-0.5'
}`}
>
<Change
change={calculateChartChange()}
decimals={
!showChangePercentage ? yDecimals : 2
}
prefix={!showChangePercentage ? prefix : ''}
suffix={!showChangePercentage ? suffix : '%'}
isPrivate={isPrivate}
/>
{changeAsPercent ? (
<ToggleChangeTypeButton
changeType={showChangePercentage}
setChangeType={setShowChangePercentage}
/>
) : null}
</div>
) : null}
</div>
<p
className={`${
small ? 'text-xs' : 'text-sm'
} text-th-fgd-4`}
>
{formatXKeyHeading
? formatXKeyHeading(
filteredData[filteredData.length - 1][xKey],
)
: dayjs(
filteredData[filteredData.length - 1][xKey],
).format(
tooltipDateFormat
? tooltipDateFormat
: 'DD MMM YY, h:mma',
)}
</p>
</div>
)}
</div>
</div>
</div>
<div
className={`-mt-1 ${
heightClass ? heightClass : 'h-96'
} w-auto`}
>
<div className="-mx-6 mt-6 h-full">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'area' ? (
<AreaChart
data={filteredData}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<RechartsTooltip
cursor={{
strokeOpacity: 0.09,
}}
content={customTooltip ? customTooltip : <></>}
/>
<defs>
<linearGradient
id={`gradientArea-${title?.replace(
/[^a-zA-Z]/g,
'',
)}`}
x1="0"
y1={flipGradientCoords ? '1' : '0'}
x2="0"
y2={flipGradientCoords ? '0' : '1'}
>
<stop
offset="0%"
stopColor={
calculateChartChange() >= 0
? COLORS.UP[theme]
: COLORS.DOWN[theme]
}
stopOpacity={0.15}
/>
<stop
offset="99%"
stopColor={
calculateChartChange() >= 0
? COLORS.UP[theme]
: COLORS.DOWN[theme]
}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
isAnimationActive={false}
type="monotone"
dataKey={yKey}
stroke={
isNaN(calculateChartChange())
? COLORS.FGD4[theme]
: calculateChartChange() >= 0
? COLORS.UP[theme]
: COLORS.DOWN[theme]
}
strokeWidth={1.5}
fill={`url(#gradientArea-${title?.replace(
/[^a-zA-Z]/g,
'',
)})`}
/>
<XAxis
axisLine={false}
dataKey={xKey}
hide={hideAxis}
minTickGap={20}
padding={{ left: 20, right: 20 }}
tick={{
fill: 'var(--fgd-4)',
fontSize: 10,
}}
tickLine={false}
tickFormatter={
xAxisType !== 'number'
? (d) => formatDateAxis(d, parseInt(daysToShow))
: undefined
}
type={xAxisType}
>
{xAxisLabel ? (
<Label
value={xAxisLabel}
offset={-2}
position="insideBottom"
fontSize={10}
fill="var(--fgd-3)"
/>
) : null}
</XAxis>
<YAxis
axisLine={false}
dataKey={yKey}
hide={hideAxis}
minTickGap={20}
type="number"
domain={
domain
? domain
: ([dataMin, dataMax]) => {
const difference = dataMax - dataMin
if (difference < 0.01) {
return [dataMin - 0.001, dataMax + 0.001]
} else if (difference < 0.1) {
return [dataMin - 0.01, dataMax + 0.01]
} else if (difference < 1) {
return [dataMin - 0.1, dataMax + 0.11]
} else if (difference < 10) {
return [dataMin - 1, dataMax + 1]
} else {
return [dataMin, dataMax]
}
}
}
padding={{ top: 20, bottom: 20 }}
tick={{
fill: 'var(--fgd-4)',
fontSize: 10,
}}
tickFormatter={
tickFormat ? (v) => tickFormat(v) : undefined
}
tickLine={false}
/>
{showZeroLine ? (
<ReferenceLine
y={0}
stroke="var(--fgd-4)"
strokeDasharray="2 2"
/>
) : null}
</AreaChart>
) : (
<BarChart
data={filteredData}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<RechartsTooltip
cursor={{
fill: 'var(--bkg-2)',
opacity: 0.5,
}}
content={customTooltip ? customTooltip : <></>}
/>
<defs>
<linearGradient
id="greenGradientBar"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={COLORS.UP[theme]}
stopOpacity={1}
/>
<stop
offset="100%"
stopColor={COLORS.UP[theme]}
stopOpacity={0.7}
/>
</linearGradient>
<linearGradient
id="redGradientBar"
x1="0"
y1="1"
x2="0"
y2="0"
>
<stop
offset="0%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={1}
/>
<stop
offset="100%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={0.7}
/>
</linearGradient>
</defs>
<Bar dataKey={yKey}>
{filteredData.map((entry, index) => {
return (
<Cell
key={`cell-${index}`}
fill={
entry[yKey] > 0
? 'url(#greenGradientBar)'
: 'url(#redGradientBar)'
}
/>
)
})}
</Bar>
<XAxis
dataKey={xKey}
axisLine={false}
dy={10}
minTickGap={20}
padding={{ left: 20, right: 20 }}
tick={{
fill: 'var(--fgd-4)',
fontSize: 10,
}}
tickLine={false}
tickFormatter={(v) =>
formatDateAxis(v, parseInt(daysToShow))
}
/>
<YAxis
dataKey={yKey}
interval="preserveStartEnd"
axisLine={false}
dx={-10}
padding={{ top: 20, bottom: 20 }}
tick={{
fill: 'var(--fgd-4)',
fontSize: 10,
}}
tickLine={false}
tickFormatter={
tickFormat ? (v) => tickFormat(v) : undefined
}
type="number"
/>
<ReferenceLine y={0} stroke={COLORS.BKG4[theme]} />
</BarChart>
)}
</ResponsiveContainer>
</div>
</div>
</>
) : (
<div
className={`flex ${
heightClass ? heightClass : 'h-96'
} mt-4 items-center justify-center rounded-lg border border-th-bkg-3 p-8 text-th-fgd-3`}
>
<div>
<NoSymbolIcon className="mx-auto mb-1 h-6 w-6 text-th-fgd-4" />
<p className="text-th-fgd-3">{t('no-data')}</p>
</div>
</div>
)}
</div>
)}
</ContentBox>
</FadeInFadeOut>
)
}
export default DetailedAreaOrBarChart
const ToggleChangeTypeButton = ({
changeType,
setChangeType,
}: {
changeType: boolean
setChangeType: (isPercent: boolean) => void
}) => {
return (
<button
className="ml-2 flex h-4 w-4 items-center justify-center text-th-fgd-3 focus:outline-none md:hover:text-th-active"
onClick={() => setChangeType(!changeType)}
>
<ArrowsRightLeftIcon className="h-3.5 w-3.5" />
</button>
)
}