diff --git a/package.json b/package.json index c13ce2f9..bb1d93ad 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@penumbra-zone/ui": "^9.0.0", "@penumbra-zone/wasm": "^26.2.0", "@radix-ui/react-icons": "^1.3.0", + "@rehooks/component-size": "^1.0.3", "@styled-icons/octicons": "^10.47.0", "@tsconfig/strictest": "^2.0.5", "@tsconfig/vite-react": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de01c5fd..17e658ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.0(react@18.3.1) + '@rehooks/component-size': + specifier: ^1.0.3 + version: 1.0.3(react@18.3.1) '@styled-icons/octicons': specifier: ^10.47.0 version: 10.47.0(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) @@ -2581,6 +2584,11 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rehooks/component-size@1.0.3': + resolution: {integrity: sha512-pnYld+8SSF2vXwdLOqBGUyOrv/SjzwLjIUcs/4c1JJgR0q4E9eBtBfuZMD6zUD51fvSehSsbnlQMzotSmPTXPg==} + peerDependencies: + react: '>=16.8.0' + '@remix-run/router@1.19.2': resolution: {integrity: sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==} engines: {node: '>=14.0.0'} @@ -8500,6 +8508,10 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@rehooks/component-size@1.0.3(react@18.3.1)': + dependencies: + react: 18.3.1 + '@remix-run/router@1.19.2': {} '@rtsao/scc@1.1.0': {} diff --git a/src/components/charts/ohlcChart.tsx b/src/components/charts/ohlcChart.tsx index d5f7e8db..8c777a2c 100644 --- a/src/components/charts/ohlcChart.tsx +++ b/src/components/charts/ohlcChart.tsx @@ -2,18 +2,21 @@ /* eslint-disable -- disabling this file as this was created before our strict rules */ // src/components/charts/ohlcChart.tsx -import React, { useEffect, useState } from "react"; -import { VStack, Text, Button, ButtonGroup } from "@chakra-ui/react"; -import { Token } from "@/utils/types/token"; -import { LoadingSpinner } from "../util/loadingSpinner"; -import ReactECharts from "echarts-for-react"; -import { format } from "date-fns"; +import React, { useEffect, useState, useRef } from 'react'; +import { VStack, Text, Button, ButtonGroup, Flex } from '@chakra-ui/react'; +import useComponentSize from '@rehooks/component-size'; +import { Token } from '@/utils/types/token'; +import { LoadingSpinner } from '../util/loadingSpinner'; +import ReactECharts from 'echarts-for-react'; +import { format } from 'date-fns'; interface OHLCChartProps { asset1Token: Token; asset2Token: Token; } const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { + const wrapperRef = useRef(); + const { width } = useComponentSize(wrapperRef); const [isLoading, setIsLoading] = useState(true); const [isOHLCDataLoading, setIsOHLCDataLoading] = useState(true); const [isTimestampsLoading, setIsTimestampsLoading] = useState(true); @@ -21,12 +24,10 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { const [originalOHLCData, setOriginalOHLCData] = useState([]); // [{open, high, low, close, directVolume, swapVolume, height} const [blockToTimestamp, setBlockToTimestamp] = useState>({}); // {height: timestamp} const [error, setError] = useState(undefined); // [error message] - const [chartData, setChartData] = useState< - [string, number, number, number, number][] - >([]); // [[date, open, close, low, high]] + const [chartData, setChartData] = useState<[string, number, number, number, number][]>([]); // [[date, open, close, low, high]] const [volumeData, setVolumeData] = useState<[string, number][]>([]); // Time aggregate, 1m, 5m, 1h, 1D to start off - const [timeAggregateSeconds, setTimeAggregateSeconds] = useState(60 * 60* 24); // 1D default + const [timeAggregateSeconds, setTimeAggregateSeconds] = useState(60 * 60 * 24); // 1D default const [isAggregating, setIsAggregating] = useState(true); // Potentially show last n days of data based on current block - n days via avg block time @@ -40,18 +41,19 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { } // Get current block height from `/api/blocks/1` const getData = async () => { - const startBlock = await fetch("/api/blocks/1") - .then((res) => res.json()) - .then((data) => { - const currentBlock = data[0].height - console.log(currentBlock) - const startBlock = currentBlock - Math.trunc(daysLookback * 24 * 60 * 60 / blockTimeSeconds); - console.log("Start block: ", startBlock); - return startBlock + const startBlock = await fetch('/api/blocks/1') + .then(res => res.json()) + .then(data => { + const currentBlock = data[0].height; + console.log(currentBlock); + const startBlock = + currentBlock - Math.trunc((daysLookback * 24 * 60 * 60) / blockTimeSeconds); + console.log('Start block: ', startBlock); + return startBlock; }) - .catch((error) => { - console.error("Error fetching data", error); - setError("Error fetching block height"); + .catch(error => { + console.error('Error fetching data', error); + setError('Error fetching block height'); setIsLoading(false); return 0; // Gets recent data (not necessarily last 7 days) }); @@ -60,11 +62,11 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { // 1. First fetch ohlc data const ohlcDataForward = fetch( - `/api/ohlc/${asset1Token.display}/${asset2Token.display}/${startBlock}/${limit}` - ).then((res) => res.json()); + `/api/ohlc/${asset1Token.display}/${asset2Token.display}/${startBlock}/${limit}`, + ).then(res => res.json()); const ohlcDataBackward = fetch( - `/api/ohlc/${asset2Token.display}/${asset1Token.display}/${startBlock}/${limit}` - ).then((res) => res.json()); + `/api/ohlc/${asset2Token.display}/${asset1Token.display}/${startBlock}/${limit}`, + ).then(res => res.json()); Promise.all([ohlcDataForward, ohlcDataBackward]) .then(([ohlcDataForwardResponse, ohlcDataBackwardResponse]) => { @@ -74,16 +76,13 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { !ohlcDataBackwardResponse || ohlcDataBackwardResponse.error ) { - throw new Error("Error fetching data"); + throw new Error('Error fetching data'); } - console.log("ohlcDataForward", ohlcDataForwardResponse); - console.log("ohlcDataBackward", ohlcDataBackwardResponse); + console.log('ohlcDataForward', ohlcDataForwardResponse); + console.log('ohlcDataBackward', ohlcDataBackwardResponse); - if ( - ohlcDataForwardResponse.length === 0 && - ohlcDataBackwardResponse.length === 0 - ) { - setError("No OHLC data found"); + if (ohlcDataForwardResponse.length === 0 && ohlcDataBackwardResponse.length === 0) { + setError('No OHLC data found'); } // Merge the two arrays, forward will be left alone, however backward will need to have 1/price and volumes will have to account for the pricing and decimal difference @@ -111,8 +110,7 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { const combinedItem = combinedDataMap.get(item.height); // OHLC should be weighted average const totalVolume = item.swapVolume + item.directVolume; - const oldTotalVolume = - combinedItem.swapVolume + combinedItem.directVolume; + const oldTotalVolume = combinedItem.swapVolume + combinedItem.directVolume; combinedItem.open = (combinedItem.open * oldTotalVolume + item.open * totalVolume) / @@ -137,8 +135,7 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { const combinedItem = combinedDataMap.get(item.height); // OHLC should be weighted average const totalVolume = item.swapVolume + item.directVolume; - const oldTotalVolume = - combinedItem.swapVolume + combinedItem.directVolume; + const oldTotalVolume = combinedItem.swapVolume + combinedItem.directVolume; combinedItem.open = (combinedItem.open * oldTotalVolume + item.open * totalVolume) / @@ -159,20 +156,20 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { // Sort the data by height // Put it back into an array const sortedData = Array.from(combinedDataMap.values()).sort( - (a, b) => a.height - b.height + (a, b) => a.height - b.height, ); setOHLCData(sortedData as any); setOriginalOHLCData(sortedData as any); setIsOHLCDataLoading(false); }) - .catch((error) => { - console.error("Error fetching data", error); - setError("Error fetching OHLC data"); + .catch(error => { + console.error('Error fetching data', error); + setError('Error fetching OHLC data'); setIsLoading(false); setIsOHLCDataLoading(false); }); - } + }; getData(); @@ -192,84 +189,65 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { // Process the data and make a list of OHLC heights // format needed is '/api/blockTimestamps/range/{startHeight}/{endHeight}' const timestampsForHeights = fetch( - `/api/blockTimestamps/range/${originalOHLCData[0].height}/${originalOHLCData[originalOHLCData.length - 1].height}` - ).then((res) => res.json()); + `/api/blockTimestamps/range/${originalOHLCData[0].height}/${originalOHLCData[originalOHLCData.length - 1].height}`, + ).then(res => res.json()); Promise.all([timestampsForHeights]) .then(([timestampsForHeightsResponse]) => { - if ( - !timestampsForHeightsResponse || - timestampsForHeightsResponse.error - ) { - throw new Error( - `Error fetching data: ${timestampsForHeightsResponse}` - ); + if (!timestampsForHeightsResponse || timestampsForHeightsResponse.error) { + throw new Error(`Error fetching data: ${timestampsForHeightsResponse}`); } // If we have less timestamps than heights, we need to throw an error - if ( - Object.keys(timestampsForHeightsResponse).length < - originalOHLCData.length - ) { - throw new Error( - `Error fetching data: ${timestampsForHeightsResponse}` - ); + if (Object.keys(timestampsForHeightsResponse).length < originalOHLCData.length) { + throw new Error(`Error fetching data: ${timestampsForHeightsResponse}`); } - console.log("Timestamps: ", timestampsForHeightsResponse); + console.log('Timestamps: ', timestampsForHeightsResponse); // Convert to a dictionary with height as key and timestamp as value const timestampMapping: Record = {}; - timestampsForHeightsResponse.forEach( - (item: { height: string; created_at: string }) => { - timestampMapping[item.height] = item.created_at; - } - ); - console.log("Timestamp mapping: ", timestampMapping); + timestampsForHeightsResponse.forEach((item: { height: string; created_at: string }) => { + timestampMapping[item.height] = item.created_at; + }); + console.log('Timestamp mapping: ', timestampMapping); setBlockToTimestamp(timestampMapping); setIsTimestampsLoading(false); }) - .catch((error) => { - console.error("Error fetching data", error); - setError("Error fetching timestamps for heights: " + error); + .catch(error => { + console.error('Error fetching data', error); + setError('Error fetching timestamps for heights: ' + error); setIsLoading(false); setIsTimestampsLoading(false); }); }, [originalOHLCData, isOHLCDataLoading]); useEffect(() => { - if ( - isOHLCDataLoading || - isTimestampsLoading || - error !== undefined || - isAggregating - ) { + if (isOHLCDataLoading || isTimestampsLoading || error !== undefined || isAggregating) { return; } // Validate and format date const formatTimestamp = (timestamp: string) => { const date = new Date(timestamp); - return isNaN(date.getTime()) ? null : format(date, "yyyy-MM-dd HH:mm:ss"); + return isNaN(date.getTime()) ? null : format(date, 'yyyy-MM-dd HH:mm:ss'); }; // Prepare data for the chart // blockToTimestamp is a dictionary with height as key and timestamp as value const preparedData = ohlcData - .map((ohlc) => { + .map(ohlc => { const formattedDate = formatTimestamp(blockToTimestamp[ohlc.height]); if (!formattedDate) { console.error( - `Invalid timestamp for height ${ohlc.height}: ${blockToTimestamp[ohlc.height] - }` + `Invalid timestamp for height ${ohlc.height}: ${blockToTimestamp[ohlc.height]}`, ); - setError("Missing timestamp for height " + ohlc.height); + setError('Missing timestamp for height ' + ohlc.height); return null; } - const decimalCorrection = - 10 ** Math.abs(asset2Token.decimals - asset1Token.decimals); + const decimalCorrection = 10 ** Math.abs(asset2Token.decimals - asset1Token.decimals); return [ formattedDate, ((ohlc.open as number) / decimalCorrection).toFixed(6), @@ -278,43 +256,22 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { ((ohlc.high as number) / decimalCorrection).toFixed(6), // Volume // Divide volume by decimals of the quote token depending on the direction of the canldestick data - ( - (((ohlc.swapVolume as number) + - ohlc.directVolume)) / - 10 ** asset1Token.decimals - ).toFixed(2), + (((ohlc.swapVolume as number) + ohlc.directVolume) / 10 ** asset1Token.decimals).toFixed( + 2, + ), ]; }) - .filter((item) => item !== null) as [ - string, - number, - number, - number, - number, - number - ][]; - - console.log("Prepared data: ", preparedData); - - const volumePreparedData = preparedData.map((item) => [ - item[0], - item[5], - ]); - - setChartData( - preparedData.map((item) => [item[0], item[1], item[2], item[3], item[4]]) - ); + .filter(item => item !== null) as [string, number, number, number, number, number][]; + + console.log('Prepared data: ', preparedData); + + const volumePreparedData = preparedData.map(item => [item[0], item[5]]); + + setChartData(preparedData.map(item => [item[0], item[1], item[2], item[3], item[4]])); setVolumeData(volumePreparedData); setIsLoading(false); - }, [ - ohlcData, - blockToTimestamp, - isOHLCDataLoading, - isTimestampsLoading, - error, - isAggregating, - ]); + }, [ohlcData, blockToTimestamp, isOHLCDataLoading, isTimestampsLoading, error, isAggregating]); // Aggregate data base on the timeAggregateSeconds useEffect(() => { @@ -328,8 +285,8 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { const batchOHLCData = (batch: any[], intervalStart: Date) => { const aggregatedOHLC: any = { open: batch[0].open, - high: Math.max(...batch.map((ohlc) => ohlc.high)), - low: Math.min(...batch.map((ohlc) => ohlc.low)), + high: Math.max(...batch.map(ohlc => ohlc.high)), + low: Math.min(...batch.map(ohlc => ohlc.low)), close: batch[batch.length - 1].close, directVolume: batch.reduce((acc, ohlc) => acc + ohlc.directVolume, 0), swapVolume: batch.reduce((acc, ohlc) => acc + ohlc.swapVolume, 0), @@ -404,11 +361,18 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { } // Fill in any missing intervals - while (new Date(currentIntervalStart.getTime() + timeAggregateSeconds * 1000) <= intervalStart) { - currentIntervalStart = new Date(currentIntervalStart.getTime() + timeAggregateSeconds * 1000); + while ( + new Date(currentIntervalStart.getTime() + timeAggregateSeconds * 1000) <= intervalStart + ) { + currentIntervalStart = new Date( + currentIntervalStart.getTime() + timeAggregateSeconds * 1000, + ); if (currentIntervalStart < intervalStart) { const previousOHLC = aggregatedData[aggregatedData.length - 1]; - const placeholderOHLC = createPlaceholderOHLC(currentIntervalStart, previousOHLC.close); + const placeholderOHLC = createPlaceholderOHLC( + currentIntervalStart, + previousOHLC.close, + ); aggregatedData.push(placeholderOHLC); } } @@ -428,49 +392,46 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { }; const aggregatedData = aggregateOHLCData(); - console.log("Aggregated data: ", aggregatedData); + console.log('Aggregated data: ', aggregatedData); // Further processing or state setting with aggregatedData setOHLCData(aggregatedData as any); setBlockToTimestamp(blockToTimestamp); setIsLoading(false); setIsAggregating(false); - }, [ - timeAggregateSeconds, - isOHLCDataLoading, - isTimestampsLoading, - blockToTimestamp, - error, - ]); + }, [timeAggregateSeconds, isOHLCDataLoading, isTimestampsLoading, blockToTimestamp, error]); + const left = 44; + const right = 8; const options = { + width: width - left - right, xAxis: [ { - type: "category", - data: chartData.map((item) => item[0]), + type: 'category', + data: chartData.map(item => item[0]), scale: true, boundaryGap: true, axisLine: { onZero: false }, splitLine: { show: false }, splitNumber: 20, axisLabel: { show: false }, - min: "dataMin", - max: "dataMax", + min: 'dataMin', + max: 'dataMax', }, { - type: "category", + type: 'category', gridIndex: 1, - data: volumeData.map((item) => item[0]), + data: volumeData.map(item => item[0]), axisLine: { onZero: false }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { formatter: function (value: string) { - return value.replace(/ /g, "\n"); // Replace space with a newline + return value.replace(/ /g, '\n'); // Replace space with a newline }, }, - min: "dataMin", - max: "dataMax", + min: 'dataMin', + max: 'dataMax', }, ], yAxis: [ @@ -489,54 +450,52 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { ], grid: [ { - left: "10%", - right: "8%", - height: "60%", + left, + right, + height: '60%', }, { - left: "10%", - right: "8%", - top: "74%", - height: "12%", + left, + right, + top: '74%', + height: '12%', }, ], series: [ { - name: "OHLC", - type: "candlestick", - data: chartData.map((item) => [item[1], item[2], item[3], item[4]]), + name: 'OHLC', + type: 'candlestick', + data: chartData.map(item => [item[1], item[2], item[3], item[4]]), itemStyle: { - color: "rgba(51, 255, 87, 1)", // Neon Green - color0: "rgba(255, 73, 108, 1)", // Neon Red - borderColor: "rgba(51, 255, 87, 1)", // Neon Green - borderColor0: "rgba(255, 73, 108, 1)", // Neon Red + color: 'rgba(51, 255, 87, 1)', // Neon Green + color0: 'rgba(255, 73, 108, 1)', // Neon Red + borderColor: 'rgba(51, 255, 87, 1)', // Neon Green + borderColor0: 'rgba(255, 73, 108, 1)', // Neon Red }, }, { - name: "Volume", - type: "bar", + name: 'Volume', + type: 'bar', xAxisIndex: 1, yAxisIndex: 1, - data: volumeData.map((item) => item[1]), + data: volumeData.map(item => item[1]), itemStyle: { color: (params: any) => { const ohlc = chartData[params.dataIndex]; - return ohlc[1] > ohlc[2] - ? "rgba(255, 73, 108, 1)" - : "rgba(51, 255, 87, 1)"; + return ohlc[1] > ohlc[2] ? 'rgba(255, 73, 108, 1)' : 'rgba(51, 255, 87, 1)'; }, }, }, ], tooltip: { - trigger: "axis", + trigger: 'axis', axisPointer: { - type: "cross", + type: 'cross', }, formatter: (params: any) => { - let tooltipText = ""; + let tooltipText = ''; params.forEach((param: any) => { - if (param.seriesType === "candlestick") { + if (param.seriesType === 'candlestick') { const [date, open, close, low, high] = param.data; tooltipText += ` ${params[0].name}
@@ -545,14 +504,9 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { Low: ${Number(low).toLocaleString()}
High: ${Number(high).toLocaleString()}
`; - } else if ( - param.seriesType === "bar" && - param.seriesName === "Volume" - ) { + } else if (param.seriesType === 'bar' && param.seriesName === 'Volume') { tooltipText += `${params[0].name}
- Volume: ${Number( - param.data - ).toLocaleString()}
`; + Volume: ${Number(param.data).toLocaleString()}
`; } }); return tooltipText; @@ -560,77 +514,64 @@ const OHLCChart = ({ asset1Token, asset2Token }: OHLCChartProps) => { }, dataZoom: [ { - type: "inside", + type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100, }, { - type: "slider", + type: 'slider', xAxisIndex: [0, 1], start: 0, end: 100, - backgroundColor: "rgba(0, 0, 0, 0)", // Transparent background + backgroundColor: 'rgba(0, 0, 0, 0)', // Transparent background showDataShadow: true, // show data shadow showDetail: true, // show detailed information dataBackground: { areaStyle: { - color: "rgba(255, 255, 255, 0.1)", // Light grey background + color: 'rgba(255, 255, 255, 0.1)', // Light grey background }, lineStyle: { - color: "rgba(255, 255, 255, 0.3)", // Light grey line + color: 'rgba(255, 255, 255, 0.3)', // Light grey line }, }, - fillerColor: "rgba(255, 255, 255, 0.2)", // Slightly brighter fill color - borderColor: "rgba(255, 255, 255, 0.2)", // Light grey border + fillerColor: 'rgba(255, 255, 255, 0.2)', // Slightly brighter fill color + borderColor: 'rgba(255, 255, 255, 0.2)', // Light grey border // handleIcon:"M8.2,13.4c0,0.6-0.4,1-1,1H1.8c-0.6,0-1-0.4-1-1v-6.8c0-0.6,0.4-1,1-1h5.4c0.6,0,1,0.4,1,1V13.4z", // Handle icon - handleSize: "100%", // Size of the handle + handleSize: '100%', // Size of the handle handleStyle: { - color: "rgba(255, 255, 255, 0.6)", // Light grey handle - borderColor: "rgba(0, 0, 0, 0.5)", // Slightly darker border + color: 'rgba(255, 255, 255, 0.6)', // Light grey handle + borderColor: 'rgba(0, 0, 0, 0.5)', // Slightly darker border }, textStyle: { - color: "rgba(255, 255, 255, 0.6)", // Light grey text + color: 'rgba(255, 255, 255, 0.6)', // Light grey text }, }, ], }; - // ! Width should be the same as that of the DepthChart - return ( - + {isLoading && error === undefined ? ( ) : error !== undefined ? ( - + {error} ) : ( - + <> + - - + + + )} );