From 461fcaf8aef820d11692117aa67b732ea6982e4b Mon Sep 17 00:00:00 2001 From: Matthias Kretschmann Date: Mon, 16 Nov 2020 15:10:33 +0100 Subject: [PATCH] Liquidity & price history graph (#248) * graph prototype * switch items * liquidity history graph prototype * more graph styling * epoch times conversion * get data in root component * redraw fix * more graph styling * loading fix * re-render fixes * re-render fixes * new Aquarius responses * price graph and switch buttons * spacing tweaks --- package-lock.json | 44 +++++ package.json | 3 + src/components/atoms/Price/PriceUnit.tsx | 18 +- .../AssetActions/Pool/Graph.module.css | 37 ++++ .../organisms/AssetActions/Pool/Graph.tsx | 184 ++++++++++++++++++ .../organisms/AssetActions/Pool/index.tsx | 35 +++- 6 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 src/components/organisms/AssetActions/Pool/Graph.module.css create mode 100644 src/components/organisms/AssetActions/Pool/Graph.tsx diff --git a/package-lock.json b/package-lock.json index 93d4d3029..42f512d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5333,6 +5333,15 @@ "@types/responselike": "*" } }, + "@types/chart.js": { + "version": "2.9.27", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.27.tgz", + "integrity": "sha512-b3ho2RpPLWzLzOXKkFwpvlRDEVWQrCknu2/p90mLY5v2DO8owk0OwWkv4MqAC91kJL52bQGXkVw/De+N/0/1+A==", + "dev": true, + "requires": { + "moment": "^2.10.2" + } + }, "@types/classnames": { "version": "2.2.11", "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz", @@ -10398,6 +10407,32 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" }, + "chart.js": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", + "integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", + "requires": { + "chartjs-color": "^2.1.0", + "moment": "^2.10.2" + } + }, + "chartjs-color": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz", + "integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==", + "requires": { + "chartjs-color-string": "^0.6.0", + "color-convert": "^1.9.3" + } + }, + "chartjs-color-string": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", + "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", + "requires": { + "color-name": "^1.0.0" + } + }, "checkpoint-store": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/checkpoint-store/-/checkpoint-store-1.1.0.tgz", @@ -27540,6 +27575,15 @@ "vanilla-swipe": "^2.2.0" } }, + "react-chartjs-2": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz", + "integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==", + "requires": { + "lodash": "^4.17.19", + "prop-types": "^15.7.2" + } + }, "react-color": { "version": "2.19.3", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", diff --git a/package.json b/package.json index 80a253bfe..7cec6146b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@vercel/node": "^1.8.4", "@walletconnect/web3-provider": "^1.3.1", "axios": "^0.21.0", + "chart.js": "^2.9.4", "classnames": "^2.2.6", "date-fns": "^2.16.1", "decimal.js": "^10.2.1", @@ -66,6 +67,7 @@ "query-string": "^6.13.7", "react": "^17.0.1", "react-alice-carousel": "^2.0.2", + "react-chartjs-2": "^2.11.1", "react-data-table-component": "^6.11.5", "react-datepicker": "^3.3.0", "react-dom": "^17.0.1", @@ -95,6 +97,7 @@ "@svgr/webpack": "^5.4.0", "@testing-library/jest-dom": "^5.11.5", "@testing-library/react": "^11.1.1", + "@types/chart.js": "^2.9.27", "@types/jest": "^26.0.15", "@types/loadable__component": "^5.13.1", "@types/lodash.debounce": "^4.0.3", diff --git a/src/components/atoms/Price/PriceUnit.tsx b/src/components/atoms/Price/PriceUnit.tsx index 21e5055ed..7d9e3bccd 100644 --- a/src/components/atoms/Price/PriceUnit.tsx +++ b/src/components/atoms/Price/PriceUnit.tsx @@ -8,6 +8,15 @@ import Badge from '../Badge' const cx = classNames.bind(styles) +export function formatPrice(price: string, locale: string): string { + return formatCurrency(Number(price), '', locale, false, { + // Not exactly clear what `significant figures` are for this library, + // but setting this seems to give us the formatting we want. + // See https://github.com/oceanprotocol/market/issues/70 + significantFigures: 4 + }) +} + export default function PriceUnit({ price, className, @@ -34,14 +43,7 @@ export default function PriceUnit({ return (
- {Number.isNaN(Number(price)) - ? '-' - : formatCurrency(Number(price), '', locale, false, { - // Not exactly clear what `significant figures` are for this library, - // but setting this seems to give us the formatting we want. - // See https://github.com/oceanprotocol/market/issues/70 - significantFigures: 4 - })}{' '} + {Number.isNaN(Number(price)) ? '-' : formatPrice(price, locale)}{' '} {symbol || 'OCEAN'} {type && type === 'pool' && ( diff --git a/src/components/organisms/AssetActions/Pool/Graph.module.css b/src/components/organisms/AssetActions/Pool/Graph.module.css new file mode 100644 index 000000000..3b200dbcc --- /dev/null +++ b/src/components/organisms/AssetActions/Pool/Graph.module.css @@ -0,0 +1,37 @@ +.graphWrap { + min-height: 97px; + display: flex; + align-items: center; + justify-content: center; + margin: calc(var(--spacer) / 6) -2rem calc(var(--spacer) / 1.5) -2rem; + position: relative; +} + +.graphWrap canvas { + position: relative; + z-index: 0; +} + +.type { + position: absolute; + bottom: -10px; + z-index: 1; + text-align: center; + padding: 5px var(--spacer); +} + +.button, +.button:hover { + display: inline-block; + color: var(--color-secondary); + font-size: var(--font-size-mini); + border: 1px solid transparent; + border-radius: var(--border-radius); + padding: calc(var(--spacer) / 16) calc(var(--spacer) / 4) !important; + transform: none; +} + +.button.active { + color: var(--font-color-text); + border-color: var(--border-color); +} diff --git a/src/components/organisms/AssetActions/Pool/Graph.tsx b/src/components/organisms/AssetActions/Pool/Graph.tsx new file mode 100644 index 000000000..ea24ce9db --- /dev/null +++ b/src/components/organisms/AssetActions/Pool/Graph.tsx @@ -0,0 +1,184 @@ +import React, { ChangeEvent, ReactElement, useEffect, useState } from 'react' +import { Line, defaults } from 'react-chartjs-2' +import { + ChartData, + ChartDataSets, + ChartOptions, + ChartTooltipItem, + ChartTooltipOptions +} from 'chart.js' +import styles from './Graph.module.css' +import Loader from '../../../atoms/Loader' +import { formatPrice } from '../../../atoms/Price/PriceUnit' +import { useUserPreferences } from '../../../../providers/UserPreferences' +import useDarkMode from 'use-dark-mode' +import { darkModeConfig } from '../../../../../app.config' +import Button from '../../../atoms/Button' +import { Logger } from '@oceanprotocol/lib' + +export interface ChartDataLiqudity { + oceanAddRemove: ChartData[] + datatokenAddRemove: ChartData[] + oceanReserveHistory: ChartData[] + datatokenReserveHistory: ChartData[] + datatokenPriceHistory: ChartData[] +} + +declare type GraphType = 'liquidity' | 'price' + +// Chart.js global defaults +defaults.global.defaultFontFamily = `'Sharp Sans', -apple-system, BlinkMacSystemFont, +'Segoe UI', Helvetica, Arial, sans-serif` +defaults.global.animation = { easing: 'easeInOutQuart', duration: 800 } + +const lineStyle: Partial = { + fill: false, + lineTension: 0.1, + borderWidth: 2, + pointBorderWidth: 0, + pointRadius: 0, + pointHoverRadius: 4, + pointHoverBorderWidth: 0, + pointHitRadius: 2, + pointHoverBackgroundColor: '#ff4092' +} + +const tooltipOptions: Partial = { + intersect: false, + titleFontStyle: 'normal', + titleFontSize: 10, + bodyFontSize: 12, + bodyFontStyle: 'bold', + displayColors: false, + xPadding: 10, + yPadding: 10, + cornerRadius: 3, + borderWidth: 1, + caretSize: 7 +} + +function constructGraphData(data: ChartData[]): ChartData { + const timestamps = data.map((item: any) => { + // convert timestamps from epoch to locale date & time string + const date = new Date(item[1] * 1000) + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` + }) + const values = data.map((item: any) => item[0]) + + return { + labels: timestamps, + datasets: [ + { + ...lineStyle, + label: 'Liquidity (OCEAN)', + data: values, + borderColor: `#8b98a9`, + pointBackgroundColor: `#8b98a9` + } + ] + } +} + +function getOptions(locale: string, isDarkMode: boolean): ChartOptions { + return { + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 10 + } + }, + tooltips: { + ...tooltipOptions, + backgroundColor: isDarkMode ? `#141414` : `#fff`, + titleFontColor: isDarkMode ? `#e2e2e2` : `#303030`, + bodyFontColor: isDarkMode ? `#fff` : `#141414`, + borderColor: isDarkMode ? `#41474e` : `#e2e2e2`, + callbacks: { + label: (tooltipItem: ChartTooltipItem) => + `${formatPrice(`${tooltipItem.yLabel}`, locale)} OCEAN` + } + }, + legend: { + display: false + }, + scales: { + yAxes: [ + { + display: false + // gridLines: { + // drawBorder: false, + // color: isDarkMode ? '#303030' : '#e2e2e2', + // zeroLineColor: isDarkMode ? '#303030' : '#e2e2e2' + // }, + // ticks: { display: false } + } + ], + xAxes: [{ display: false, gridLines: { display: true } }] + } + } +} + +const graphTypes = ['Liquidity', 'Price'] + +export default function Graph({ + data +}: { + data: ChartDataLiqudity +}): ReactElement { + const { locale } = useUserPreferences() + const darkMode = useDarkMode(false, darkModeConfig) + + const [graphData, setGraphData] = useState() + const [options, setOptions] = useState() + const [graphType, setGraphType] = useState('liquidity') + + useEffect(() => { + Logger.log('Fired GraphOptions!') + const options = getOptions(locale, darkMode.value) + setOptions(options) + }, [locale, darkMode.value]) + + useEffect(() => { + if (!data) return + Logger.log('Fired GraphData!') + const graphData = + graphType === 'liquidity' + ? constructGraphData(data.oceanReserveHistory) + : constructGraphData(data.datatokenPriceHistory) + setGraphData(graphData) + }, [data, graphType]) + + function handleGraphTypeSwitch(e: ChangeEvent) { + e.preventDefault() + setGraphType(e.currentTarget.textContent.toLowerCase() as GraphType) + } + + return ( +
+ {graphData ? ( + <> + + + + ) : ( + + )} +
+ ) +} diff --git a/src/components/organisms/AssetActions/Pool/index.tsx b/src/components/organisms/AssetActions/Pool/index.tsx index 9850949f4..d9bee002f 100644 --- a/src/components/organisms/AssetActions/Pool/index.tsx +++ b/src/components/organisms/AssetActions/Pool/index.tsx @@ -18,6 +18,8 @@ import Token from './Token' import TokenList from './TokenList' import { graphql, useStaticQuery } from 'gatsby' import Transactions from './Transactions' +import Graph, { ChartDataLiqudity } from './Graph' +import axios from 'axios' export interface Balance { ocean: number @@ -49,7 +51,7 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement { const data = useStaticQuery(contentQuery) const content = data.content.edges[0].node.childContentJson.pool - const { ocean, accountId, networkId } = useOcean() + const { ocean, accountId, networkId, config } = useOcean() const { price, refreshPrice, owner } = useMetadata(ddo) const { dtSymbol } = usePricing(ddo) const { isInPurgatory } = useAsset() @@ -77,6 +79,8 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement { const [creatorLiquidity, setCreatorLiquidity] = useState() const [creatorPoolTokens, setCreatorPoolTokens] = useState() const [creatorPoolShare, setCreatorPoolShare] = useState() + const [graphData, setGraphData] = useState() + // the purpose of the value is just to trigger the effect const [refreshPool, setRefreshPool] = useState(false) @@ -195,6 +199,34 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement { return () => clearInterval(interval) }, [ocean, accountId, price, ddo, refreshPool, owner]) + // Get graph history data + useEffect(() => { + if (!price?.address || !price?.ocean || !price?.value) return + + const source = axios.CancelToken.source() + const url = `${config.metadataCacheUri}/api/v1/aquarius/pools/history/${price.address}` + + async function getData() { + Logger.log('Fired GetGraphData!') + try { + const response = await axios(url, { cancelToken: source.token }) + if (!response || response.status !== 200) return + setGraphData(response.data) + } catch (error) { + if (axios.isCancel(error)) { + Logger.log(error.message) + } else { + Logger.error(error.message) + } + } + } + getData() + + return () => { + source.cancel() + } + }, [config.metadataCacheUri, price?.address, price?.ocean, price?.value]) + const refreshInfo = async () => { setRefreshPool(!refreshPool) await refreshPrice() @@ -284,6 +316,7 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement { {weightOcean}/{weightDt} )} + } ocean={`${price?.ocean}`}