"use client";
import { useLogger } from "@pythnetwork/app-logger";
import { useResizeObserver } from "@react-hookz/web";
import {
type IChartApi,
type ISeriesApi,
type UTCTimestamp,
LineStyle,
createChart,
} from "lightweight-charts";
import { useTheme } from "next-themes";
import { type RefObject, useEffect, useRef, useCallback } from "react";
import { z } from "zod";
import theme from "./theme.module.scss";
import { useLivePriceData } from "../../hooks/use-live-price-data";
type Props = {
symbol: string;
feedId: string;
};
export const Chart = ({ symbol, feedId }: Props) => {
const chartContainerRef = useChart(symbol, feedId);
return (
);
};
const useChart = (symbol: string, feedId: string) => {
const { chartContainerRef, chartRef } = useChartElem(symbol, feedId);
useChartResize(chartContainerRef, chartRef);
useChartColors(chartRef);
return chartContainerRef;
};
const useChartElem = (symbol: string, feedId: string) => {
const logger = useLogger();
const { current } = useLivePriceData(feedId);
const chartContainerRef = useRef(null);
const chartRef = useRef(undefined);
const earliestDateRef = useRef(undefined);
const isBackfilling = useRef(false);
const backfillData = useCallback(() => {
if (!isBackfilling.current && earliestDateRef.current) {
isBackfilling.current = true;
const url = new URL("/historical-prices", window.location.origin);
url.searchParams.set("symbol", symbol);
url.searchParams.set("until", earliestDateRef.current.toString());
fetch(url)
.then(async (data) => historicalDataSchema.parse(await data.json()))
.then((data) => {
const firstPoint = data[0];
if (firstPoint) {
earliestDateRef.current = BigInt(firstPoint.timestamp);
}
if (
chartRef.current &&
chartRef.current.resolution === Resolution.Tick
) {
const convertedData = data.map(
({ timestamp, price, confidence }) => ({
time: getLocalTimestamp(new Date(timestamp * 1000)),
price,
confidence,
}),
);
chartRef.current.price.setData([
...convertedData.map(({ time, price }) => ({
time,
value: price,
})),
...chartRef.current.price.data(),
]);
chartRef.current.confidenceHigh.setData([
...convertedData.map(({ time, price, confidence }) => ({
time,
value: price + confidence,
})),
...chartRef.current.confidenceHigh.data(),
]);
chartRef.current.confidenceLow.setData([
...convertedData.map(({ time, price, confidence }) => ({
time,
value: price - confidence,
})),
...chartRef.current.confidenceLow.data(),
]);
}
isBackfilling.current = false;
})
.catch((error: unknown) => {
logger.error("Error fetching historical prices", error);
});
}
}, [logger, symbol]);
useEffect(() => {
const chartElem = chartContainerRef.current;
if (chartElem === null) {
return;
} else {
const chart = createChart(chartElem, {
layout: {
attributionLogo: false,
background: { color: "transparent" },
},
timeScale: {
timeVisible: true,
secondsVisible: true,
},
});
const price = chart.addLineSeries({ priceFormat });
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (
range && // if (range.to - range.from > 1000) {
// console.log("DECREASE RESOLUTION");
// } else if (range.to - range.from < 100) {
// console.log("INCREASE RESOLUTION");
// } else if (range.from < 10) {
range.from < 10
) {
backfillData();
}
});
chartRef.current = {
resolution: Resolution.Tick,
chart,
confidenceHigh: chart.addLineSeries(confidenceConfig),
confidenceLow: chart.addLineSeries(confidenceConfig),
price,
};
return () => {
chart.remove();
};
}
}, [backfillData]);
useEffect(() => {
if (current && chartRef.current) {
if (!earliestDateRef.current) {
earliestDateRef.current = current.timestamp;
}
const { price, confidence } = current.aggregate;
const time = getLocalTimestamp(
new Date(Number(current.timestamp * 1000n)),
);
if (chartRef.current.resolution === Resolution.Tick) {
chartRef.current.price.update({ time, value: price });
chartRef.current.confidenceHigh.update({
time,
value: price + confidence,
});
chartRef.current.confidenceLow.update({
time,
value: price - confidence,
});
}
}
}, [current]);
return { chartRef, chartContainerRef };
};
enum Resolution {
Tick,
Minute,
Hour,
Day,
}
type ChartRefContents = {
chart: IChartApi;
} & (
| {
resolution: Resolution.Tick;
confidenceHigh: ISeriesApi<"Line">;
confidenceLow: ISeriesApi<"Line">;
price: ISeriesApi<"Line">;
}
| {
resolution: Exclude;
series: ISeriesApi<"Candlestick">;
}
);
const historicalDataSchema = z.array(
z.strictObject({
timestamp: z.number(),
price: z.number(),
confidence: z.number(),
}),
);
const priceFormat = {
type: "price",
precision: 5,
minMove: 0.000_01,
} as const;
const confidenceConfig = {
priceFormat,
lineStyle: LineStyle.Dashed,
lineWidth: 1,
} as const;
const useChartResize = (
chartContainerRef: RefObject,
chartRef: RefObject,
) => {
useResizeObserver(chartContainerRef.current, ({ contentRect }) => {
const { chart } = chartRef.current ?? {};
if (chart) {
chart.applyOptions({ width: contentRect.width });
}
});
};
const useChartColors = (chartRef: RefObject) => {
const { resolvedTheme } = useTheme();
useEffect(() => {
if (chartRef.current && resolvedTheme) {
applyColors(chartRef.current, resolvedTheme);
}
}, [resolvedTheme, chartRef]);
};
const applyColors = ({ chart, ...series }: ChartRefContents, theme: string) => {
const colors = getColors(theme);
chart.applyOptions({
grid: {
horzLines: {
color: colors.border,
},
vertLines: {
color: colors.border,
},
},
layout: {
textColor: colors.muted,
},
timeScale: {
borderColor: colors.muted,
},
rightPriceScale: {
borderColor: colors.muted,
},
});
if (series.resolution === Resolution.Tick) {
series.confidenceHigh.applyOptions({
color: colors.chartNeutral,
});
series.confidenceLow.applyOptions({
color: colors.chartNeutral,
});
series.price.applyOptions({
color: colors.chartPrimary,
});
}
};
const getColors = (resolvedTheme: string) => ({
border: theme[`border-${resolvedTheme}`] ?? "red",
muted: theme[`muted-${resolvedTheme}`] ?? "",
chartNeutral: theme[`chart-series-neutral-${resolvedTheme}`] ?? "",
chartPrimary: theme[`chart-series-primary-${resolvedTheme}`] ?? "",
});
const getLocalTimestamp = (date: Date): UTCTimestamp =>
(Date.UTC(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds(),
) / 1000) as UTCTimestamp;