diff --git a/package.json b/package.json index 58acc8680..5e42f734c 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "diff-match-patch": "^1.0.5", "emoji-mart": "^5.5.2", "framer-motion": "^11.3.24", - "highcharts": "^11.4.5", + "highcharts": "^12.1.2", "highcharts-react-official": "^3.2.1", "hive-uri": "^0.2.5", "hivesigner": "^3.3.4", diff --git a/src/api/hive.ts b/src/api/hive.ts index 16a0c467c..53f27012a 100644 --- a/src/api/hive.ts +++ b/src/api/hive.ts @@ -64,9 +64,6 @@ export const getTradeHistory = (limit: number = 1000): Promise limit ]); -export const getMarketBucketSizes = (): Promise => - client.call("condenser_api", "get_market_history_buckets", []); - export const getMarketHistory = ( seconds: number, startDate: Date, diff --git a/src/app/market/advanced/_components/api/index.ts b/src/app/market/advanced/_components/api/index.ts index f0c839941..28eec0195 100644 --- a/src/app/market/advanced/_components/api/index.ts +++ b/src/app/market/advanced/_components/api/index.ts @@ -1 +1,2 @@ -export * from "./trading-view-api"; +export * from "./trading-view-query"; +export * from "./market-bucket-size-query"; diff --git a/src/app/market/advanced/_components/api/market-bucket-size-query.ts b/src/app/market/advanced/_components/api/market-bucket-size-query.ts new file mode 100644 index 000000000..ff5ea6e5d --- /dev/null +++ b/src/app/market/advanced/_components/api/market-bucket-size-query.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { QueryIdentifiers } from "@/core/react-query"; +import { client } from "@/api/hive"; + +export function useMarketBucketSizeQuery() { + return useQuery({ + queryKey: [QueryIdentifiers.MARKET_BUCKET_SIZE], + queryFn: () => + client.call("condenser_api", "get_market_history_buckets", []) as Promise + }); +} diff --git a/src/app/market/advanced/_components/api/trading-view-api.ts b/src/app/market/advanced/_components/api/trading-view-api.ts deleted file mode 100644 index cccf7a70d..000000000 --- a/src/app/market/advanced/_components/api/trading-view-api.ts +++ /dev/null @@ -1,49 +0,0 @@ -import moment from "moment/moment"; -import { Time } from "lightweight-charts"; -import { useState } from "react"; -import { useTradingViewCache } from "../caching"; -import { Moment } from "moment"; -import { MarketCandlestickDataItem } from "@/entities"; - -export function useTradingViewApi(setData: (value: MarketCandlestickDataItem[]) => void) { - const { getCachedMarketHistory } = useTradingViewCache(); - - const [isLoading, setIsLoading] = useState(false); - const [originalData, setOriginalData] = useState([]); - - const fetchData = async ( - bucketSeconds: number, - startDate: Moment, - endDate: Moment, - loadMore?: boolean - ) => { - setIsLoading(true); - const apiData = await getCachedMarketHistory(bucketSeconds, startDate, endDate); - let transformedData: MarketCandlestickDataItem[] = []; - - if (loadMore) { - transformedData = [...originalData, ...apiData]; - } else { - transformedData = apiData; - } - setOriginalData(transformedData); - - const dataMap = transformedData - .map(({ hive, non_hive, open }) => ({ - close: non_hive.close / hive.close, - open: non_hive.open / hive.open, - low: non_hive.low / hive.low, - high: non_hive.high / hive.high, - volume: hive.volume, - time: Math.floor(moment(open).toDate().getTime() / 1000) as Time - })) - .reduce((acc, item) => acc.set(item.time, item), new Map()); - setIsLoading(false); - - setData(Array.from(dataMap.values()).sort((a, b) => Number(a.time) - Number(b.time))); - }; - - return { - fetchData - }; -} diff --git a/src/app/market/advanced/_components/api/trading-view-query.ts b/src/app/market/advanced/_components/api/trading-view-query.ts new file mode 100644 index 000000000..a04e5cb9c --- /dev/null +++ b/src/app/market/advanced/_components/api/trading-view-query.ts @@ -0,0 +1,46 @@ +import moment from "moment/moment"; +import { MarketCandlestickDataItem } from "@/entities"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { QueryIdentifiers } from "@/core/react-query"; +import { getMarketHistory } from "@/api/hive"; +import { Time } from "lightweight-charts"; + +export interface TradingViewQueryDataItem { + close: number; + open: number; + low: number; + high: number; + volume: number; + time: Time; +} + +export function useTradingViewQuery(bucketSeconds: number) { + return useInfiniteQuery({ + queryKey: [QueryIdentifiers.MARKET_TRADING_VIEW, bucketSeconds], + queryFn: async ({ pageParam: [startDate, endDate] }) => { + const apiData: MarketCandlestickDataItem[] = await getMarketHistory( + bucketSeconds, + startDate.toDate(), + endDate.toDate() + ); + + return apiData.map(({ hive, non_hive, open }) => ({ + close: non_hive.close / hive.close, + open: non_hive.open / hive.open, + low: non_hive.low / hive.low, + high: non_hive.high / hive.high, + volume: hive.volume, + time: Math.floor(moment(open).toDate().getTime() / 1000) as Time + })); + }, + initialPageParam: [ + // Fetch at least 8 hours or given interval + moment().subtract(Math.max(100 * bucketSeconds, 28_800), "seconds"), + moment() + ], + getNextPageParam: (_, __, [prevStartDate]) => [ + prevStartDate.clone().subtract(Math.max(100 * bucketSeconds, 28_800), "seconds"), + prevStartDate.subtract(bucketSeconds, "seconds") + ] + }); +} diff --git a/src/app/market/advanced/_components/market-advanced-mode-widget-header.tsx b/src/app/market/advanced/_components/market-advanced-mode-widget-header.tsx index 9fe03c60d..314b7474f 100644 --- a/src/app/market/advanced/_components/market-advanced-mode-widget-header.tsx +++ b/src/app/market/advanced/_components/market-advanced-mode-widget-header.tsx @@ -57,7 +57,7 @@ export const MarketAdvancedModeWidgetHeader = ({ {settings ? ( - +
{settings}
) : ( diff --git a/src/app/market/advanced/_components/trading-view-widget.tsx b/src/app/market/advanced/_components/trading-view-widget.tsx index e1f7f0c78..5c1356c48 100644 --- a/src/app/market/advanced/_components/trading-view-widget.tsx +++ b/src/app/market/advanced/_components/trading-view-widget.tsx @@ -1,159 +1,59 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MarketAdvancedModeWidget } from "./market-advanced-mode-widget"; -import { getMarketBucketSizes } from "@/api/hive"; -import moment, { Moment } from "moment"; -import { IChartApi, ISeriesApi, TimeRange } from "lightweight-charts"; import useLocalStorage from "react-use/lib/useLocalStorage"; -import useDebounce from "react-use/lib/useDebounce"; -import { useResizeDetector } from "react-resize-detector"; -import { useTradingViewApi } from "./api"; +import { TradingViewQueryDataItem, useMarketBucketSizeQuery, useTradingViewQuery } from "./api"; import { Widget } from "@/app/market/advanced/_advanced-mode/types/layout.type"; import { PREFIX } from "@/utils/local-storage"; import { useGlobalStore } from "@/core/global-store"; import i18next from "i18next"; import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from "@ui/dropdown"; import { Button } from "@ui/button"; +import { useInfiniteDataFlow } from "@/utils"; +import { useDebounce, useMount } from "react-use"; +import { createChart, IChartApi, ISeriesApi, Time, TimeRange } from "lightweight-charts"; +import { useResizeDetector } from "react-resize-detector"; interface Props { widgetTypeChanged: (type: Widget) => void; } -interface TriggerFetch { - fetch: boolean; - loadMore: boolean; -} - -const TRIGGER_FETCH_DEFAULT: TriggerFetch = { fetch: false, loadMore: false }; -const HISTOGRAM_OPTIONS: any = { - color: "#26a69a", - priceFormat: { - type: "volume" - }, - priceScaleId: "", - scaleMargins: { - top: 0.8, - bottom: 0 - } -}; - export const TradingViewWidget = ({ widgetTypeChanged }: Props) => { const theme = useGlobalStore((s) => s.theme); - const { width, height, ref: chartRef } = useResizeDetector(); - - const [storedBucketSeconds, setStoredBucketSeconds] = useLocalStorage( - PREFIX + "_amml_tv_bs", - 300 - ); - - const [data, setData] = useState([]); - const [startDate, setStartDate] = useState(moment().subtract(8, "hours")); - const [endDate, setEndDate] = useState(moment()); - const [bucketSeconds, setBucketSeconds] = useState(storedBucketSeconds ?? 300); - const [chart, setChart] = useState(null); - const [chartSeries, setChartSeries] = useState | null>(null); - const [histoSeries, setHistoSeries] = useState | null>(null); - const [bucketSecondsList, setBucketSecondsList] = useState([]); - const [triggerFetch, setTriggerFetch] = useState(TRIGGER_FETCH_DEFAULT); - const [isZoomed, setIsZoomed] = useState(false); - const [lastTimeRange, setLastTimeRange] = useState(null); - - const { fetchData } = useTradingViewApi(setData); - - useDebounce( - () => { - if (!triggerFetch.fetch) return; - - setEndDate(startDate.clone().subtract(bucketSeconds, "seconds")); - setStartDate( - getNewStartDate(startDate.clone().subtract(bucketSeconds, "seconds"), "subtract") - ); - fetchData(bucketSeconds, startDate, endDate, triggerFetch.loadMore); - setTriggerFetch(TRIGGER_FETCH_DEFAULT); - }, - 300, - [triggerFetch] + const { ref: chartContainerRef, width, height } = useResizeDetector(); + const chartRef = useRef(); + + const [bucketSeconds, setBucketSeconds] = useLocalStorage(PREFIX + "_amml_tv_bs", 300); + const [candleStickSeries, setCandleStickSeries] = useState>(); + const [lastTimeRange, setLastTimeRange] = useState(); + + const { data: bucketSecondsList } = useMarketBucketSizeQuery(); + const { + data: dataPages, + fetchNextPage, + isFetchingNextPage + } = useTradingViewQuery(bucketSeconds ?? 300); + + const data = useInfiniteDataFlow(dataPages); + const uniqueData = useMemo( + () => + Array.from( + data + .reduce( + (acc, item) => acc.set(item.time, item), + new Map() + ) + .values() + ).sort((a, b) => Number(a.time) - Number(b.time)), + [data] ); - useEffect(() => { - getMarketBucketSizes().then((sizes) => setBucketSecondsList(sizes)); - buildChart().then(() => fetchData(bucketSeconds, startDate, endDate)); - }, []); - - useEffect(() => { - if (width && height) { - chart?.resize(width, height); - } - }, [width, height]); - - useEffect(() => { - const fromDate = lastTimeRange ? new Date(Number(lastTimeRange.from) * 1000) : null; - if (fromDate) { - if (lastTimeRange?.from === data[0]?.time) setTriggerFetch({ fetch: true, loadMore: true }); - } - }, [lastTimeRange]); - - useEffect(() => { - if (chartSeries) { - chart?.removeSeries(chartSeries); - setChartSeries(null); - } - - if (histoSeries) { - chart?.removeSeries(histoSeries); - setHistoSeries(null); - } - - setData([]); - setEndDate(moment()); - setStartDate(getNewStartDate(moment(), "subtract")); - setTriggerFetch({ fetch: true, loadMore: false }); - - setStoredBucketSeconds(bucketSeconds); - }, [bucketSeconds]); - - useEffect(() => { - if (!chart) { + useMount(() => { + if (!chartContainerRef.current) { return; } - const candleStickSeries = - chartSeries ?? - chart.addCandlestickSeries({ - priceFormat: { - type: "price", - precision: 5, - minMove: 0.00001 - } - }); - candleStickSeries.setData(data); - - const volumeSeries = histoSeries ?? chart.addHistogramSeries(HISTOGRAM_OPTIONS); - volumeSeries.setData( - data.map(({ time, volume, open, close }) => ({ - time, - value: volume / 1000, - color: open < close ? "rgba(0, 150, 136, 0.8)" : "rgba(255,82,82, 0.8)" - })) - ); - - if (!isZoomed && data.length > 0) { - chart?.timeScale().fitContent(); - setIsZoomed(true); - } - - setChartSeries(candleStickSeries); - setHistoSeries(volumeSeries); - }, [data, chart]); - - useEffect(() => { - if (chart) { - chart.options().layout.textColor = theme == "night" ? "#fff" : "#000"; - } - }, [theme]); - const buildChart = async () => { - const tradingView = await import("lightweight-charts"); - const chartInstance = tradingView.createChart(chartRef.current, { + const chartOptions = { rightPriceScale: { scaleMargins: { top: 0.3, @@ -169,38 +69,80 @@ export const TradingViewWidget = ({ widgetTypeChanged }: Props) => { color: "transparent" }, textColor: theme == "night" ? "#fff" : "#000" + }, + grid: { + horzLines: { + visible: true, + color: "rgba(100, 100, 100, 0.5)", + style: 1, + width: 1 + }, + vertLines: { + visible: true, + color: "rgba(100, 100, 100, 0.5)", + style: 1, + width: 1 + } } - }); + }; + const chart = createChart(chartContainerRef.current, chartOptions); + chartRef.current = chart; - chartInstance + chart .timeScale() - .subscribeVisibleTimeRangeChange((timeRange) => setLastTimeRange(timeRange)); - setChart(chartInstance); - }; + .subscribeVisibleTimeRangeChange((timeRange) => setLastTimeRange(timeRange ?? undefined)); - const getNewStartDate = (date: Moment, operation: "add" | "subtract") => { - let newStartDate = date.clone(); - let value = 0; - let unit: "hours" | "days" = "hours"; - if (bucketSeconds === 15) value = 4; - if (bucketSeconds === 60) value = 8; - if (bucketSeconds === 300) value = 8; - if (bucketSeconds === 3600) { - value = 1; - unit = "days"; - } - if (bucketSeconds === 86400) { - value = 20; - unit = "days"; - } + setCandleStickSeries( + chart.addCandlestickSeries({ + upColor: "#26a69a", + downColor: "#ef5350", + borderVisible: false, + wickUpColor: "#26a69a", + wickDownColor: "#ef5350", + priceFormat: { + type: "price", + precision: 5, + minMove: 0.00001 + } + }) + ); + + setTimeout(() => fetchNextPage(), 10000); + }); + + useDebounce( + () => { + if (lastTimeRange?.from === uniqueData[0]?.time) { + fetchNextPage(); + } + }, + 300, + [lastTimeRange, uniqueData, fetchNextPage] + ); - if (operation === "add") newStartDate = newStartDate.add(value, unit); - if (operation === "subtract") newStartDate = newStartDate.subtract(value, unit); + useEffect(() => { + chartRef.current?.resize(width ?? 0, height ?? 0); + }, [width, height]); + + useEffect(() => { + if (candleStickSeries) { + candleStickSeries.setData([]); + candleStickSeries.setData( + uniqueData.map((item) => ({ + ...item, + value: item.volume / 1000 + })) + ); + } + }, [candleStickSeries, uniqueData]); - return newStartDate; - }; + useEffect(() => { + if (chartRef.current) { + chartRef.current.options().layout.textColor = theme == "night" ? "#fff" : "#161d26"; + } + }, [theme]); - const getBucketSecondsLabel = () => { + const getBucketSecondsLabel = useCallback((bucketSeconds: number) => { switch (bucketSeconds) { case 15: return "15s"; @@ -215,7 +157,7 @@ export const TradingViewWidget = ({ widgetTypeChanged }: Props) => { default: return ""; } - }; + }, []); return ( { title={ <> {i18next.t("market.advanced.chart")} - ({getBucketSecondsLabel()}) + ({getBucketSecondsLabel(bucketSeconds ?? 300)}) } widgetTypeChanged={widgetTypeChanged} @@ -236,8 +178,8 @@ export const TradingViewWidget = ({ widgetTypeChanged }: Props) => { {bucketSecondsList - .map((size) => ({ - label: `${size}`, + ?.map((size) => ({ + label: `${getBucketSecondsLabel(size)}`, selected: bucketSeconds === size, onClick: () => setBucketSeconds(size) })) @@ -250,7 +192,7 @@ export const TradingViewWidget = ({ widgetTypeChanged }: Props) => { } > -
+
); }; diff --git a/src/app/market/index.scss b/src/app/market/index.scss index 9d7d257ec..a07c297df 100644 --- a/src/app/market/index.scss +++ b/src/app/market/index.scss @@ -323,10 +323,6 @@ thead, .pagination { .market-advanced-mode-tv-widget { .market-advanced-mode-trading-view-widget { height: calc(100% - 44px); - - .tv-lightweight-charts:first-child { - display: none; - } } &.expanded-header { diff --git a/src/core/react-query/index.ts b/src/core/react-query/index.ts index 8364385e5..02fad8297 100644 --- a/src/core/react-query/index.ts +++ b/src/core/react-query/index.ts @@ -97,7 +97,9 @@ export enum QueryIdentifiers { GET_HIVE_ENGINE_MARKET_DATA = "get-hive-engine-market-data", HIVE_ENGINE_TOKEN_BALANCES = "hive-engine-token-balances", HIVE_ENGINE_TOKEN_BALANCES_USD = "hive-engine-token-balances-usd", - PAGE_STATS = "page-stats" + PAGE_STATS = "page-stats", + MARKET_TRADING_VIEW = "market-trading-view", + MARKET_BUCKET_SIZE = "market-bucket-size" } export function makeQueryClient() { diff --git a/src/features/ui/accordion/accordion-collapse.tsx b/src/features/ui/accordion/accordion-collapse.tsx index 3cf0daabd..f38f2261e 100644 --- a/src/features/ui/accordion/accordion-collapse.tsx +++ b/src/features/ui/accordion/accordion-collapse.tsx @@ -2,15 +2,17 @@ import React, { HTMLProps, useContext, useRef } from "react"; import { AccordionContext } from "@/features/ui/accordion/accordion-context"; import { classNameObject, useFilteredProps } from "@/features/ui/util"; -export function AccordionCollapse(props: HTMLProps & { eventKey: string }) { +export function AccordionCollapse( + props: HTMLProps & { eventKey: string; overflowHidden: boolean } +) { const { show } = useContext(AccordionContext); const collapseRef = useRef(null); - const nativeProps = useFilteredProps(props, ["eventKey"]); + const nativeProps = useFilteredProps(props, ["eventKey", "overflowHidden"]); return (
diff --git a/yarn.lock b/yarn.lock index 1523b6df4..b61b3690b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5593,14 +5593,19 @@ he@^1.2.0: highcharts-react-official@^3.2.1: version "3.2.1" - resolved "https://registry.npmjs.org/highcharts-react-official/-/highcharts-react-official-3.2.1.tgz" + resolved "https://registry.yarnpkg.com/highcharts-react-official/-/highcharts-react-official-3.2.1.tgz#4b62a7af2969bdebde6b338d36f6b9bf1f1029bc" integrity sha512-hyQTX7ezCxl7JqumaWiGsroGWalzh24GedQIgO3vJbkGOZ6ySRAltIYjfxhrq4HszJOySZegotEF7v+haQ75UA== -highcharts@*, highcharts@^11.4.5: +highcharts@*: version "11.4.5" resolved "https://registry.npmjs.org/highcharts/-/highcharts-11.4.5.tgz" integrity sha512-jUkojddxLFhyfZ+naLsRtYv/NjXEptcZ0/8bqSSS2aoz9sU80rb4LBJlYfyRFHBGw5sMkUqeaXsv66J49O8n7A== +highcharts@^12.1.2: + version "12.1.2" + resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-12.1.2.tgz#826114935b3ffb6b99dc906da00f137667a67fbb" + integrity sha512-paZ72q1um0zZT1sS+O/3JfXVSOLPmZ0zlo8SgRc0rEplPFPQUPc4VpkgQS8IUTueeOBgIWwVpAWyC9tBYbQ0kg== + highcharts@^6.0.4: version "6.2.0" resolved "https://registry.npmjs.org/highcharts/-/highcharts-6.2.0.tgz"