import BigNumber from 'bignumber.js'
import { uniqBy } from 'lodash'
import React, { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
LayoutChangeEvent,
StyleSheet,
Text,
View,
ViewStyle,
} from 'react-native'
import { Circle, G, Line, Text as SvgText } from 'react-native-svg'
import i18n from 'src/i18n'
import { LocalCurrencyCode } from 'src/localCurrency/consts'
import { convertDollarsToLocalAmount } from 'src/localCurrency/convert'
import { getLocalCurrencyCode, usdToLocalCurrencyRateSelector } from 'src/localCurrency/selectors'
import { priceHistoryPricesSelector, priceHistoryStatusSelector } from 'src/priceHistory/selectors'
import { Price, fetchPriceHistoryStart } from 'src/priceHistory/slice'
import { useDispatch, useSelector } from 'src/redux/hooks'
import { RootState } from 'src/redux/reducers'
import colors, { ColorValue } from 'src/styles/colors'
import { Spacing } from 'src/styles/styles'
import variables from 'src/styles/variables'
import { getLocalCurrencyDisplayValue } from 'src/utils/formatting'
import { ONE_DAY_IN_MILLIS, ONE_HOUR_IN_MILLIS, formatFeedDate } from 'src/utils/time'
import { VictoryGroup, VictoryLine, VictoryScatter } from 'victory-native'
const CHART_WIDTH = variables.width
const CHART_HEIGHT = 180
const CHART_MIN_VERTICAL_RANGE = 0.01 // one cent
const CHART_DOMAIN_PADDING = { y: [30, 30] as [number, number], x: [5, 5] as [number, number] }
const CHART_STEP_IN_HOURS = 12
function Loader({
color = colors.loadingIndicator,
style,
}: {
color?: ColorValue
style?: ViewStyle
}) {
return (
)
}
function ChartAwareSvgText({
position,
x,
y,
value,
chartWidth,
}: {
position: 'top' | 'bottom'
x: number
y: number
value: string
chartWidth: number
}) {
if (position === 'top') {
y = y - 16
} else if (position === 'bottom') {
y = y + 25
}
const [adjustedX, setAdjustedX] = useState(x)
const horizontalOffset = variables.contentPadding
const onLayout = useCallback(
({
nativeEvent: {
layout: { width },
},
}: LayoutChangeEvent) => {
if (Math.abs(width - chartWidth) > 2) {
if (x - width / 2 - horizontalOffset < 0) {
setAdjustedX(width / 2 + horizontalOffset)
}
if (x + width / 2 + horizontalOffset > chartWidth) {
setAdjustedX(chartWidth - width / 2 - horizontalOffset)
}
}
},
[x]
)
return (
{value}
)
}
function renderPointOnChart(
chartData: Array<{ amount: number | BigNumber; displayValue: string }>,
chartWidth: number,
color: ColorValue
) {
let lowestRateIdx = 0,
highestRateIdx = 0
chartData.forEach((rate, idx) => {
if (rate.amount > chartData[highestRateIdx].amount) {
highestRateIdx = idx
}
if (rate.amount < chartData[lowestRateIdx].amount) {
lowestRateIdx = idx
}
})
return ({ datum, x, y }: { x: number; y: number; datum: { _x: number; _y: number } }) => {
const idx = datum._x
if (new Set([0, chartData.length - 1, highestRateIdx, lowestRateIdx]).has(idx)) {
return (
<>
{idx === 0 && (
<>
>
)}
{idx === chartData.length - 1 && }
{[highestRateIdx, lowestRateIdx].includes(idx) && (
)}
>
)
}
}
}
export function createChartData(
priceHistoryPrices: Price[],
chartStepInHours = 6,
dollarsToLocal: (amount: BigNumber.Value | null) => BigNumber | null,
displayLocalCurrency: (amount: BigNumber.Value) => string
) {
const chartData = []
let lastTimestampAdded, highPrice, lowPrice
for (let i = 0; i < priceHistoryPrices.length; i++) {
// Check if price is the highest or lowest price and if so store it
if (!highPrice || priceHistoryPrices[i].priceUsd > highPrice.priceUsd) {
highPrice = priceHistoryPrices[i]
}
if (!lowPrice || priceHistoryPrices[i].priceUsd < lowPrice.priceUsd) {
lowPrice = priceHistoryPrices[i]
}
// Only grab one price per chart step & the most recent price
if (
lastTimestampAdded &&
priceHistoryPrices[i].priceFetchedAt - lastTimestampAdded <
ONE_HOUR_IN_MILLIS * chartStepInHours &&
i !== priceHistoryPrices.length - 1
) {
continue
} else {
lastTimestampAdded = priceHistoryPrices[i].priceFetchedAt
const { priceUsd } = priceHistoryPrices[i]
const localAmount = dollarsToLocal(priceUsd)
chartData.push({
amount: localAmount ? localAmount.toNumber() : 0,
displayValue: localAmount ? displayLocalCurrency(localAmount) : '',
priceFetchedAt: priceHistoryPrices[i].priceFetchedAt,
})
}
}
// Make sure the highest and lowest prices are included in the chart
if (highPrice) {
chartData.push({
amount: dollarsToLocal(highPrice.priceUsd)?.toNumber() || 0,
displayValue: displayLocalCurrency(dollarsToLocal(highPrice.priceUsd) || 0),
priceFetchedAt: highPrice.priceFetchedAt,
})
}
if (lowPrice) {
chartData.push({
amount: dollarsToLocal(lowPrice.priceUsd)?.toNumber() || 0,
displayValue: displayLocalCurrency(dollarsToLocal(lowPrice.priceUsd) || 0),
priceFetchedAt: lowPrice.priceFetchedAt,
})
}
const sortedChartData = chartData.sort((a, b) => a.priceFetchedAt - b.priceFetchedAt)
const uniqueChartData = uniqBy(sortedChartData, 'priceFetchedAt')
return uniqueChartData
}
interface PriceHistoryChartProps {
tokenId: string
containerStyle?: ViewStyle
testID?: string
chartPadding?: number
color?: ColorValue
step?: number
}
export default function PriceHistoryChart({
tokenId,
containerStyle,
testID,
chartPadding,
color = colors.contentPrimary,
step = CHART_STEP_IN_HOURS,
}: PriceHistoryChartProps) {
const dispatch = useDispatch()
const localCurrencyCode = useSelector(getLocalCurrencyCode)
const localExchangeRate = useSelector(usdToLocalCurrencyRateSelector)
const prices = useSelector((state: RootState) => priceHistoryPricesSelector(state, tokenId))
const status = useSelector((state: RootState) => priceHistoryStatusSelector(state, tokenId))
const dollarsToLocal = useCallback(
(amount: BigNumber.Value | null) =>
convertDollarsToLocalAmount(amount, localCurrencyCode ? localExchangeRate : 1),
[localExchangeRate]
)
const displayLocalCurrency = useCallback(
(amount: BigNumber.Value) =>
getLocalCurrencyDisplayValue(amount, localCurrencyCode || LocalCurrencyCode.USD, true),
[localCurrencyCode]
)
useEffect(() => {
const latestTimestamp = prices.at(-1)?.priceFetchedAt ?? 0
if (prices.length > 0 && latestTimestamp > Date.now() - ONE_HOUR_IN_MILLIS) {
return
}
dispatch(
fetchPriceHistoryStart({
tokenId,
startTimestamp: Date.now() - ONE_DAY_IN_MILLIS * 30,
endTimestamp: Date.now(),
})
)
}, [tokenId])
if (status === 'loading' && prices.length === 0) {
return
} else if (prices.length === 0) {
return null
}
const chartData = createChartData(prices, step, dollarsToLocal, displayLocalCurrency)
const RenderPoint = renderPointOnChart(chartData, CHART_WIDTH, color)
const values = chartData.map((el) => el.amount)
const min = Math.min(...values)
const max = Math.max(...values)
let domain
if (max - min < CHART_MIN_VERTICAL_RANGE) {
// Use min of the CHART_MIN_VERTICAL_RANGE or 1% of the min value
// This works better for small values e.g. ImpactMarket
const offset = Math.min(CHART_MIN_VERTICAL_RANGE - (max - min) / 2, min / 100)
domain = {
y: [min - offset, max + offset] as [number, number],
x: [0, chartData.length - 1] as [number, number],
}
}
const latestTimestamp = chartData.at(-1)?.priceFetchedAt
const earliestTimestamp = chartData.at(0)?.priceFetchedAt
return (
el.amount)}
domain={domain}
>
{/* @ts-expect-error dynamically created the datum */}
} />
{!!earliestTimestamp && (
{formatFeedDate(earliestTimestamp, i18n)}
)}
{!!latestTimestamp && (
{formatFeedDate(latestTimestamp, i18n)}
)}
)
}
const styles = StyleSheet.create({
container: {
marginBottom: Spacing.Thick24,
},
loader: {
width: CHART_WIDTH,
height: CHART_HEIGHT + 35.5,
alignItems: 'center',
justifyContent: 'center',
},
timeframe: {
color: colors.contentSecondary,
fontSize: 16,
flexGrow: 1,
},
range: {
marginTop: variables.contentPadding,
justifyContent: 'space-between',
flexDirection: 'row',
},
})