diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 98ebda9..c127e66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,9 @@ jobs: echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" >> .env echo "VITE_STOMP_URL=${{ secrets.VITE_STOMP_URL }}" >> .env echo "VITE_AI_URL=${{ secrets.VITE_AI_URL }}" >> .env + echo "VITE_SENTRY_ORG=${{ secrets.VITE_SENTRY_ORG }}" >> .env + echo "VITE_SENTRY_PROJECT=${{ secrets.VITE_SENTRY_PROJECT }}" >> .env + echo "VITE_SENTRY_AUTH_TOKEN=${{ secrets.VITE_SENTRY_AUTH_TOKEN }}" >> .env - name: Build image run: | diff --git a/instrument.server.mjs b/instrument.server.mjs new file mode 100644 index 0000000..60fba0e --- /dev/null +++ b/instrument.server.mjs @@ -0,0 +1,13 @@ +import { nodeProfilingIntegration } from '@sentry/profiling-node'; +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + dsn: 'https://8343c6ee467e6f35f22c570a68cd2e6e@o4509544992407552.ingest.us.sentry.io/4509548888391680', + sendDefaultPii: true, + // Enable logs to be sent to Sentry + _experiments: { enableLogs: true }, + + integrations: [nodeProfilingIntegration()], + tracesSampleRate: 1.0, // Capture 100% of the transactions + profilesSampleRate: 1.0, // profile every transaction +}); diff --git a/package.json b/package.json index 6432c70..962aaaf 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "react-router dev", - "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router dev", + "build": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", "test": "vitest", @@ -21,12 +21,17 @@ "@react-router/fs-routes": "^7.5.3", "@react-router/node": "^7.5.3", "@react-router/serve": "^7.5.3", + "@sentry/profiling-node": "^9.30.0", + "@sentry/react-router": "^9.30.0", "@stomp/stompjs": "^7.1.1", "@xstate/react": "^5.0.4", "clsx": "^2.1.1", "cookie": "^1.0.2", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "isbot": "^5", "ky": "^1.8.1", + "lightweight-charts": "^5.0.7", "motion": "^12.12.1", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -63,8 +68,6 @@ }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "msw": { - "workerDirectory": [ - "public" - ] + "workerDirectory": ["public"] } } diff --git a/react-router.config.ts b/react-router.config.ts index 8307d6f..55fd0f7 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -1,6 +1,10 @@ import type { Config } from '@react-router/dev/config'; +import { sentryOnBuildEnd } from '@sentry/react-router'; export default { appDirectory: './src/app', ssr: true, + buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => { + await sentryOnBuildEnd({viteConfig, reactRouterConfig, buildManifest}); + }, } satisfies Config; diff --git a/src/app/entry.client.tsx b/src/app/entry.client.tsx index ee80c88..2ed6ee5 100644 --- a/src/app/entry.client.tsx +++ b/src/app/entry.client.tsx @@ -1,20 +1,41 @@ +import * as Sentry from '@sentry/react-router'; /* v8 ignore start */ -import { StrictMode, startTransition } from 'react'; +import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; +Sentry.init({ + dsn: 'https://8343c6ee467e6f35f22c570a68cd2e6e@o4509544992407552.ingest.us.sentry.io/4509548888391680', + + sendDefaultPii: true, + + integrations: [ + Sentry.reactRouterTracingIntegration(), + Sentry.replayIntegration(), + Sentry.feedbackIntegration({ + colorScheme: 'system', + }), + ], + + _experiments: { enableLogs: true }, + + tracesSampleRate: 1.0, + + // Set `tracePropagationTargets` to declare which URL(s) should have trace propagation enabled + tracePropagationTargets: [/^\//, /^https:\/\/investfuture\.my\/api/], + // Capture Replay for 10% of all sessions, + // plus 100% of sessions with an error + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +}); + async function prepareApp() { return Promise.resolve(); } prepareApp().then(() => { startTransition(() => { - hydrateRoot( - document, - - - , - ); + hydrateRoot(document, ); }); }); diff --git a/src/app/entry.server.tsx b/src/app/entry.server.tsx index 816b65b..1a6c146 100644 --- a/src/app/entry.server.tsx +++ b/src/app/entry.server.tsx @@ -2,6 +2,10 @@ import { PassThrough } from 'node:stream'; import { createReadableStreamFromReadable } from '@react-router/node'; +import { + getMetaTagTransformer, + wrapSentryHandleRequest, +} from '@sentry/react-router'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; @@ -10,7 +14,7 @@ import { ServerRouter } from 'react-router'; export const streamTimeout = 5_000; -export default function handleRequest( +function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, @@ -47,7 +51,7 @@ export default function handleRequest( }), ); - pipe(body); + pipe(getMetaTagTransformer(body)); }, onShellError(error: unknown) { reject(error); @@ -71,4 +75,5 @@ export default function handleRequest( }); } +export default wrapSentryHandleRequest(handleRequest); /* v8 ignore end */ diff --git a/src/app/root.tsx b/src/app/root.tsx index 4e77d50..eec2f94 100644 --- a/src/app/root.tsx +++ b/src/app/root.tsx @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/react-router'; +import { preload } from 'react-dom'; import { Links, Meta, @@ -6,12 +8,11 @@ import { ScrollRestoration, isRouteErrorResponse, } from 'react-router'; +import { Slide } from 'react-toastify'; import { ToastContainer } from 'react-toastify/unstyled'; -import type { Route } from './+types/root'; import './app.css'; -import { preload } from 'react-dom'; -import { Slide } from 'react-toastify'; +import type { Route } from './+types/root'; import StompProvider from './provider/StompProvider'; import UserIdProvider from './provider/UserInfoProvider'; @@ -121,9 +122,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; - } else if (import.meta.env.DEV && error && error instanceof Error) { - details = error.message; - stack = error.stack; + } else if (error && error instanceof Error) { + Sentry.captureException(error); + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } } return ( diff --git a/src/app/routes/trade.$ticker.tsx b/src/app/routes/trade.$ticker.tsx index eb79baa..7aed917 100644 --- a/src/app/routes/trade.$ticker.tsx +++ b/src/app/routes/trade.$ticker.tsx @@ -1,6 +1,6 @@ import * as cookie from 'cookie'; import { AnimatePresence } from 'motion/react'; -import { Suspense, lazy, useState } from 'react'; +import { Suspense, lazy, useMemo, useState } from 'react'; import { Outlet, redirect } from 'react-router'; import { CoinPriceWithName, api as coinApi } from '~/entities/coin'; @@ -49,11 +49,16 @@ export default function TradeRouteComponent({ useTradeNotification(userId ?? 0); const [isMenuOpen, setIsMenuOpen] = useState(false); const { coinInfo, coinList, isLoggedIn } = loaderData; - const coinListWithIcon = coinList.map((coinInfo) => ({ - ...coinInfo, - coinIcon: ๐Ÿช™, - to: `/trade/${coinInfo.ticker}`, - })); + const coinListWithIcon = useMemo( + () => + coinList.map((coinInfo) => ({ + ...coinInfo, + coinIcon: ๐Ÿช™, + to: `/trade/${coinInfo.ticker}`, + onClick: () => setIsMenuOpen(false), + })), + [coinList], + ); const handleOpenMenu = () => { setIsMenuOpen(true); @@ -82,12 +87,7 @@ export default function TradeRouteComponent({ ์‹ค์‹œ๊ฐ„ ์ฐจํŠธ - {coinInfo && ( - - )} + diff --git a/src/features/coin-search-list/ui/CoinListItem/index.tsx b/src/features/coin-search-list/ui/CoinListItem/index.tsx index c4c2c48..d29164e 100644 --- a/src/features/coin-search-list/ui/CoinListItem/index.tsx +++ b/src/features/coin-search-list/ui/CoinListItem/index.tsx @@ -1,4 +1,4 @@ -import { Link, type LinkProps } from 'react-router'; +import { type LinkProps, useNavigate } from 'react-router'; import { CoinWithIconAndName, @@ -9,6 +9,7 @@ import { formatCurrencyKR } from '~/shared/utils'; export type CoinListItemProps = { to: LinkProps['to']; + onClick?: () => void; } & CoinWithIconAndNameProps; export default function CoinListItem({ @@ -16,41 +17,43 @@ export default function CoinListItem({ ticker, coinIcon: CoinIcon, to, + onClick, }: Readonly) { + const navigate = useNavigate(); const currentPriceData = useCurrentPrice(ticker); const isBull = currentPriceData && currentPriceData.changeRate > 0; const formatedPrice = `${formatCurrencyKR( +(currentPriceData?.currentPrice ?? 0).toFixed(2), )}์›`; + const handleClickCoinItem = async () => { + onClick?.(); + await navigate(to); + }; + return ( - - - + ); } diff --git a/src/features/tradeview/api/tradeview.endpoints.ts b/src/features/tradeview/api/tradeview.endpoints.ts index b47f032..3ffb6c2 100644 --- a/src/features/tradeview/api/tradeview.endpoints.ts +++ b/src/features/tradeview/api/tradeview.endpoints.ts @@ -1,10 +1,17 @@ /* v8 ignore start */ import ApiClient from '~/shared/api/httpClient'; -import type { RowData } from '../types/tradeview.type'; +import type { RawData } from '../types/tradeview.type'; export default { - getPastData: async (ticker = 'TRUMP') => { - return await ApiClient.get(`api/minute-ohlc?ticker=${ticker}`); + getPastData: async ( + ticker = 'TRUMP', + interval = 1, + count = 100, + from?: string, + ) => { + return await ApiClient.get( + `api/minute-ohlc?ticker=${ticker}&count=${count}&interval=${interval}${from ? `&from=${from}` : ''}`, + ); }, }; /* v8 ignore end */ diff --git a/src/features/tradeview/const/chart.const.ts b/src/features/tradeview/const/chart.const.ts new file mode 100644 index 0000000..98813a9 --- /dev/null +++ b/src/features/tradeview/const/chart.const.ts @@ -0,0 +1,9 @@ +export const INTERVALS = [ + { interval: 1, text: '1๋ถ„' }, + { interval: 3, text: '3๋ถ„' }, + { interval: 5, text: '5๋ถ„' }, + { interval: 15, text: '15๋ถ„' }, + { interval: 30, text: '30๋ถ„' }, +]; + +export const INTERVAL_SELECTOR_HEIGHT = 26; diff --git a/src/features/tradeview/hooks/usePastTimeData.tsx b/src/features/tradeview/hooks/usePastTimeData.tsx index 0e52f76..66bfb2f 100644 --- a/src/features/tradeview/hooks/usePastTimeData.tsx +++ b/src/features/tradeview/hooks/usePastTimeData.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; - -import type { CoinTicker } from '~/entities/coin'; import api from '../api/tradeview.endpoints'; -import type { CandlestickData } from '../types/tradeview.type'; +import type { RawData } from '../types/tradeview.type'; export type UpbitCandle = { market: string; @@ -18,24 +16,20 @@ export type UpbitCandle = { unit: number; }; -export default function usePastTimeData(ticker: CoinTicker) { - const [pastTimeData, setPastTimeData] = useState([]); +export default function usePastTimeData( + ticker = 'BTC', + interval = 1, + count = 100, + from?: string, +) { + const [pastTimeData, setPastTimeData] = useState([]); const fetchData = useCallback(async () => { - const response = await api.getPastData(ticker); + const response = await api.getPastData(ticker, interval, count, from); const data = await response.json(); - setPastTimeData( - data.map((datum) => ({ - Timestamp: Date.parse(datum.timestamp), - Close: Number.parseFloat(datum.close), - High: Number.parseFloat(datum.high), - Low: Number.parseFloat(datum.low), - Open: Number.parseFloat(datum.open), - Volume: Number.parseFloat(datum.volume), - })), - ); - }, [ticker]); + setPastTimeData(data); + }, [ticker, interval, count, from]); useEffect(() => { fetchData(); diff --git a/src/features/tradeview/hooks/useRealTimeData.tsx b/src/features/tradeview/hooks/useRealTimeData.tsx index 1b100e7..d2cfb0e 100644 --- a/src/features/tradeview/hooks/useRealTimeData.tsx +++ b/src/features/tradeview/hooks/useRealTimeData.tsx @@ -1,8 +1,10 @@ +import type { Time } from 'lightweight-charts'; import { useEffect, useState } from 'react'; import { useStompClient } from '~/app/provider/StompProvider'; import type { CoinTicker } from '~/entities/coin'; -import type { CandlestickData, RowData } from '../types/tradeview.type'; +import type { CandlestickData, RawData } from '../types/tradeview.type'; +import { timeToKrTz } from '../utils'; export default function useRealTimeData(ticker: CoinTicker) { const { client, connected } = useStompClient(); @@ -19,14 +21,14 @@ export default function useRealTimeData(ticker: CoinTicker) { const subscription = client.subscribe( `/topic/realTimeOhlc/${ticker}`, (message) => { - const parsedData = JSON.parse(message.body) as RowData; + const parsedData = JSON.parse(message.body) as RawData; + const parsedTime = timeToKrTz(parsedData.timestamp, 'Asia/Seoul'); setData({ - Timestamp: Date.parse(parsedData.timestamp), - Close: Number.parseFloat(parsedData.close), - High: Number.parseFloat(parsedData.high), - Low: Number.parseFloat(parsedData.low), - Open: Number.parseFloat(parsedData.open), - Volume: Number.parseFloat(parsedData.volume), + time: parsedTime as Time, + close: Number.parseFloat(parsedData.close), + high: Number.parseFloat(parsedData.high), + low: Number.parseFloat(parsedData.low), + open: Number.parseFloat(parsedData.open), }); }, ); diff --git a/src/features/tradeview/types/chart.type.ts b/src/features/tradeview/types/chart.type.ts new file mode 100644 index 0000000..8c3ba2d --- /dev/null +++ b/src/features/tradeview/types/chart.type.ts @@ -0,0 +1,151 @@ +export type ChartTextType = Partial<{ + // Misc prompts + Line: string; + Candles: string; + 'Hollow Candles': string; + Sticks: string; + Fills: string; + Color: string; + 'Positive color': string; + 'Negative color': string; + Fill: string; + Save: string; + Cancel: string; + Apply: string; + Reset: string; + Comparison: string; + to: string; + 'Scroll to increment': string; + 'Click to toggle': string; + Search: string; + 'Search results are limited to %1.': string; + + // Settings + Settings: string; + 'Y-axis scale': string; + 'Change percent': string; + Regular: string; + Logarithmic: string; + + // Date-range selectors + 'Date Range': string; + 'Period selector': string; + D: string; + M: string; + YTD: string; + Y: string; + Max: string; + minute: string; + minutes: string; + hour: string; + hours: string; + day: string; + week: string; + month: string; + year: string; + Year: string; + Month: string; + Hour: string; + Minute: string; + Wk: string; + + // Drawing + Draw: string; + 'Drawing tool': string; + 'Snap icon to data': string; + 'Line color': string; + 'Line thickness': string; + 'Line style': string; + 'Fill color': string; + Text: string; + 'Text color': string; + 'Label font size': string; + Bold: string; + Italic: string; + 'Label font family': string; + 'Show line extension': string; + Eraser: string; + Clear: string; + 'Clear all drawings': string; + Callout: string; + Doodle: string; + Ellipse: string; + Fibonacci: string; + 'Fibonacci Timezone': string; + 'Horizontal Line': string; + 'Horizontal Ray': string; + 'Arrows & Icons': string; + Label: string; + Polyline: string; + 'Quadrant Line': string; + Rectangle: string; + Regression: string; + 'Trend Line': string; + 'Vertical Line': string; + + // Indicators + Indicators: string; + Increase: string; + Decrease: string; + 'Accumulation Distribution': string; + 'Accumulative Swing Index': string; + 'Use Volume': string; + 'Limit move value': string; + Period: string; + 'Aroon up': string; + 'Aroon down': string; + Increasing: string; + Decreasing: string; + Upper: string; + Average: string; + Lower: string; + Field: string; + Type: string; + 'Fast period': string; + 'Slow period': string; + Overbought: string; + Oversold: string; + 'Moving Average Type': string; + 'Fast MA period': string; + 'Slow MA period': string; + 'Signal period': string; + MACD: string; + Signal: string; + Offset: string; + 'Points/Percent': string; + 'Shift type': string; + Shift: string; + Top: string; + Median: string; + Bottom: string; + '%K Smoothing': string; + '%D Smoothing': string; + Fast: string; + Slow: string; + 'Signal color': string; + 'Up volume': string; + 'Down volume': string; + Deviation: string; + Depth: string; + Aroon: string; + 'Awesome Oscillator': string; + 'Bollinger Bands': string; + 'Chaikin Money Flow': string; + 'Chaikin Oscillator': string; + 'Commodity Channel Index': string; + 'Disparity Index': string; + 'Moving Average': string; + 'Moving Average Deviation': string; + 'Moving Average Envelope': string; + 'On Balance Volume': string; + 'Relative Strength Index': string; + 'Standard Deviation': string; + 'Stochastic Oscillator': string; + Trix: string; + 'Typical Price': string; + Volume: string; + VWAP: string; + 'Williams R': string; + 'Median Price': string; + ZigZag: string; +}>; diff --git a/src/features/tradeview/types/tradeview.type.ts b/src/features/tradeview/types/tradeview.type.ts index d553595..a0c9f09 100644 --- a/src/features/tradeview/types/tradeview.type.ts +++ b/src/features/tradeview/types/tradeview.type.ts @@ -1,4 +1,6 @@ -export type RowData = { +import type { Time } from 'lightweight-charts'; + +export type RawData = { ticker: string; timestamp: string; open: string; @@ -9,10 +11,9 @@ export type RowData = { }; export type CandlestickData = { - Timestamp: number; - Close: number; - High: number; - Low: number; - Open: number; - Volume: number; + time: Time; + close: number; + high: number; + low: number; + open: number; }; diff --git a/src/features/tradeview/ui/Button/index.tsx b/src/features/tradeview/ui/Button/index.tsx new file mode 100644 index 0000000..4c57956 --- /dev/null +++ b/src/features/tradeview/ui/Button/index.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx'; +import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'; + +type ButtonProps = PropsWithChildren< + ButtonHTMLAttributes & { selected?: boolean } +>; +export default function Button({ children, selected, ...props }: ButtonProps) { + return ( + + ); +} diff --git a/src/features/tradeview/ui/IntervalSelector/index.tsx b/src/features/tradeview/ui/IntervalSelector/index.tsx new file mode 100644 index 0000000..61ace8f --- /dev/null +++ b/src/features/tradeview/ui/IntervalSelector/index.tsx @@ -0,0 +1,29 @@ +import type { MouseEvent } from 'react'; +import Button from '../Button'; + +type IntervalSelectorProps = { + intervals: { interval: number; text: string }[]; + selectedInterval: number; + onSelectInterval: (e: MouseEvent) => void; +}; + +export default function IntervalSelector({ + intervals, + onSelectInterval, + selectedInterval, +}: IntervalSelectorProps) { + return ( +
+ {intervals.map((interval) => ( + + ))} +
+ ); +} diff --git a/src/features/tradeview/ui/StockChart/ChartContainer.tsx b/src/features/tradeview/ui/StockChart/ChartContainer.tsx new file mode 100644 index 0000000..1671441 --- /dev/null +++ b/src/features/tradeview/ui/StockChart/ChartContainer.tsx @@ -0,0 +1,132 @@ +import { + type ChartOptions, + type DeepPartial, + type IChartApi, + type ISeriesApi, + type LayoutOptions, + type SeriesType, + createChart, +} from 'lightweight-charts'; +import { + type PropsWithChildren, + createContext, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, +} from 'react'; + +import { INTERVAL_SELECTOR_HEIGHT } from '../../const/chart.const'; +import { useChartRoot } from './ChartRoot'; + +type ChartContainerProps = PropsWithChildren<{ + layout?: DeepPartial; + chartOption?: DeepPartial; + ref?: React.RefObject; + onChartReady?: () => void; +}>; + +type ChartApi = { + _instance: IChartApi | null; + isRemoved: boolean; + getInstance(): IChartApi; + free(series: ISeriesApi): void; +}; + +const ChartContainerContext = createContext(null); + +export default function ChartContainer({ + children, + layout, + chartOption, + ref, + onChartReady, +}: ChartContainerProps) { + const { root } = useChartRoot(); + + const chartApiRef = useRef({ + _instance: null, + isRemoved: false, + getInstance() { + if (!root) { + throw new Error('ChartCotainer should be used within ChartRoot'); + } + + if (!this._instance) { + this._instance = createChart(root, { + ...chartOption, + layout, + width: root.clientWidth, + height: root.clientHeight - INTERVAL_SELECTOR_HEIGHT, + }); + this._instance.timeScale().fitContent(); + } + + return this._instance; + }, + free(series) { + if (!this._instance) return; + + this._instance.removeSeries(series); + }, + }); + + useLayoutEffect(() => { + const chartApi = chartApiRef.current; + const chart = chartApi.getInstance(); + + const handleResize = () => { + if (!root) return; + chart.applyOptions({ + ...chartOption, + width: root.clientWidth, + height: root.clientHeight - INTERVAL_SELECTOR_HEIGHT, + }); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chartApi.isRemoved = true; + chart.remove(); + }; + }, [root, chartOption]); + + useLayoutEffect(() => { + const chartApi = chartApiRef.current; + chartApi.getInstance(); + if (onChartReady) { + onChartReady(); + } + }, [onChartReady]); + + useLayoutEffect(() => { + if (!chartOption) return; + + const currentRef = chartApiRef.current; + currentRef.getInstance().applyOptions(chartOption); + }, [chartOption]); + + useEffect(() => { + const currentRef = chartApiRef.current; + currentRef.getInstance().applyOptions({ layout }); + }, [layout]); + + useImperativeHandle(ref, () => chartApiRef.current.getInstance(), []); + + return ( + + {children} + + ); +} + +export function useChartContainer() { + const context = useContext(ChartContainerContext); + if (!context) { + throw new Error('useChartContainer must be used within a ChartContainer'); + } + return context; +} diff --git a/src/features/tradeview/ui/StockChart/ChartRoot.tsx b/src/features/tradeview/ui/StockChart/ChartRoot.tsx new file mode 100644 index 0000000..25495f1 --- /dev/null +++ b/src/features/tradeview/ui/StockChart/ChartRoot.tsx @@ -0,0 +1,36 @@ +import { + type PropsWithChildren, + createContext, + useCallback, + useContext, + useState, +} from 'react'; + +type ChartRootProps = PropsWithChildren; + +type RootContext = { + root: HTMLDivElement | null; +}; + +const Context = createContext(null); + +export default function ChartRoot({ children }: ChartRootProps) { + const [root, setRoot] = useState(null); + const handleRef = useCallback((ref: HTMLDivElement) => setRoot(ref), []); + + return ( + +
+ {root && children} +
+
+ ); +} + +export const useChartRoot = () => { + const context = useContext(Context); + if (!context) { + throw new Error('useChartRoot must be used within a ChartRoot'); + } + return context; +}; diff --git a/src/features/tradeview/ui/StockChart/Series.tsx b/src/features/tradeview/ui/StockChart/Series.tsx new file mode 100644 index 0000000..1e45e1a --- /dev/null +++ b/src/features/tradeview/ui/StockChart/Series.tsx @@ -0,0 +1,137 @@ +import { + AreaSeries, + BarSeries, + CandlestickSeries, + HistogramSeries, + type ISeriesApi, + LineSeries, + type SeriesDataItemTypeMap, + type SeriesPartialOptionsMap, + type SeriesType, +} from 'lightweight-charts'; +import { + type PropsWithChildren, + createContext, + useContext, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; + +import { useChartContainer } from './ChartContainer'; + +type SeriesApi = { + _instance: ISeriesApi | null; + getInstance(): ISeriesApi; + free(): void; +}; + +type SeriesProps = PropsWithChildren<{ + seriesType: T; + seriesOption?: SeriesPartialOptionsMap[T]; + ref?: React.RefObject | null>; + data?: SeriesDataItemTypeMap[T][]; +}>; + +const SeriesContext = createContext | null>(null); + +export default function Series({ + seriesType, + children, + seriesOption, + data, + ref, +}: SeriesProps) { + const chartContainer = useChartContainer(); + const seriesApiRef = useRef>({ + _instance: null, + + getInstance() { + if (!chartContainer) { + throw new Error('Series should be used within ChartContainer'); + } + + if (!this._instance) { + switch (seriesType) { + case 'Area': + this._instance = chartContainer + .getInstance() + .addSeries(AreaSeries, seriesOption) as ISeriesApi; + break; + case 'Bar': + this._instance = chartContainer + .getInstance() + .addSeries(BarSeries, seriesOption) as ISeriesApi; + break; + case 'Line': + this._instance = chartContainer + .getInstance() + .addSeries(LineSeries, seriesOption) as ISeriesApi; + break; + case 'Histogram': + this._instance = chartContainer + .getInstance() + .addSeries(HistogramSeries, seriesOption) as ISeriesApi; + break; + case 'Candlestick': + this._instance = chartContainer + .getInstance() + .addSeries(CandlestickSeries, seriesOption) as ISeriesApi; + break; + default: + throw new Error('Invalid series type'); + } + + if (data) { + this._instance.setData(data); + } + } + + return this._instance; + }, + + free() { + if (!this._instance || chartContainer.isRemoved) return; + + chartContainer.free(this._instance); + }, + }); + + useLayoutEffect(() => { + const currentRef = seriesApiRef.current; + currentRef.getInstance(); + + return () => currentRef.free(); + }, []); + + useLayoutEffect(() => { + if (!seriesOption) return; + + const currentRef = seriesApiRef.current; + currentRef.getInstance().applyOptions(seriesOption); + }, [seriesOption]); + + useImperativeHandle(ref, () => seriesApiRef.current.getInstance(), []); + + const context = useMemo( + () => ({ + series: seriesApiRef.current, + }), + [], + ); + + return ( + + {children} + + ); +} + +export function useSeries() { + const context = useContext(SeriesContext); + if (!context) { + throw new Error('useSeries must be used within a Series'); + } + return context; +} diff --git a/src/features/tradeview/ui/StockChart/ToolTip.tsx b/src/features/tradeview/ui/StockChart/ToolTip.tsx new file mode 100644 index 0000000..b878c6c --- /dev/null +++ b/src/features/tradeview/ui/StockChart/ToolTip.tsx @@ -0,0 +1,80 @@ +import type { CandlestickData } from 'lightweight-charts'; +import { useLayoutEffect, useRef } from 'react'; +import { formatCurrencyKR } from '~/shared/utils'; +import { useChartContainer } from './ChartContainer'; +import { useChartRoot } from './ChartRoot'; +import { useSeries } from './Series'; + +const TOOLTIP_WIDTH = 80; +const TOOLTIP_HEIGHT = 80; +const TOOLTIP_MARGIN = 15; + +export default function ToolTip() { + const { root: chartRoot } = useChartRoot(); + const chartContainer = useChartContainer(); + const series = useSeries(); + const toolTipElementRef = useRef(null); + + useLayoutEffect(() => { + const chart = chartContainer.getInstance(); + const chartSeries = series.getInstance(); + + chart.subscribeCrosshairMove((param) => { + if (!chartRoot || !toolTipElementRef.current) return; + + if ( + param.point === undefined || + !param.time || + param.point.x < 0 || + param.point.y < 0 || + !chartSeries + ) { + toolTipElementRef.current.style.display = 'none'; + } else { + const x = param.point.x; + const y = param.point.y; + const { close, high, low, open, time } = param.seriesData.get( + chartSeries, + ) as CandlestickData; + + toolTipElementRef.current.style.display = 'block'; + toolTipElementRef.current.innerHTML = `
+
+
Open:
+
${formatCurrencyKR(open)}์›
+
High:
+
${formatCurrencyKR(high)}์›
+
Low:
+
${formatCurrencyKR(low)}์›
+
Close:
+
${formatCurrencyKR(close)}์›
+
+
+
${new Date((time as number) * 1000).toLocaleString()}
+
+
`; + + let left = x + TOOLTIP_MARGIN; + if (left > chartRoot.clientWidth - TOOLTIP_WIDTH) { + left = x - TOOLTIP_MARGIN - TOOLTIP_WIDTH; + } + + let top = y + TOOLTIP_MARGIN; + if (top > chartRoot.clientHeight - TOOLTIP_HEIGHT) { + top = y - TOOLTIP_MARGIN - TOOLTIP_HEIGHT; + } + + toolTipElementRef.current.style.left = `${left}px`; + toolTipElementRef.current.style.top = `${top}px`; + } + }); + }, [chartContainer, series, chartRoot]); + + return ( +
+ ); +} diff --git a/src/features/tradeview/ui/StockChart/index.tsx b/src/features/tradeview/ui/StockChart/index.tsx index c60ed4e..7fa11b3 100644 --- a/src/features/tradeview/ui/StockChart/index.tsx +++ b/src/features/tradeview/ui/StockChart/index.tsx @@ -1,511 +1,175 @@ -import * as am5 from '@amcharts/amcharts5'; -import * as am5stock from '@amcharts/amcharts5/stock'; -import am5themes_Animated from '@amcharts/amcharts5/themes/Animated'; -import * as am5xy from '@amcharts/amcharts5/xy'; -import { useEffect, useLayoutEffect, useRef } from 'react'; - -import type { CoinTicker } from '~/entities/coin'; +import type { + CandlestickData, + DeepPartial, + IChartApi, + ISeriesApi, + LogicalRangeChangeEventHandler, + Time, + TimeChartOptions, +} from 'lightweight-charts'; +import { + type MouseEvent, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import ChartContainer from './ChartContainer'; +import ChartRoot from './ChartRoot'; +import Series from './Series'; +import ToolTip from './ToolTip'; + +import api from '../../api/tradeview.endpoints'; +import { INTERVALS } from '../../const/chart.const'; import usePastTimeData from '../../hooks/usePastTimeData'; import useRealTimeData from '../../hooks/useRealTimeData'; -import type { CandlestickData } from '../../types/tradeview.type'; +import { extractCandlestickData, timestampToISOString } from '../../utils'; +import IntervalSelector from '../IntervalSelector'; -type StockChartProps = { - ticker: CoinTicker; +type ChartProps = { + ticker?: string; + count?: number; }; -// ์‹œ๋ฆฌ์ฆˆ ์„ค์ •์„ ์ถ”์ถœํ•˜๋Š” ํ•จ์ˆ˜ -function getNewSettings< - T extends am5xy.XYSeries, - K extends keyof T['_settings'], ->(series: T): Pick { - const settingsToCopy: K[] = [ - 'name', - 'valueYField', - 'highValueYField', - 'lowValueYField', - 'openValueYField', - 'calculateAggregates', - 'valueXField', - 'xAxis', - 'yAxis', - 'legendValueText', - 'legendRangeValueText', - 'stroke', - 'fill', - ] as K[]; - - const newSettings: Partial> = {}; - - am5.array.each(settingsToCopy, (setting) => { - const value = series.get(setting); - if (value !== undefined) { - newSettings[setting] = value; - } - }); - - return newSettings as Pick; -} -// ์ฐจํŠธ์— ์ด๋ฒคํŠธ ๋งˆ์ปค๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜ -function makeEvent( - root: am5.Root, - dateAxis: am5xy.DateAxis, - date: number, - letter: string, - color: am5.Color, - description: string, -) { - const dataItem = dateAxis.createAxisRange( - dateAxis.makeDataItem({ value: date }), - ); - const grid = dataItem.get('grid'); - if (grid) { - grid.setAll({ - visible: true, - strokeOpacity: 0.2, - strokeDasharray: [3, 3], - }); - } - - const bullet = am5.Container.new(root, { - dy: -100, - }); - - const circle = bullet.children.push( - am5.Circle.new(root, { - radius: 10, - stroke: color, - fill: am5.color(0xffffff), - tooltipText: description, - tooltip: am5.Tooltip.new(root, {}), - tooltipY: 0, - }), - ); - - const label = bullet.children.push( - am5.Label.new(root, { - text: letter, - centerX: am5.p50, - centerY: am5.p50, - }), - ); - - dataItem.set( - 'bullet', - am5xy.AxisBullet.new(root, { - location: 0, - stacked: true, - sprite: bullet, - }), - ); -} - -export default function StockChart({ ticker }: Readonly) { - const chartControlRef = useRef(null); - const valueSeriesRef = useRef(null); - const sbSeriesRef = useRef(null); - const currentValueDataRef = useRef>(null); - const isFirstRendered = useRef(true); +export default function Chart({ ticker = 'BTC', count = 30 }: ChartProps) { + const [selectedInterval, setSelectedInterval] = useState(1); + const chartRef = useRef(null); + const seriesRef = useRef>(null); + const [isChartReady, setIsChartReady] = useState(false); const realTimeData = useRealTimeData(ticker); - const pastTimeData = usePastTimeData(ticker); - const rootRef = useRef(null); - const stockChartRef = useRef(null); - - useEffect(() => { - if (!isFirstRendered.current) return; - if (!valueSeriesRef.current || !sbSeriesRef.current || !pastTimeData.length) - return; - - valueSeriesRef.current.data.setAll(pastTimeData); - sbSeriesRef.current.data.setAll(pastTimeData); - - isFirstRendered.current = false; - }, [pastTimeData]); + const pastTimeData = usePastTimeData(ticker, selectedInterval, count); + + const chartOption: DeepPartial = useMemo(() => { + return { + timeScale: { timeVisible: true }, + localization: { + locale: 'kr', + dateFormat: 'yyyy-MM-dd', + }, + rightPriceScale: { + borderVisible: false, + }, + crosshair: { + horzLine: { + visible: true, + labelVisible: true, + }, + vertLine: { + labelVisible: true, + }, + }, + }; + }, []); - useEffect(() => { + useLayoutEffect(() => { if ( - !valueSeriesRef.current || - !sbSeriesRef.current || - !realTimeData || + !chartRef.current || + !seriesRef.current || + !isChartReady || !pastTimeData.length ) return; - const lastDataObject = valueSeriesRef.current.data.values.at( - -1, - ) as CandlestickData; - - if (!lastDataObject) { - valueSeriesRef.current.data.push(realTimeData); - sbSeriesRef.current.data.push(realTimeData); - return; - } - - const { - High, - Low, - Open, - Volume, - Timestamp: PrevTimestamp, - } = lastDataObject; - const { - Close: CurrentClose, - Volume: CurrentVolume, - Timestamp: CurrentTimestamp, - } = realTimeData; - - if (am5.time.checkChange(CurrentTimestamp, PrevTimestamp, 'minute')) { - // ์ƒˆ๋กœ์šด ๋ถ„๋ด‰ ์‹œ์ž‘ - const newCandlestickData = { - Open: CurrentClose, - High: CurrentClose, - Low: CurrentClose, - Close: CurrentClose, - Volume: CurrentVolume, - Timestamp: CurrentTimestamp, - }; - valueSeriesRef.current.data.push(newCandlestickData); - sbSeriesRef.current.data.push(newCandlestickData); - } else { - // ๊ธฐ์กด ๋ถ„๋ด‰ ์—…๋ฐ์ดํŠธ - const newCandlestickData = { - Open: Open, - High: Math.max(High, CurrentClose), - Low: Math.min(Low, CurrentClose), - Close: CurrentClose, - Volume: Volume + CurrentVolume, - Timestamp: CurrentTimestamp, - }; - valueSeriesRef.current.data.setIndex( - valueSeriesRef.current.data.length - 1, - newCandlestickData, - ); - sbSeriesRef.current.data.setIndex( - sbSeriesRef.current.data.length - 1, - newCandlestickData, - ); - } - - if (!currentValueDataRef.current) return; - - const currentLabel = currentValueDataRef.current.get('label'); - if (currentLabel) { - currentValueDataRef.current.animate({ - key: 'value', - to: realTimeData.Close, - duration: 500, - easing: am5.ease.out(am5.ease.cubic), - }); - currentLabel.set( - 'text', - stockChartRef.current?.getNumberFormatter().format(realTimeData.Close), - ); - const bg = currentLabel.get('background'); - if (bg) { - if (realTimeData.Close < realTimeData.Open) { - bg.set('fill', rootRef.current?.interfaceColors.get('negative')); - } else { - bg.set('fill', rootRef.current?.interfaceColors.get('positive')); - } - } - } - }, [realTimeData, pastTimeData]); - - useLayoutEffect(() => { - // ๋ฃจํŠธ ์š”์†Œ ์ƒ์„ฑ - rootRef.current = am5.Root.new('chartdiv'); - if (!rootRef.current) return; - - const myTheme = am5.Theme.new(rootRef.current); - - myTheme.rule('Grid', ['scrollbar', 'minor']).setAll({ - visible: false, + const convertedData = extractCandlestickData(pastTimeData); + seriesRef.current.setData(convertedData); + chartRef.current.timeScale().applyOptions({ + borderVisible: false, }); + chartRef.current.timeScale().fitContent(); + }, [isChartReady, pastTimeData]); - rootRef.current.setThemes([ - am5themes_Animated.new(rootRef.current), - myTheme, - ]); - - // ์Šคํ†ก ์ฐจํŠธ ์ƒ์„ฑ - stockChartRef.current = rootRef.current.container.children.push( - am5stock.StockChart.new(rootRef.current, { - paddingRight: 0, - }), - ); - - // ์ „์—ญ ์ˆซ์ž ํฌ๋งท ์„ค์ • - rootRef.current.numberFormatter.set('numberFormat', '#,###.00'); - - // ๋ฉ”์ธ ์Šคํ†ก ํŒจ๋„(์ฐจํŠธ) ์ƒ์„ฑ - const mainPanel = stockChartRef.current?.panels.push( - am5stock.StockPanel.new(rootRef.current, { - wheelY: 'zoomX', - panX: true, - panY: true, - }), - ); - - // ๊ฐ’ ์ถ• ์ƒ์„ฑ - const valueAxis = mainPanel.yAxes.push( - am5xy.ValueAxis.new(rootRef.current, { - renderer: am5xy.AxisRendererY.new(rootRef.current, { - pan: 'zoom', - }), - extraMin: 0.1, // adds some space for for main series - tooltip: am5.Tooltip.new(rootRef.current, {}), - numberFormat: '#,###.00', - extraTooltipPrecision: 2, - }), - ); - - const dateAxis = mainPanel.xAxes.push( - am5xy.GaplessDateAxis.new(rootRef.current, { - baseInterval: { - timeUnit: 'minute', - count: 1, - }, - renderer: am5xy.AxisRendererX.new(rootRef.current, { - minorGridEnabled: true, - }), - tooltip: am5.Tooltip.new(rootRef.current, {}), - }), - ); - - // ์‹œ๋ฆฌ์ฆˆ ์ถ”๊ฐ€ - valueSeriesRef.current = mainPanel.series.push( - am5xy.CandlestickSeries.new(rootRef.current, { - name: 'MSFT', - clustered: false, - valueXField: 'Timestamp', - valueYField: 'Close', - highValueYField: 'High', - lowValueYField: 'Low', - openValueYField: 'Open', - calculateAggregates: true, - xAxis: dateAxis, - yAxis: valueAxis, - legendValueText: - 'open: [bold]{openValueY}[/] high: [bold]{highValueY}[/] low: [bold]{lowValueY}[/] close: [bold]{valueY}[/]', - legendRangeValueText: '', - }), - ); - - // ๋ฉ”์ธ ๊ฐ’ ์‹œ๋ฆฌ์ฆˆ ์„ค์ • - stockChartRef.current?.set('stockSeries', valueSeriesRef.current); - - currentValueDataRef.current = valueAxis.createAxisRange( - valueAxis.makeDataItem({ value: 0 }), - ); - - // ์Šคํ†ก ๋ฒ”๋ก€ ์ถ”๊ฐ€ - const valueLegend = mainPanel.plotContainer.children.push( - am5stock.StockLegend.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - ); - - // ๋ฉ”์ธ ์‹œ๋ฆฌ์ฆˆ ์„ค์ • - valueLegend.data.setAll([valueSeriesRef.current]); - - // ์ปค์„œ ์ถ”๊ฐ€ - mainPanel.set( - 'cursor', - am5xy.XYCursor.new(rootRef.current, { - yAxis: valueAxis, - xAxis: dateAxis, - snapToSeries: [valueSeriesRef.current], - snapToSeriesBy: 'y!', - }), - ); - - // ์Šคํฌ๋กค๋ฐ” ์ถ”๊ฐ€ - const scrollbar = mainPanel.set( - 'scrollbarX', - am5xy.XYChartScrollbar.new(rootRef.current, { - orientation: 'horizontal', - height: 50, - }), - ); - stockChartRef.current?.toolsContainer.children.push(scrollbar); - - const sbDateAxis = scrollbar.chart.xAxes.push( - am5xy.GaplessDateAxis.new(rootRef.current, { - baseInterval: { - timeUnit: 'minute', - count: 1, - }, - renderer: am5xy.AxisRendererX.new(rootRef.current, { - minorGridEnabled: true, - }), - }), - ); - - const sbValueAxis = scrollbar.chart.yAxes.push( - am5xy.ValueAxis.new(rootRef.current, { - renderer: am5xy.AxisRendererY.new(rootRef.current, {}), - }), - ); - - sbSeriesRef.current = scrollbar.chart.series.push( - am5xy.LineSeries.new(rootRef.current, { - valueYField: 'Close', - valueXField: 'Timestamp', - xAxis: sbDateAxis, - yAxis: sbValueAxis, - }), - ); - - sbSeriesRef.current?.fills.template.setAll({ - visible: true, - fillOpacity: 0.3, - }); - - // ์‹œ๋ฆฌ์ฆˆ ํƒ€์ž… ์Šค์œ„์ฒ˜ - const seriesSwitcher = am5stock.SeriesTypeControl.new(rootRef.current, { - stockChart: stockChartRef.current, - }); - - seriesSwitcher.events.on('selected', (ev) => { - // Handle the case where item can be string or IDropdownListItem - const itemValue = typeof ev.item === 'string' ? ev.item : ev.item.id; - setSeriesType(itemValue); - }); - - // ์‹œ๋ฆฌ์ฆˆ ํƒ€์ž…์„ ์ „ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜ - const setSeriesType = (seriesType: string) => { - if (!rootRef.current || !stockChartRef.current) return; - // ํ˜„์žฌ ์‹œ๋ฆฌ์ฆˆ์™€ ์„ค์ • ๊ฐ€์ ธ์˜ค๊ธฐ - const currentSeries = stockChartRef.current.get('stockSeries'); - if (!currentSeries) return; - - const newSettings = getNewSettings(currentSeries); - - // ์ด์ „ ์‹œ๋ฆฌ์ฆˆ ์ œ๊ฑฐ - const data = currentSeries.data.values; - mainPanel.series.removeValue(currentSeries); - - // ์ƒˆ ์‹œ๋ฆฌ์ฆˆ ์ƒ์„ฑ - let series: am5xy.LineSeries | am5xy.CandlestickSeries | am5xy.OHLCSeries; - switch (seriesType) { - case 'line': - series = mainPanel.series.push( - am5xy.LineSeries.new(rootRef.current, newSettings), - ); - break; - case 'candlestick': - case 'procandlestick': - // newSettings.clustered = false; - series = mainPanel.series.push( - am5xy.CandlestickSeries.new(rootRef.current, newSettings), - ); - if (seriesType === 'procandlestick') { - series?.columns?.template?.get('themeTags')?.push('pro'); - } - break; - case 'ohlc': - // newSettings.clustered = false; - series = mainPanel.series.push( - am5xy.OHLCSeries.new(rootRef.current, newSettings), - ); - break; - } - - // ์ƒˆ ์‹œ๋ฆฌ์ฆˆ๋ฅผ stockSeries๋กœ ์„ค์ • - if (series) { - valueLegend.data.removeValue(currentSeries); - series.data.setAll(data); - stockChartRef.current?.set('stockSeries', series); - const cursor = mainPanel.get('cursor'); - if (cursor) { - cursor.set('snapToSeries', [series]); - } - valueLegend.data.insertIndex(0, series); + useLayoutEffect(() => { + if (!chartRef.current || !isChartReady) return; + + const handleVisibleRangeChange: LogicalRangeChangeEventHandler = async ( + logicalRange, + ) => { + if (!logicalRange) return; + if (logicalRange.from < -0.5) { + const firstData = seriesRef.current?.dataByIndex(0) as CandlestickData; + if (!firstData || !firstData.time) return; + + const firstDate = timestampToISOString(firstData.time as number); + + const response = await api.getPastData( + ticker, + selectedInterval, + count, + firstDate, + ); + const pastData = await response.json(); + + if (!pastData.length) return; + + const previousData = seriesRef.current?.data() || []; + + const pastCandlestickData = extractCandlestickData(pastData).filter( + (data) => { + const prevTime = previousData.at(0)?.time; + if (!prevTime) return true; + return data.time < prevTime; + }, + ); + seriesRef.current?.setData([...pastCandlestickData, ...previousData]); } }; - // ์Šคํ†ก ํˆด๋ฐ” - const toolbar = am5stock.StockToolbar.new(rootRef.current, { - // biome-ignore lint/style/noNonNullAssertion: - container: chartControlRef.current!, - stockChart: stockChartRef.current, - controls: [ - am5stock.IndicatorControl.new(rootRef.current, { - stockChart: stockChartRef.current, - legend: valueLegend, - }), - am5stock.DateRangeSelector.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - am5stock.PeriodSelector.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - seriesSwitcher, - am5stock.DrawingControl.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - am5stock.DataSaveControl.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - am5stock.ResetControl.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - am5stock.SettingsControl.new(rootRef.current, { - stockChart: stockChartRef.current, - }), - ], - }); + chartRef.current + .timeScale() + .subscribeVisibleLogicalRangeChange(handleVisibleRangeChange); - // ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ ์ƒ˜ํ”Œ๋กœ ๊ณ„์†ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค + return () => { + chartRef.current + ?.timeScale() + .unsubscribeVisibleLogicalRangeChange(handleVisibleRangeChange); + }; + }, [isChartReady, count, selectedInterval, ticker]); - // ์‚ฌ์šฉ์ž ์ •์˜ ํˆดํŒ ์ƒ์„ฑ - const tooltip = am5.Tooltip.new(rootRef.current, { - getStrokeFromSprite: false, - getFillFromSprite: false, - }); + useLayoutEffect(() => { + if (!realTimeData || !realTimeData.time) return; + const latestTime = seriesRef.current?.data().at(-1); - tooltip.get('background')?.setAll({ - strokeOpacity: 1, - stroke: am5.color(0x000000), - fillOpacity: 1, - fill: am5.color(0xffffff), - }); + if (!latestTime) { + seriesRef.current?.setData([realTimeData]); + return; + } - // ์ฐจํŠธ์— ์ด๋ฒคํŠธ ์ถ”๊ฐ€ - makeEvent( - rootRef.current, - dateAxis, - 1619006400000, - 'S', - am5.color(0xff0000), - 'Split 4:1', - ); - makeEvent( - rootRef.current, - dateAxis, - 1619006400000, - 'D', - am5.color(0x00ff00), - 'Dividends paid', - ); - makeEvent( - rootRef.current, - dateAxis, - 1634212800000, - 'D', - am5.color(0x00ff00), - 'Dividends paid', - ); + const timeDiff = +realTimeData.time - +latestTime.time; - // ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ์‹œ ์ฐจํŠธ ์ •๋ฆฌ - return () => { - rootRef.current?.dispose(); - }; - }, []); + if (timeDiff < 60 * selectedInterval) { + seriesRef.current?.update({ ...realTimeData, time: latestTime.time }); + } else { + seriesRef.current?.update({ + ...realTimeData, + time: (+latestTime.time + 60 * selectedInterval) as Time, + }); + } + }, [realTimeData, selectedInterval]); + + const handleSelectInterval = (e: MouseEvent) => { + setSelectedInterval(Number(e.currentTarget.value)); + }; return ( -
-
-
-
+ + + setIsChartReady(true)} + chartOption={chartOption} + > + + + + + ); } diff --git a/src/features/tradeview/utils/index.ts b/src/features/tradeview/utils/index.ts new file mode 100644 index 0000000..d034c8d --- /dev/null +++ b/src/features/tradeview/utils/index.ts @@ -0,0 +1,31 @@ +import type { IDisposer } from '@amcharts/amcharts5'; +import { toZonedTime } from 'date-fns-tz'; +import type { UTCTimestamp } from 'lightweight-charts'; +import type { CandlestickData, RawData } from '../types/tradeview.type'; + +export function isDisposed(...amchartElements: IDisposer[]) { + return amchartElements.every((amchartElement) => amchartElement.isDisposed()); +} + +export function extractCandlestickData(data: RawData[]): CandlestickData[] { + return data.map((item) => ({ + time: timeToKrTz(item.timestamp, 'Asia/Seoul') as UTCTimestamp, + open: Number(item.open), + high: Number(item.high), + low: Number(item.low), + close: Number(item.close), + })); +} + +export function timeToKrTz(originalTime: string, timeZone: string) { + const date = new Date(originalTime); + date.setHours(date.getHours() + 9); + + const zonedDate = toZonedTime(date, timeZone); + return zonedDate.getTime() / 1000; +} + +export function timestampToISOString(timestamp: number) { + const date = new Date(timestamp * 1000); + return date.toISOString().slice(0, -1); +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 53bc534..d38b5c0 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -32,3 +32,7 @@ export function preventNonNumericInput(event: React.KeyboardEvent): void { event.preventDefault(); } } + +export function isNullish(value: unknown): value is null | undefined { + return value === null || value === undefined; +} diff --git a/stats.html b/stats.html index b527ac8..26e6e89 100644 --- a/stats.html +++ b/stats.html @@ -4929,7 +4929,7 @@