Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className={clsx(className, styles.hoverCard)} {...props}>
<table className={styles.hoverCardTable}>
<tbody>
<tr>
<td colSpan={2}>{timestamp}</td>
</tr>
<tr>
<td>Price</td>
<td>{price}</td>
</tr>
{confidence && (
<tr>
<td>Confidence</td>
<td>{confidence}</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
height: theme.spacing(140);
border-radius: theme.border-radius("xl");
overflow: hidden;
position: relative;

.spinnerContainer {
width: 100%;
Expand Down
179 changes: 160 additions & 19 deletions apps/insights/src/components/PriceFeed/Chart/chart.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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 (
<div
style={{ width: "100%", height: "100%" }}
className={styles.chart}
ref={chartContainerRef}
/>
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 (
<>
<div
style={{ width: "100%", height: "100%" }}
className={styles.chart}
ref={chartContainerRef}
/>
<ChartHoverCard ref={hoverCardRef} {...hoverCardData} />
</>
);
};

const useChartElem = (symbol: string, feedId: string) => {
const useChartElem = (
symbol: string,
livePriceData: PriceData | undefined,
priceFormatter: ReturnType<typeof usePriceFormatter>,
) => {
const logger = useLogger();
const [quickSelectWindow] = useChartQuickSelectWindow();
const [resolution] = useChartResolution();
Expand All @@ -77,11 +98,6 @@ const useChartElem = (symbol: string, feedId: string) => {
// appropriate times.
const whitespaceData = useRef<Set<WhitespaceData>>(new Set());

const { current: livePriceData } = useLivePriceData(Cluster.Pythnet, feedId);
const priceFormatter = usePriceFormatter(livePriceData?.exponent, {
subscriptZeros: false,
});

const didResetVisibleRange = useRef(false);
const didLoadInitialData = useRef(false);

Expand Down Expand Up @@ -264,6 +280,7 @@ const useChartElem = (symbol: string, feedId: string) => {
if (chartRef.current) {
chartRef.current.chart.remove();
}

const chart = createChart(chartElem, {
layout: {
attributionLogo: false,
Expand All @@ -283,6 +300,7 @@ const useChartElem = (symbol: string, feedId: string) => {
},
localization: {
priceFormatter: priceFormatter.format,
dateFormat: "dd MMM yy,",
},
});

Expand Down Expand Up @@ -445,6 +463,129 @@ const useChartColors = (
}, [resolvedTheme, chartRef, chartContainerRef]);
};

const useChartHoverCard = (
chartRef: RefObject<ChartRefContents | undefined>,
chartContainerRef: RefObject<HTMLDivElement | null>,
priceFormatter: ReturnType<typeof usePriceFormatter>,
) => {
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<HTMLDivElement | null>(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,
Expand Down
Loading