diff --git a/src/app/components/PriceChart.tsx b/src/app/components/PriceChart.tsx index 39503a00..59d75da7 100644 --- a/src/app/components/PriceChart.tsx +++ b/src/app/components/PriceChart.tsx @@ -4,8 +4,14 @@ import { CANDLE_PERIODS, OHLCVData, setCandlePeriod, + handleCrosshairMove, + // fetchCandlesForInitialPeriod, + initializeLegend, } from "../redux/priceChartSlice"; import { useAppDispatch, useAppSelector } from "../hooks"; +import { formatPercentageChange } from "../utils"; +import { displayAmount } from "../utils"; +import * as tailwindConfig from "../../../tailwind.config"; interface PriceChartProps { data: OHLCVData[]; @@ -13,10 +19,18 @@ interface PriceChartProps { function PriceChartCanvas(props: PriceChartProps) { const chartContainerRef = useRef(null); + const dispatch = useAppDispatch(); const { data } = props; + const theme = tailwindConfig.daisyui.themes[0].dark; + useEffect(() => { const chartContainer = chartContainerRef.current; + // dispatch(fetchCandlesForInitialPeriod()); + if (data && data.length > 0) { + dispatch(initializeLegend()); + } + if (chartContainer) { const handleResize = () => { chart.applyOptions({ width: chartContainer.clientWidth }); @@ -24,10 +38,22 @@ function PriceChartCanvas(props: PriceChartProps) { const chart = createChart(chartContainer, { width: chartContainer.clientWidth, - height: 450, - - // TODO: timescale is not visible + height: 500, + //MODIFY THEME COLOR HERE + layout: { + background: { + color: theme["base-100"], + }, //base-100 + textColor: theme["primary-content"], + }, + //MODIFY THEME COLOR HERE + grid: { + vertLines: { color: theme["secondary-content"] }, + horzLines: { color: theme["secondary-content"] }, + }, timeScale: { + //MODIFY THEME COLOR HERE + borderColor: theme["primary-content"], timeVisible: true, }, }); @@ -37,30 +63,53 @@ function PriceChartCanvas(props: PriceChartProps) { // OHLC const ohlcSeries = chart.addCandlestickSeries({}); ohlcSeries.setData(clonedData); + + ohlcSeries.applyOptions({ + wickUpColor: theme["success"], //success + upColor: theme["success"], //success + wickDownColor: theme["error"], //error + downColor: theme["error"], //error + }); + chart.priceScale("right").applyOptions({ + //MODIFY THEME COLOR HERE + borderColor: theme["primary-content"], //primary-content scaleMargins: { - top: 0, - bottom: 0.2, + top: 0.1, + bottom: 0.3, }, }); - // Volume + // Volume Initialization const volumeSeries = chart.addHistogramSeries({ priceFormat: { type: "volume", }, priceScaleId: "volume", - color: "#eaeff5", }); - volumeSeries.setData(clonedData); + // VOLUME BARS + // MODIFY THEME COLOR HERE + volumeSeries.setData( + data.map((datum) => ({ + ...datum, + color: + datum.close - datum.open <= 0 ? theme["error"] : theme["success"], //error : success + })) + ); + + // volumeSeries.setData(clonedData); chart.priceScale("volume").applyOptions({ scaleMargins: { top: 0.8, - bottom: 0, + bottom: 0.01, }, }); + //Crosshair Data for legend + dispatch(handleCrosshairMove(chart, data, volumeSeries)); + + //Prevent Chart from clipping const chartDiv = chartContainer.querySelector(".tv-lightweight-charts"); if (chartDiv) { (chartDiv as HTMLElement).style.overflow = "visible"; @@ -70,37 +119,122 @@ function PriceChartCanvas(props: PriceChartProps) { return () => { window.removeEventListener("resize", handleResize); - // clearInterval(intervalId); chart.remove(); }; } - }, [data]); - - return
; + }, [data, dispatch]); + //Temporary brute force approach to trim the top of the chart to remove the gap + return
; } export function PriceChart() { const state = useAppSelector((state) => state.priceChart); const dispatch = useAppDispatch(); + const candlePeriod = useAppSelector((state) => state.priceChart.candlePeriod); + const candlePrice = useAppSelector( + (state) => state.priceChart.legendCandlePrice + ); + const change = useAppSelector((state) => state.priceChart.legendChange); + const percChange = useAppSelector( + (state) => state.priceChart.legendPercChange + ); + const currentVolume = useAppSelector( + (state) => state.priceChart.legendCurrentVolume + ); + const isNegativeOrZero = useAppSelector( + (state) => state.priceChart.isNegativeOrZero + ); + const noDigits = 4; + const decimalSeparator = "."; + const thousandSeparator = ","; + const fixedDecimals = 3; return (
- - - +
+
+ {CANDLE_PERIODS.map((period) => ( + + ))} +
+
+
+ Open:{" "} + + {displayAmount( + candlePrice?.open || 0, + noDigits, + decimalSeparator, + thousandSeparator, + fixedDecimals + )} + +
+
+ High:{" "} + + {displayAmount( + candlePrice?.high || 0, + noDigits, + decimalSeparator, + thousandSeparator, + fixedDecimals + )} + +
+
+ Low:{" "} + + {displayAmount( + candlePrice?.low || 0, + noDigits, + decimalSeparator, + thousandSeparator, + fixedDecimals + )} + +
+
+ Close:{" "} + + {displayAmount( + candlePrice?.close || 0, + noDigits, + decimalSeparator, + thousandSeparator, + fixedDecimals + )} + +
+
+ Volume:{" "} + + {displayAmount( + currentVolume, + noDigits, + decimalSeparator, + thousandSeparator, + fixedDecimals + )} + +
+
+ Change:{" "} + + {change} + {formatPercentageChange(percChange)} + +
+
+
); diff --git a/src/app/page.tsx b/src/app/page.tsx index fefdf0a8..c2df5ed0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,7 +23,7 @@ export default function Home() {
-
+
diff --git a/src/app/redux/priceChartSlice.ts b/src/app/redux/priceChartSlice.ts index be673956..6fdd9982 100644 --- a/src/app/redux/priceChartSlice.ts +++ b/src/app/redux/priceChartSlice.ts @@ -1,6 +1,14 @@ import * as adex from "alphadex-sdk-js"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import { CandlestickData, UTCTimestamp } from "lightweight-charts"; +import { + CandlestickData, + IChartApi, + UTCTimestamp, + ISeriesApi, + SeriesOptionsMap, +} from "lightweight-charts"; +import { AppDispatch } from "./store"; + export interface OHLCVData extends CandlestickData { value: number; } @@ -10,11 +18,21 @@ export const CANDLE_PERIODS = adex.CandlePeriods; export interface PriceChartState { candlePeriod: string; ohlcv: OHLCVData[]; + legendCandlePrice: OHLCVData | null; + legendChange: number | null; + legendPercChange: number | null; + legendCurrentVolume: number; + isNegativeOrZero: boolean; } const initialState: PriceChartState = { - candlePeriod: adex.CandlePeriods[0], + candlePeriod: adex.CandlePeriods[2], ohlcv: [], + legendCandlePrice: null, + legendPercChange: null, + legendChange: null, + legendCurrentVolume: 0, + isNegativeOrZero: false, }; function cleanData(data: OHLCVData[]): OHLCVData[] { @@ -39,6 +57,37 @@ function cleanData(data: OHLCVData[]): OHLCVData[] { return cleanedData; } +//Chart Crosshair +export function handleCrosshairMove( + chart: IChartApi, + data: OHLCVData[], + volumeSeries: ISeriesApi +) { + return (dispatch: AppDispatch) => { + chart.subscribeCrosshairMove((param) => { + if (param.time) { + const currentIndex = data.findIndex( + (candle) => candle.time === param.time + ); + + if (currentIndex > 0 && currentIndex < data.length) { + const currentData = data[currentIndex]; + const volumeData = param.seriesData.get(volumeSeries) as OHLCVData; + dispatch(setLegendChange(currentData)); + dispatch(setLegendCandlePrice(currentData)); + dispatch( + setLegendPercChange({ + currentOpen: currentData.open, + currentClose: currentData.close, + }) + ); + dispatch(setLegendCurrentVolume(volumeData ? volumeData.value : 0)); + } + } + }); + }; +} + function convertAlphaDEXData(data: adex.Candle[]): OHLCVData[] { let tradingViewData = data.map((row): OHLCVData => { const time = (new Date(row.startTime).getTime() / 1000) as UTCTimestamp; @@ -65,7 +114,68 @@ export const priceChartSlice = createSlice({ updateCandles: (state, action: PayloadAction) => { state.ohlcv = convertAlphaDEXData(action.payload); }, + setLegendCandlePrice: (state, action: PayloadAction) => { + state.legendCandlePrice = action.payload; + if (action.payload) { + state.isNegativeOrZero = + action.payload.close - action.payload.open <= 0; + } + }, + setLegendChange: (state, action: PayloadAction) => { + if (action.payload) { + const difference = action.payload.close - action.payload.open; + state.legendChange = difference; + } else { + state.legendChange = null; + } + }, + setLegendPercChange: ( + state, + action: PayloadAction<{ currentOpen: number; currentClose: number }> + ) => { + const { currentOpen, currentClose } = action.payload; + if (currentOpen !== null && currentClose !== null) { + const difference = currentClose - currentOpen; + let percentageChange = (difference / currentOpen) * 100; + + if (Math.abs(percentageChange) < 0.01) { + percentageChange = 0; + } + + state.legendPercChange = parseFloat(percentageChange.toFixed(2)); + } else { + state.legendPercChange = null; + } + }, + initializeLegend: (state) => { + if (state.ohlcv && state.ohlcv.length > 0) { + const latestOHLCVData = state.ohlcv[state.ohlcv.length - 1]; + state.legendCandlePrice = latestOHLCVData; + state.legendChange = latestOHLCVData.close - latestOHLCVData.open; + state.legendPercChange = parseFloat( + ( + ((latestOHLCVData.close - latestOHLCVData.open) / + latestOHLCVData.open) * + 100 + ).toFixed(2) + ); + state.legendCurrentVolume = latestOHLCVData.value; + state.isNegativeOrZero = + latestOHLCVData.close - latestOHLCVData.open <= 0; + } + }, + setLegendCurrentVolume: (state, action: PayloadAction) => { + state.legendCurrentVolume = action.payload; + }, }, }); -export const { setCandlePeriod, updateCandles } = priceChartSlice.actions; +export const { + setCandlePeriod, + updateCandles, + setLegendCandlePrice, + setLegendChange, + setLegendPercChange, + setLegendCurrentVolume, + initializeLegend, +} = priceChartSlice.actions; diff --git a/src/app/utils.ts b/src/app/utils.ts index 7d23d44a..8287c350 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -293,3 +293,11 @@ export function calculateTotalFees(order: any): number { ? roundTo(totalFees, 4, RoundType.NEAREST) : totalFees; } + +//Chart Helper Functions +export const formatPercentageChange = (percChange: number | null): string => { + if (percChange !== null) { + return `(${percChange.toFixed(2)}%)`; + } + return "(0.00%)"; +};