diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.module.scss new file mode 100644 index 0000000000..d99e5ed0d3 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.module.scss @@ -0,0 +1,40 @@ +@use "@pythnetwork/component-library/theme"; + +.hoverCard { + @include theme.elevation("default", 2); + + display: none; + background-color: theme.color("background", "tooltip"); + border-radius: theme.border-radius("md"); + color: theme.color("tooltip"); + font-size: theme.font-size("xs"); + padding: theme.spacing(3); + pointer-events: none; + position: absolute; + width: theme.spacing(60); + overflow: hidden; + z-index: 2; // 2 to render above chart crosshair +} + +.hoverCardTable { + color-scheme: dark; + white-space: nowrap; + width: 100%; + + td:last-child { + color: theme.color("foreground"); + font-weight: theme.font-weight("medium"); + text-align: right; + font-variant-numeric: tabular-nums; + } + + td:first-child { + color: theme.color("muted"); + font-weight: theme.font-weight("normal"); + text-align: left; + } +} + +:global([data-theme="dark"]) .hoverCardTable { + color-scheme: light; +} diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.tsx b/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.tsx new file mode 100644 index 0000000000..15e92bca22 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/Chart/chart-hover-card.tsx @@ -0,0 +1,42 @@ +"use client"; + +import clsx from "clsx"; +import type { ComponentPropsWithRef } from "react"; + +import styles from "./chart-hover-card.module.scss"; + +export type ChartHoverCardProps = ComponentPropsWithRef<"div"> & { + timestamp?: string; + price?: string; + confidence?: string; +}; + +export function ChartHoverCard({ + timestamp, + price, + confidence, + className, + ...props +}: ChartHoverCardProps) { + return ( +
+ + + + + + + + + + {confidence && ( + + + + + )} + +
{timestamp}
Price{price}
Confidence{confidence}
+
+ ); +} diff --git a/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss b/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss index edab5582d0..df9125a9aa 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss +++ b/apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss @@ -6,6 +6,7 @@ height: theme.spacing(140); border-radius: theme.border-radius("xl"); overflow: hidden; + position: relative; .spinnerContainer { width: 100%; diff --git a/apps/insights/src/components/PriceFeed/Chart/chart.tsx b/apps/insights/src/components/PriceFeed/Chart/chart.tsx index 7a4690d369..b2451462c4 100644 --- a/apps/insights/src/components/PriceFeed/Chart/chart.tsx +++ b/apps/insights/src/components/PriceFeed/Chart/chart.tsx @@ -1,7 +1,9 @@ "use client"; +import type { PriceData } from "@pythnetwork/client"; import { PriceStatus } from "@pythnetwork/client"; import { useLogger } from "@pythnetwork/component-library/useLogger"; +import { isNullOrUndefined } from "@pythnetwork/shared-lib/util"; import { useResizeObserver, useMountEffect } from "@react-hookz/web"; import { startOfMinute, @@ -25,9 +27,12 @@ import { } from "lightweight-charts"; import { useTheme } from "next-themes"; import type { RefObject } from "react"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDateFormatter } from "react-aria"; import { z } from "zod"; +import type { ChartHoverCardProps } from "./chart-hover-card"; +import { ChartHoverCard } from "./chart-hover-card"; import styles from "./chart.module.scss"; import { quickSelectWindowToMilliseconds, @@ -44,25 +49,41 @@ type Props = { }; export const Chart = ({ symbol, feedId }: Props) => { - const chartContainerRef = useChart(symbol, feedId); + const { current: livePriceData } = useLivePriceData(Cluster.Pythnet, feedId); + const priceFormatter = usePriceFormatter(livePriceData?.exponent, { + subscriptZeros: false, + }); - return ( -
+ const { chartContainerRef, chartRef } = useChartElem( + symbol, + livePriceData, + priceFormatter, + ); + const { hoverCardRef, hoverCardData } = useChartHoverCard( + chartRef, + chartContainerRef, + priceFormatter, ); -}; - -const useChart = (symbol: string, feedId: string) => { - const { chartContainerRef, chartRef } = useChartElem(symbol, feedId); useChartResize(chartContainerRef, chartRef); useChartColors(chartContainerRef, chartRef); - return chartContainerRef; + + return ( + <> +
+ + + ); }; -const useChartElem = (symbol: string, feedId: string) => { +const useChartElem = ( + symbol: string, + livePriceData: PriceData | undefined, + priceFormatter: ReturnType, +) => { const logger = useLogger(); const [quickSelectWindow] = useChartQuickSelectWindow(); const [resolution] = useChartResolution(); @@ -77,11 +98,6 @@ const useChartElem = (symbol: string, feedId: string) => { // appropriate times. const whitespaceData = useRef>(new Set()); - const { current: livePriceData } = useLivePriceData(Cluster.Pythnet, feedId); - const priceFormatter = usePriceFormatter(livePriceData?.exponent, { - subscriptZeros: false, - }); - const didResetVisibleRange = useRef(false); const didLoadInitialData = useRef(false); @@ -264,6 +280,7 @@ const useChartElem = (symbol: string, feedId: string) => { if (chartRef.current) { chartRef.current.chart.remove(); } + const chart = createChart(chartElem, { layout: { attributionLogo: false, @@ -283,6 +300,7 @@ const useChartElem = (symbol: string, feedId: string) => { }, localization: { priceFormatter: priceFormatter.format, + dateFormat: "dd MMM yy,", }, }); @@ -445,6 +463,129 @@ const useChartColors = ( }, [resolvedTheme, chartRef, chartContainerRef]); }; +const useChartHoverCard = ( + chartRef: RefObject, + chartContainerRef: RefObject, + priceFormatter: ReturnType, +) => { + const dateFormatter = useDateFormatter({ + year: "2-digit", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: "UTC", + }); + + const hoverCardRef = useRef(null); + const hoverCardElement = hoverCardRef.current; + + const containerElement = chartContainerRef.current; + + const [hoverCardData, setHoverCardData] = useState< + | (ChartHoverCardProps & { + style: { + left: string; + top: string; + }; + }) + | undefined + >(undefined); + + useEffect(() => { + const chartData = chartRef.current; + + if (!chartData || !containerElement || !hoverCardElement) { + return; + } + + const { chart, price, confidenceHigh, confidenceLow } = chartData; + + const handleCrosshairMove: Parameters< + typeof chart.subscribeCrosshairMove + >[0] = (param) => { + const priceData = param.seriesData.get(price); + const confidenceHighData = param.seriesData.get(confidenceHigh); + const confidenceLowData = param.seriesData.get(confidenceLow); + + const hasPrice = priceData && "value" in priceData; + + if ( + isNullOrUndefined(param.point) || + isNullOrUndefined(param.time) || + !hasPrice + ) { + setHoverCardData(undefined); + return; + } + + const priceValue = priceData.value; + const timestampValue = new Date(Number(param.time) * 1000); + const formattedTimestamp = dateFormatter.format(timestampValue); + const formattedPrice = priceFormatter.format(priceValue); + + let formattedConfidence = "N/A"; + if ( + confidenceHighData && + "value" in confidenceHighData && + confidenceLowData && + "value" in confidenceLowData + ) { + formattedConfidence = `±${priceFormatter.format(confidenceHighData.value - priceValue)}`; + } + + const hoverCardRect = hoverCardElement.getBoundingClientRect(); + const hoverCardMargin = 20; + const x = param.point.x; + + const left = Math.max( + 0, + Math.min( + chartData.chart.options().width - hoverCardRect.width, + x - hoverCardRect.width / 2, + ), + ); + + const coordinate = price.priceToCoordinate(priceValue); + if (coordinate === null) { + setHoverCardData(undefined); + return; + } + const top = Math.max( + 0, + coordinate - hoverCardRect.height - hoverCardMargin, + ); + + setHoverCardData({ + timestamp: formattedTimestamp, + price: formattedPrice, + confidence: formattedConfidence, + style: { + left: `${String(left)}px`, + top: `${String(top)}px`, + display: "block", + }, + }); + }; + + chart.subscribeCrosshairMove(handleCrosshairMove); + + return () => { + chart.unsubscribeCrosshairMove(handleCrosshairMove); + }; + }, [ + priceFormatter, + containerElement, + dateFormatter, + chartRef, + hoverCardElement, + ]); + + return { hoverCardRef, hoverCardData }; +}; + const applyColors = ( { chart, ...series }: ChartRefContents, container: HTMLDivElement,