add average rates chart to stats
This commit is contained in:
parent
adc8b02fda
commit
6cb89c79ea
|
@ -0,0 +1,68 @@
|
|||
import { DownTriangle, UpTriangle } from './DirectionTriangles'
|
||||
import FormatNumericValue from './FormatNumericValue'
|
||||
|
||||
const Change = ({
|
||||
change,
|
||||
decimals,
|
||||
prefix,
|
||||
size,
|
||||
suffix,
|
||||
}: {
|
||||
change: number | typeof NaN
|
||||
decimals?: number
|
||||
prefix?: string
|
||||
size?: 'small' | 'large'
|
||||
suffix?: string
|
||||
}) => {
|
||||
return !isNaN(change) ? (
|
||||
<div className="flex items-center space-x-1.5">
|
||||
{change > 0 ? (
|
||||
<div className="mt-[1px]">
|
||||
<UpTriangle size={size} />
|
||||
</div>
|
||||
) : change < 0 ? (
|
||||
<div className="mt-[1px]">
|
||||
<DownTriangle size={size} />
|
||||
</div>
|
||||
) : null}
|
||||
<p
|
||||
className={`font-mono font-normal ${
|
||||
size === 'small'
|
||||
? 'text-xs'
|
||||
: size === 'large'
|
||||
? 'text-base'
|
||||
: 'text-sm'
|
||||
} ${
|
||||
change > 0
|
||||
? 'text-th-up'
|
||||
: change < 0
|
||||
? 'text-th-down'
|
||||
: 'text-th-fgd-2'
|
||||
}`}
|
||||
>
|
||||
{prefix ? prefix : ''}
|
||||
<FormatNumericValue
|
||||
value={isNaN(change) ? '0.00' : Math.abs(change)}
|
||||
decimals={decimals ? decimals : 2}
|
||||
/>
|
||||
{suffix ? suffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className={`font-mono font-normal ${
|
||||
size === 'small'
|
||||
? 'text-xs'
|
||||
: size === 'large'
|
||||
? 'text-base'
|
||||
: 'text-sm'
|
||||
}`}
|
||||
>
|
||||
{prefix ? prefix : ''}
|
||||
<FormatNumericValue value="0.00" decimals={decimals ? decimals : 2} />
|
||||
{suffix ? suffix : ''}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default Change
|
|
@ -0,0 +1,53 @@
|
|||
import { FunctionComponent } from 'react'
|
||||
|
||||
interface ChartRangeButtonsProps {
|
||||
activeValue: string
|
||||
className?: string
|
||||
onChange: (x: string) => void
|
||||
values: Array<string>
|
||||
names?: Array<string>
|
||||
}
|
||||
|
||||
const ChartRangeButtons: FunctionComponent<ChartRangeButtonsProps> = ({
|
||||
activeValue,
|
||||
className,
|
||||
values,
|
||||
onChange,
|
||||
names,
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative flex">
|
||||
{activeValue && values.includes(activeValue) ? (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full rounded-md bg-th-bkg-2"
|
||||
style={{
|
||||
transform: `translateX(${
|
||||
values.findIndex((v) => v === activeValue) * 100
|
||||
}%)`,
|
||||
width: `${100 / values.length}%`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{values.map((v, i) => (
|
||||
<button
|
||||
className={`${className} relative h-6 cursor-pointer rounded-md px-3 text-center text-xs focus-visible:bg-th-bkg-2 focus-visible:text-th-fgd-1
|
||||
${
|
||||
v === activeValue
|
||||
? `text-th-fgd-1`
|
||||
: `text-th-fgd-4 md:hover:text-th-active`
|
||||
}
|
||||
`}
|
||||
key={`${v}${i}`}
|
||||
onClick={() => onChange(v)}
|
||||
style={{
|
||||
width: `${100 / values.length}%`,
|
||||
}}
|
||||
>
|
||||
{names ? names[i] : v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChartRangeButtons
|
|
@ -0,0 +1,27 @@
|
|||
type ContentBoxProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
showBackground?: boolean
|
||||
hideBorder?: boolean
|
||||
hidePadding?: boolean
|
||||
}
|
||||
|
||||
const ContentBox = ({
|
||||
children,
|
||||
className = '',
|
||||
showBackground = false,
|
||||
hideBorder = false,
|
||||
hidePadding = false,
|
||||
}: ContentBoxProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`${hideBorder ? '' : 'border border-th-bkg-3'} ${
|
||||
showBackground ? 'bg-th-bkg-2' : ''
|
||||
} ${hidePadding ? '' : 'p-6'} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentBox
|
|
@ -0,0 +1,610 @@
|
|||
/* 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 ContentBox from './ContentBox'
|
||||
import SheenLoader from './SheenLoader'
|
||||
import { ArrowsRightLeftIcon, NoSymbolIcon } from '@heroicons/react/20/solid'
|
||||
import { FadeInFadeOut } from './Transitions'
|
||||
import ChartRangeButtons from './ChartRangeButtons'
|
||||
import Change from './Change'
|
||||
import { DAILY_MILLISECONDS } from 'utils/constants'
|
||||
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'
|
||||
|
||||
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
|
||||
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,
|
||||
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 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, yKey])
|
||||
|
||||
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, xKey])
|
||||
|
||||
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`}
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{mouseData[yKey] < 0 ? '-' : ''}
|
||||
{prefix}
|
||||
<FormatNumericValue
|
||||
value={Math.abs(mouseData[yKey])}
|
||||
decimals={yDecimals}
|
||||
/>
|
||||
{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 : '%'}
|
||||
/>
|
||||
{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`}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</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 : '%'}
|
||||
/>
|
||||
{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
|
||||
? 'var(--success)'
|
||||
: 'var(--error)'
|
||||
}
|
||||
stopOpacity={0.15}
|
||||
/>
|
||||
<stop
|
||||
offset="99%"
|
||||
stopColor={
|
||||
calculateChartChange() >= 0
|
||||
? 'var(--success)'
|
||||
: 'var(--error)'
|
||||
}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
isAnimationActive={false}
|
||||
type="monotone"
|
||||
dataKey={yKey}
|
||||
stroke={
|
||||
isNaN(calculateChartChange())
|
||||
? 'var(--fgd-4)'
|
||||
: calculateChartChange() >= 0
|
||||
? 'var(--success)'
|
||||
: 'var(--error)'
|
||||
}
|
||||
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="var(--success)"
|
||||
stopOpacity={1}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--success)"
|
||||
stopOpacity={0.7}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="redGradientBar"
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="0"
|
||||
y2="0"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--error)"
|
||||
stopOpacity={1}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--error)"
|
||||
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="var(--bkg-4)" />
|
||||
</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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
export const UpTriangle = ({ size }: { size?: 'small' | 'large' }) => (
|
||||
<div
|
||||
className={`h-0 w-0 ${
|
||||
size === 'small'
|
||||
? 'border-b-[6.92px] border-l-[4px] border-r-[4px]'
|
||||
: 'border-b-[8.65px] border-l-[5px] border-r-[5px]'
|
||||
} border-b-th-up border-l-transparent border-r-transparent`}
|
||||
/>
|
||||
)
|
||||
|
||||
export const DownTriangle = ({ size }: { size?: 'small' | 'large' }) => (
|
||||
<div
|
||||
className={`h-0 w-0 ${
|
||||
size === 'small'
|
||||
? 'border-l-[4px] border-r-[4px] border-t-[6.92px]'
|
||||
: 'border-l-[5px] border-r-[5px] border-t-[8.65px]'
|
||||
} border-l-transparent border-r-transparent border-t-th-down`}
|
||||
/>
|
||||
)
|
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import useMangoGroup from 'hooks/useMangoGroup'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { fetchTokenStatsData } from 'utils/stats'
|
||||
import TokenRatesChart from './TokenRatesChart'
|
||||
|
||||
const HistoricalStats = () => {
|
||||
const { group } = useMangoGroup()
|
||||
const [depositDaysToShow, setDepositDaysToShow] = useState('30')
|
||||
|
||||
const { data: historicalStats, isLoading: loadingHistoricalStats } = useQuery(
|
||||
['historical-stats'],
|
||||
() => fetchTokenStatsData(group),
|
||||
{
|
||||
cacheTime: 1000 * 60 * 10,
|
||||
staleTime: 1000 * 60,
|
||||
retry: 3,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!group,
|
||||
},
|
||||
)
|
||||
|
||||
const usdcStats = useMemo(() => {
|
||||
if (!historicalStats?.length) return []
|
||||
return historicalStats
|
||||
.filter((token) => token.symbol === 'USDC')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.date_hour).getTime() - new Date(b.date_hour).getTime(),
|
||||
)
|
||||
}, [historicalStats])
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!usdcStats?.length) return []
|
||||
const statsToFormat = [...usdcStats]
|
||||
for (const stat of statsToFormat) {
|
||||
const APY_Daily_Compound = Math.pow(1 + stat.deposit_rate / 365, 365) - 1
|
||||
stat.deposit_apy = APY_Daily_Compound
|
||||
}
|
||||
return statsToFormat
|
||||
}, [usdcStats])
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-8">
|
||||
<TokenRatesChart
|
||||
data={chartData}
|
||||
dataKey="deposit_apy"
|
||||
daysToShow={depositDaysToShow}
|
||||
loading={loadingHistoricalStats}
|
||||
setDaysToShow={setDepositDaysToShow}
|
||||
title={`USDC Deposit Rates (APR)`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoricalStats
|
|
@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { STAKEABLE_TOKENS } from 'utils/constants'
|
||||
import { formatCurrencyValue } from 'utils/numbers'
|
||||
import { formatTokenSymbol } from 'utils/tokens'
|
||||
import HistoricalStats from './HistoricalStats'
|
||||
|
||||
const StatsPage = () => {
|
||||
const { group } = useMangoGroup()
|
||||
|
@ -138,6 +139,7 @@ const StatsPage = () => {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
<HistoricalStats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
import DetailedAreaOrBarChart from '@components/shared/DetailedAreaOrBarChart'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TokenStatsItem } from 'types'
|
||||
|
||||
interface GroupedDataItem extends TokenStatsItem {
|
||||
intervalStartMillis: number
|
||||
}
|
||||
|
||||
const groupByHourlyInterval = (
|
||||
data: TokenStatsItem[],
|
||||
dataKey: 'borrow_apr' | 'deposit_apy',
|
||||
intervalDurationHours: number,
|
||||
) => {
|
||||
const intervalMillis = intervalDurationHours * 60 * 60 * 1000
|
||||
const groupedData = []
|
||||
let currentGroup: GroupedDataItem | null = null
|
||||
let itemsInCurrentGroup = 0
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const obj = data[i]
|
||||
const date = new Date(obj.date_hour)
|
||||
const intervalStartMillis =
|
||||
Math.floor(date.getTime() / intervalMillis) * intervalMillis
|
||||
|
||||
if (
|
||||
!currentGroup ||
|
||||
currentGroup.intervalStartMillis !== intervalStartMillis
|
||||
) {
|
||||
if (currentGroup) {
|
||||
// calculate the average for the previous group
|
||||
currentGroup[dataKey] /= itemsInCurrentGroup
|
||||
groupedData.push(currentGroup)
|
||||
}
|
||||
|
||||
currentGroup = {
|
||||
...obj,
|
||||
intervalStartMillis: intervalStartMillis,
|
||||
}
|
||||
|
||||
// initialize the sum for the new group
|
||||
currentGroup[dataKey] = obj[dataKey] * 100
|
||||
itemsInCurrentGroup = 1
|
||||
} else {
|
||||
// add the value to the sum for the current group
|
||||
currentGroup[dataKey] += obj[dataKey] * 100
|
||||
itemsInCurrentGroup++
|
||||
}
|
||||
}
|
||||
|
||||
// calculate the average for the last group (if it exists)
|
||||
if (currentGroup) {
|
||||
currentGroup[dataKey] /= itemsInCurrentGroup
|
||||
groupedData.push(currentGroup)
|
||||
}
|
||||
|
||||
return groupedData
|
||||
}
|
||||
|
||||
const TokenRatesChart = ({
|
||||
data,
|
||||
dataKey,
|
||||
daysToShow,
|
||||
loading,
|
||||
setDaysToShow,
|
||||
title,
|
||||
}: {
|
||||
data: TokenStatsItem[]
|
||||
dataKey: 'deposit_apy' | 'borrow_apr'
|
||||
daysToShow: string | undefined
|
||||
loading: boolean
|
||||
setDaysToShow: (x: string) => void
|
||||
title: string
|
||||
}) => {
|
||||
const { t } = useTranslation('stats')
|
||||
const [interval, intervalString] = useMemo(() => {
|
||||
if (daysToShow === '30') {
|
||||
return [24, 'Daily']
|
||||
} else if (daysToShow === '7') {
|
||||
return [6, 'Six-hourly']
|
||||
} else {
|
||||
return [1, 'Hourly']
|
||||
}
|
||||
}, [daysToShow])
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || !data.length) return []
|
||||
const groupedData = groupByHourlyInterval(data, dataKey, interval)
|
||||
return groupedData
|
||||
}, [data, dataKey, interval])
|
||||
|
||||
return (
|
||||
<DetailedAreaOrBarChart
|
||||
chartType="bar"
|
||||
data={chartData}
|
||||
daysToShow={daysToShow}
|
||||
setDaysToShow={setDaysToShow}
|
||||
heightClass="h-64"
|
||||
loaderHeightClass="h-[334px]"
|
||||
loading={loading}
|
||||
small
|
||||
hideChange
|
||||
suffix="%"
|
||||
tickFormat={(x) => `${x.toFixed(2)}%`}
|
||||
title={`${t(intervalString)} ${title}`}
|
||||
xKey="date_hour"
|
||||
yKey={dataKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TokenRatesChart
|
|
@ -364,6 +364,7 @@ export interface TokenStatsItem {
|
|||
collected_fees: number
|
||||
date_hour: string
|
||||
deposit_apr: number
|
||||
deposit_apy: number
|
||||
deposit_rate: number
|
||||
mango_group: string
|
||||
price: number
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import { Group } from '@blockworks-foundation/mango-v4'
|
||||
import { MangoTokenStatsItem, TokenStatsItem } from 'types'
|
||||
import { MANGO_DATA_API_URL } from 'utils/constants'
|
||||
|
||||
export const fetchTokenStatsData = async (group: Group | undefined) => {
|
||||
if (!group) return []
|
||||
const response = await fetch(
|
||||
`${MANGO_DATA_API_URL}/token-historical-stats?mango-group=${group?.publicKey.toString()}`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
const rawData = await response.json()
|
||||
const [data] = processTokenStatsData(rawData, group)
|
||||
return data
|
||||
}
|
||||
|
||||
export const processTokenStatsData = (
|
||||
data: TokenStatsItem[] | unknown,
|
||||
group: Group,
|
||||
) => {
|
||||
const mangoStatsMap: Record<string, MangoTokenStatsItem> = {}
|
||||
if (!Array.isArray(data)) return []
|
||||
data.forEach((c) => {
|
||||
const bank = group.banksMapByTokenIndex.get(c.token_index)?.[0]
|
||||
if (!bank) return
|
||||
|
||||
const date: string = c.date_hour
|
||||
const uiPrice = bank.uiPrice
|
||||
|
||||
if (!mangoStatsMap[date]) {
|
||||
mangoStatsMap[date] = {
|
||||
date,
|
||||
depositValue: 0,
|
||||
borrowValue: 0,
|
||||
feesCollected: 0,
|
||||
}
|
||||
}
|
||||
|
||||
mangoStatsMap[date].depositValue += Math.floor(c.total_deposits * c.price)
|
||||
mangoStatsMap[date].borrowValue += Math.floor(c.total_borrows * c.price)
|
||||
mangoStatsMap[date].feesCollected += c.collected_fees * uiPrice
|
||||
})
|
||||
|
||||
// add most recent value, using most recent datapoint to patch difficult to compute stats
|
||||
for (const banks of group.banksMapByTokenIndex.values()) {
|
||||
const bank = banks[0]
|
||||
const now = new Date().toISOString()
|
||||
const filtered = data.filter(
|
||||
(x: TokenStatsItem) => bank.tokenIndex === x?.token_index,
|
||||
)
|
||||
if (!filtered || filtered.length === 0) {
|
||||
continue
|
||||
}
|
||||
const previous = filtered.reduce((max, cur) =>
|
||||
max.date_hour > cur.date_hour ? max : cur,
|
||||
)
|
||||
const APY_Daily_Compound =
|
||||
Math.pow(1 + bank.getDepositRateUi() / 365, 365) - 1
|
||||
|
||||
const tokenStatsItem: TokenStatsItem = {
|
||||
borrow_apr: previous.borrow_apr,
|
||||
borrow_rate: bank.getBorrowRateUi() / 100,
|
||||
collected_fees: previous.collected_fees,
|
||||
date_hour: now,
|
||||
deposit_apr: previous.deposit_apr,
|
||||
deposit_apy: APY_Daily_Compound,
|
||||
deposit_rate: bank.getDepositRateUi() / 100,
|
||||
mango_group: bank.group.toBase58(),
|
||||
price: bank.uiPrice,
|
||||
symbol: bank.name,
|
||||
token_index: bank.tokenIndex,
|
||||
total_borrows: bank.uiBorrows(),
|
||||
total_deposits: bank.uiDeposits(),
|
||||
}
|
||||
data.push(tokenStatsItem)
|
||||
}
|
||||
|
||||
const mangoStats: MangoTokenStatsItem[] = Object.values(mangoStatsMap)
|
||||
mangoStats.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
)
|
||||
|
||||
return [data, mangoStats]
|
||||
}
|
Loading…
Reference in New Issue