chart.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. "use client";
  2. import { useLogger } from "@pythnetwork/app-logger";
  3. import { useResizeObserver } from "@react-hookz/web";
  4. import {
  5. type IChartApi,
  6. type ISeriesApi,
  7. type UTCTimestamp,
  8. LineStyle,
  9. createChart,
  10. } from "lightweight-charts";
  11. import { useTheme } from "next-themes";
  12. import { type RefObject, useEffect, useRef, useCallback } from "react";
  13. import { z } from "zod";
  14. import theme from "./theme.module.scss";
  15. import { useLivePriceData } from "../../hooks/use-live-price-data";
  16. type Props = {
  17. symbol: string;
  18. feedId: string;
  19. };
  20. export const Chart = ({ symbol, feedId }: Props) => {
  21. const chartContainerRef = useChart(symbol, feedId);
  22. return (
  23. <div style={{ width: "100%", height: "100%" }} ref={chartContainerRef} />
  24. );
  25. };
  26. const useChart = (symbol: string, feedId: string) => {
  27. const { chartContainerRef, chartRef } = useChartElem(symbol, feedId);
  28. useChartResize(chartContainerRef, chartRef);
  29. useChartColors(chartRef);
  30. return chartContainerRef;
  31. };
  32. const useChartElem = (symbol: string, feedId: string) => {
  33. const logger = useLogger();
  34. const { current } = useLivePriceData(feedId);
  35. const chartContainerRef = useRef<HTMLDivElement | null>(null);
  36. const chartRef = useRef<ChartRefContents | undefined>(undefined);
  37. const earliestDateRef = useRef<bigint | undefined>(undefined);
  38. const isBackfilling = useRef(false);
  39. const backfillData = useCallback(() => {
  40. if (!isBackfilling.current && earliestDateRef.current) {
  41. isBackfilling.current = true;
  42. const url = new URL("/historical-prices", window.location.origin);
  43. url.searchParams.set("symbol", symbol);
  44. url.searchParams.set("until", earliestDateRef.current.toString());
  45. fetch(url)
  46. .then(async (data) => historicalDataSchema.parse(await data.json()))
  47. .then((data) => {
  48. const firstPoint = data[0];
  49. if (firstPoint) {
  50. earliestDateRef.current = BigInt(firstPoint.timestamp);
  51. }
  52. if (
  53. chartRef.current &&
  54. chartRef.current.resolution === Resolution.Tick
  55. ) {
  56. const convertedData = data.map(
  57. ({ timestamp, price, confidence }) => ({
  58. time: getLocalTimestamp(new Date(timestamp * 1000)),
  59. price,
  60. confidence,
  61. }),
  62. );
  63. chartRef.current.price.setData([
  64. ...convertedData.map(({ time, price }) => ({
  65. time,
  66. value: price,
  67. })),
  68. ...chartRef.current.price.data(),
  69. ]);
  70. chartRef.current.confidenceHigh.setData([
  71. ...convertedData.map(({ time, price, confidence }) => ({
  72. time,
  73. value: price + confidence,
  74. })),
  75. ...chartRef.current.confidenceHigh.data(),
  76. ]);
  77. chartRef.current.confidenceLow.setData([
  78. ...convertedData.map(({ time, price, confidence }) => ({
  79. time,
  80. value: price - confidence,
  81. })),
  82. ...chartRef.current.confidenceLow.data(),
  83. ]);
  84. }
  85. isBackfilling.current = false;
  86. })
  87. .catch((error: unknown) => {
  88. logger.error("Error fetching historical prices", error);
  89. });
  90. }
  91. }, [logger, symbol]);
  92. useEffect(() => {
  93. const chartElem = chartContainerRef.current;
  94. if (chartElem === null) {
  95. return;
  96. } else {
  97. const chart = createChart(chartElem, {
  98. layout: {
  99. attributionLogo: false,
  100. background: { color: "transparent" },
  101. },
  102. timeScale: {
  103. timeVisible: true,
  104. secondsVisible: true,
  105. },
  106. });
  107. const price = chart.addLineSeries({ priceFormat });
  108. chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
  109. if (
  110. range && // if (range.to - range.from > 1000) {
  111. // console.log("DECREASE RESOLUTION");
  112. // } else if (range.to - range.from < 100) {
  113. // console.log("INCREASE RESOLUTION");
  114. // } else if (range.from < 10) {
  115. range.from < 10
  116. ) {
  117. backfillData();
  118. }
  119. });
  120. chartRef.current = {
  121. resolution: Resolution.Tick,
  122. chart,
  123. confidenceHigh: chart.addLineSeries(confidenceConfig),
  124. confidenceLow: chart.addLineSeries(confidenceConfig),
  125. price,
  126. };
  127. return () => {
  128. chart.remove();
  129. };
  130. }
  131. }, [backfillData]);
  132. useEffect(() => {
  133. if (current && chartRef.current) {
  134. if (!earliestDateRef.current) {
  135. earliestDateRef.current = current.timestamp;
  136. }
  137. const { price, confidence } = current.aggregate;
  138. const time = getLocalTimestamp(
  139. new Date(Number(current.timestamp * 1000n)),
  140. );
  141. if (chartRef.current.resolution === Resolution.Tick) {
  142. chartRef.current.price.update({ time, value: price });
  143. chartRef.current.confidenceHigh.update({
  144. time,
  145. value: price + confidence,
  146. });
  147. chartRef.current.confidenceLow.update({
  148. time,
  149. value: price - confidence,
  150. });
  151. }
  152. }
  153. }, [current]);
  154. return { chartRef, chartContainerRef };
  155. };
  156. enum Resolution {
  157. Tick,
  158. Minute,
  159. Hour,
  160. Day,
  161. }
  162. type ChartRefContents = {
  163. chart: IChartApi;
  164. } & (
  165. | {
  166. resolution: Resolution.Tick;
  167. confidenceHigh: ISeriesApi<"Line">;
  168. confidenceLow: ISeriesApi<"Line">;
  169. price: ISeriesApi<"Line">;
  170. }
  171. | {
  172. resolution: Exclude<Resolution, Resolution.Tick>;
  173. series: ISeriesApi<"Candlestick">;
  174. }
  175. );
  176. const historicalDataSchema = z.array(
  177. z.strictObject({
  178. timestamp: z.number(),
  179. price: z.number(),
  180. confidence: z.number(),
  181. }),
  182. );
  183. const priceFormat = {
  184. type: "price",
  185. precision: 5,
  186. minMove: 0.000_01,
  187. } as const;
  188. const confidenceConfig = {
  189. priceFormat,
  190. lineStyle: LineStyle.Dashed,
  191. lineWidth: 1,
  192. } as const;
  193. const useChartResize = (
  194. chartContainerRef: RefObject<HTMLDivElement | null>,
  195. chartRef: RefObject<ChartRefContents | undefined>,
  196. ) => {
  197. useResizeObserver(chartContainerRef.current, ({ contentRect }) => {
  198. const { chart } = chartRef.current ?? {};
  199. if (chart) {
  200. chart.applyOptions({ width: contentRect.width });
  201. }
  202. });
  203. };
  204. const useChartColors = (chartRef: RefObject<ChartRefContents | undefined>) => {
  205. const { resolvedTheme } = useTheme();
  206. useEffect(() => {
  207. if (chartRef.current && resolvedTheme) {
  208. applyColors(chartRef.current, resolvedTheme);
  209. }
  210. }, [resolvedTheme, chartRef]);
  211. };
  212. const applyColors = ({ chart, ...series }: ChartRefContents, theme: string) => {
  213. const colors = getColors(theme);
  214. chart.applyOptions({
  215. grid: {
  216. horzLines: {
  217. color: colors.border,
  218. },
  219. vertLines: {
  220. color: colors.border,
  221. },
  222. },
  223. layout: {
  224. textColor: colors.muted,
  225. },
  226. timeScale: {
  227. borderColor: colors.muted,
  228. },
  229. rightPriceScale: {
  230. borderColor: colors.muted,
  231. },
  232. });
  233. if (series.resolution === Resolution.Tick) {
  234. series.confidenceHigh.applyOptions({
  235. color: colors.chartNeutral,
  236. });
  237. series.confidenceLow.applyOptions({
  238. color: colors.chartNeutral,
  239. });
  240. series.price.applyOptions({
  241. color: colors.chartPrimary,
  242. });
  243. }
  244. };
  245. const getColors = (resolvedTheme: string) => ({
  246. border: theme[`border-${resolvedTheme}`] ?? "red",
  247. muted: theme[`muted-${resolvedTheme}`] ?? "",
  248. chartNeutral: theme[`chart-series-neutral-${resolvedTheme}`] ?? "",
  249. chartPrimary: theme[`chart-series-primary-${resolvedTheme}`] ?? "",
  250. });
  251. const getLocalTimestamp = (date: Date): UTCTimestamp =>
  252. (Date.UTC(
  253. date.getFullYear(),
  254. date.getMonth(),
  255. date.getDate(),
  256. date.getHours(),
  257. date.getMinutes(),
  258. date.getSeconds(),
  259. date.getMilliseconds(),
  260. ) / 1000) as UTCTimestamp;