From 94056bb3fe05027cce3f249800954f08d80da2f8 Mon Sep 17 00:00:00 2001 From: BHyeonKim Date: Fri, 13 Jun 2025 17:57:58 +0900 Subject: [PATCH 01/19] =?UTF-8?q?refactor:=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=ED=98=95=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 차트를 컴포넌트들로 나누어서 책임을 분리하였고 선언형으로 사용할 수 있게 했습니다 issues: #33 --- src/app/entry.client.tsx | 8 +- src/app/routes/trade.$ticker.tsx | 8 +- .../tradeview/api/tradeview.endpoints.ts | 6 +- .../tradeview/ui/Chart/ChartContainer.tsx | 60 ++++++++++++++ src/features/tradeview/ui/Chart/MainPanel.tsx | 53 ++++++++++++ src/features/tradeview/ui/Chart/SbSeries.tsx | 77 +++++++++++++++++ src/features/tradeview/ui/Chart/StockAxis.tsx | 82 +++++++++++++++++++ .../tradeview/ui/Chart/StockChart.tsx | 39 +++++++++ .../tradeview/ui/Chart/StockToolBar.tsx | 64 +++++++++++++++ .../tradeview/ui/Chart/ValueSeries.tsx | 68 +++++++++++++++ .../tradeview/ui/Chart/XScrollBar.tsx | 54 ++++++++++++ src/features/tradeview/ui/Chart/index.tsx | 73 +++++++++++++++++ stats.html | 2 +- vite.config.ts | 3 + 14 files changed, 584 insertions(+), 13 deletions(-) create mode 100644 src/features/tradeview/ui/Chart/ChartContainer.tsx create mode 100644 src/features/tradeview/ui/Chart/MainPanel.tsx create mode 100644 src/features/tradeview/ui/Chart/SbSeries.tsx create mode 100644 src/features/tradeview/ui/Chart/StockAxis.tsx create mode 100644 src/features/tradeview/ui/Chart/StockChart.tsx create mode 100644 src/features/tradeview/ui/Chart/StockToolBar.tsx create mode 100644 src/features/tradeview/ui/Chart/ValueSeries.tsx create mode 100644 src/features/tradeview/ui/Chart/XScrollBar.tsx create mode 100644 src/features/tradeview/ui/Chart/index.tsx diff --git a/src/app/entry.client.tsx b/src/app/entry.client.tsx index ee80c88..5cc12e1 100644 --- a/src/app/entry.client.tsx +++ b/src/app/entry.client.tsx @@ -1,5 +1,5 @@ /* v8 ignore start */ -import { StrictMode, startTransition } from 'react'; +import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; @@ -11,9 +11,9 @@ prepareApp().then(() => { startTransition(() => { hydrateRoot( document, - - - , + // + // , + , ); }); }); diff --git a/src/app/routes/trade.$ticker.tsx b/src/app/routes/trade.$ticker.tsx index eb79baa..83be18c 100644 --- a/src/app/routes/trade.$ticker.tsx +++ b/src/app/routes/trade.$ticker.tsx @@ -10,6 +10,7 @@ import { CoinListWithSearchBar } from '~/features/coin-search-list'; import { OrderForm, OrderFormFallback } from '~/features/order'; import { ExecutionList } from '~/features/order-execution-list'; import useTradeNotification from '~/features/trade/hooks/useTradeNotification'; +import Chart from '~/features/tradeview/ui/Chart'; import Container from '~/shared/ui/Container'; import ContainerTitle from '~/shared/ui/ContainerTitle'; import { NavBar, SideBar } from '~/widgets/navbar'; @@ -82,12 +83,7 @@ export default function TradeRouteComponent({ 실시간 차트 - {coinInfo && ( - - )} + diff --git a/src/features/tradeview/api/tradeview.endpoints.ts b/src/features/tradeview/api/tradeview.endpoints.ts index b47f032..95db40a 100644 --- a/src/features/tradeview/api/tradeview.endpoints.ts +++ b/src/features/tradeview/api/tradeview.endpoints.ts @@ -3,8 +3,10 @@ import ApiClient from '~/shared/api/httpClient'; import type { RowData } from '../types/tradeview.type'; export default { - getPastData: async (ticker = 'TRUMP') => { - return await ApiClient.get(`api/minute-ohlc?ticker=${ticker}`); + getPastData: async (ticker = 'TRUMP', period = 1) => { + return await ApiClient.get( + `api/minute-ohlc?ticker=${ticker}&period=${period}`, + ); }, }; /* v8 ignore end */ diff --git a/src/features/tradeview/ui/Chart/ChartContainer.tsx b/src/features/tradeview/ui/Chart/ChartContainer.tsx new file mode 100644 index 0000000..9d041a4 --- /dev/null +++ b/src/features/tradeview/ui/Chart/ChartContainer.tsx @@ -0,0 +1,60 @@ +import * as am5 from '@amcharts/amcharts5'; +import am5themes_Animated from '@amcharts/amcharts5/themes/Animated'; +import React, { useEffect, useRef, useState, type ReactNode } from 'react'; + +export type ChartContainerProps = { + containerId: string; + toolbarId: string; + children: ReactNode; +}; + +export type ChartContainer = { + chartRoot: am5.Root; + chartToolbarContainerRef: React.RefObject; +}; + +export default function ChartContainer({ + containerId, + toolbarId, + children, +}: ChartContainerProps) { + const [chartRoot, setChartRoot] = useState< + ChartContainer['chartRoot'] | null + >(null); + const chartToolbarContainerRef = + useRef(null); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child) && chartRoot) { + return React.cloneElement(child, { chartRoot: chartRoot }); + } + return child; + }); + + useEffect(() => { + const root = am5.Root.new(containerId); + + const Theme = am5.Theme.new(root); + Theme.rule('Grid', ['scrollbar', 'minor']).setAll({ + visible: false, + }); + + root.setThemes([am5themes_Animated.new(root), Theme]); + root.numberFormatter.set('numberFormat', '#,###.00'); + + setChartRoot(root); + + return () => { + root.dispose(); + }; + }, [containerId]); + + return ( + <> +
+
+ {chartRoot && childrenWithProps} +
+ + ); +} diff --git a/src/features/tradeview/ui/Chart/MainPanel.tsx b/src/features/tradeview/ui/Chart/MainPanel.tsx new file mode 100644 index 0000000..09f6748 --- /dev/null +++ b/src/features/tradeview/ui/Chart/MainPanel.tsx @@ -0,0 +1,53 @@ +import * as am5stock from '@amcharts/amcharts5/stock'; + +import React, { useEffect, type PropsWithChildren } from 'react'; +import type { StockChart } from './StockChart'; + +type MainPanelProps = PropsWithChildren>; + +export type MainPanel = { + mainPanel: am5stock.StockPanel | null; +} & StockChart; + +export default function MainPanel({ + chartRoot, + stockChart, + children, +}: MainPanelProps) { + if (!chartRoot || !stockChart) { + return null; + } + + const mainPanel = stockChart.panels.push( + am5stock.StockPanel.new(chartRoot, { + wheelY: 'zoomX', + panX: true, + panY: true, + }), + ); + + const valueLegend = mainPanel.plotContainer.children.push( + am5stock.StockLegend.new(chartRoot, { + stockChart: stockChart, + }), + ); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + chartRoot, + stockChart, + mainPanel, + }); + } + return child; + }); + + useEffect(() => { + return () => { + mainPanel.dispose(); + }; + }, [mainPanel]); + + return childrenWithProps; +} diff --git a/src/features/tradeview/ui/Chart/SbSeries.tsx b/src/features/tradeview/ui/Chart/SbSeries.tsx new file mode 100644 index 0000000..5ee02b9 --- /dev/null +++ b/src/features/tradeview/ui/Chart/SbSeries.tsx @@ -0,0 +1,77 @@ +import * as am5xy from '@amcharts/amcharts5/xy'; +import type { PropsWithChildren } from 'react'; +import { useEffect, useState } from 'react'; +import type { CandlestickData } from '../../types/tradeview.type'; +import type { XScrollBar } from './XScrollBar'; + +type SbSeriesProps = PropsWithChildren< + Partial & { pastTimeData?: CandlestickData[] } +>; + +export type SbSeries = { + sbSeries: am5xy.LineSeries | null; + sbValueAxis: am5xy.ValueAxis | null; + sbDateAxis: am5xy.GaplessDateAxis | null; +} & XScrollBar; + +export default function SbSeries({ + scrollbar, + chartRoot, + stockChart, + pastTimeData, + mainPanel, + children, +}: SbSeriesProps) { + const [sbSeries, setSbSeries] = useState(null); + + useEffect(() => { + if (!pastTimeData || !pastTimeData.length || !sbSeries) return; + sbSeries.data.setAll(pastTimeData); + }, [pastTimeData, sbSeries]); + + useEffect(() => { + if (!scrollbar || !chartRoot || !stockChart || !mainPanel) return; + + const newSbDateAxis = scrollbar.chart.xAxes.push( + am5xy.GaplessDateAxis.new(chartRoot, { + baseInterval: { + timeUnit: 'minute', + count: 1, + }, + renderer: am5xy.AxisRendererX.new(chartRoot, { + minorGridEnabled: true, + }), + }), + ); + + const newSbValueAxis = scrollbar.chart.yAxes.push( + am5xy.ValueAxis.new(chartRoot, { + renderer: am5xy.AxisRendererY.new(chartRoot, {}), + }), + ); + + const newSbSeries = scrollbar.chart.series.push( + am5xy.LineSeries.new(chartRoot, { + valueYField: 'Close', + valueXField: 'Timestamp', + xAxis: newSbDateAxis, + yAxis: newSbValueAxis, + }), + ); + + newSbSeries.fills.template.setAll({ + visible: true, + fillOpacity: 0.3, + }); + + setSbSeries(newSbSeries); + + return () => { + newSbDateAxis.dispose(); + newSbValueAxis.dispose(); + newSbSeries.dispose(); + }; + }, [scrollbar, chartRoot, stockChart, mainPanel]); + + return children; +} diff --git a/src/features/tradeview/ui/Chart/StockAxis.tsx b/src/features/tradeview/ui/Chart/StockAxis.tsx new file mode 100644 index 0000000..a3cece6 --- /dev/null +++ b/src/features/tradeview/ui/Chart/StockAxis.tsx @@ -0,0 +1,82 @@ +import * as am5 from '@amcharts/amcharts5'; +import * as am5xy from '@amcharts/amcharts5/xy'; +import type { PropsWithChildren } from 'react'; +import React, { useEffect } from 'react'; +import type { MainPanel } from './MainPanel'; + +type StockAxisProps = PropsWithChildren>; + +export type StockAxis = { + dateAxis: am5xy.GaplessDateAxis | null; + valueAxis: am5xy.ValueAxis | null; +} & MainPanel; + +export default function StockAxis({ + chartRoot, + stockChart, + mainPanel, + children, +}: StockAxisProps) { + if (!chartRoot || !stockChart || !mainPanel) { + return null; + } + + const dateAxis = mainPanel.xAxes.push( + am5xy.GaplessDateAxis.new(chartRoot, { + baseInterval: { + timeUnit: 'minute', + count: 1, + }, + renderer: am5xy.AxisRendererX.new(chartRoot, { + minorGridEnabled: true, + }), + tooltip: am5.Tooltip.new(chartRoot, {}), + }), + ); + + const valueAxis = mainPanel.yAxes.push( + am5xy.ValueAxis.new(chartRoot, { + renderer: am5xy.AxisRendererY.new(chartRoot, { + pan: 'zoom', + }), + extraMin: 0.1, + tooltip: am5.Tooltip.new(chartRoot, {}), + numberFormat: '#,###.00', + extraTooltipPrecision: 2, + }), + ); + + valueAxis.createAxisRange(valueAxis.makeDataItem({ value: 0 })); + + mainPanel.set( + 'cursor', + am5xy.XYCursor.new(chartRoot, { + yAxis: valueAxis, + xAxis: dateAxis, + snapToSeries: valueAxis.series, + snapToSeriesBy: 'y!', + }), + ); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + chartRoot, + stockChart, + mainPanel, + dateAxis, + valueAxis, + }); + } + return child; + }); + + useEffect(() => { + return () => { + dateAxis.dispose(); + valueAxis.dispose(); + }; + }, [dateAxis, valueAxis]); + + return childrenWithProps; +} diff --git a/src/features/tradeview/ui/Chart/StockChart.tsx b/src/features/tradeview/ui/Chart/StockChart.tsx new file mode 100644 index 0000000..7c9aa2b --- /dev/null +++ b/src/features/tradeview/ui/Chart/StockChart.tsx @@ -0,0 +1,39 @@ +import * as am5stock from '@amcharts/amcharts5/stock'; +import React, { type PropsWithChildren } from 'react'; + +import type { ChartContainer } from './ChartContainer'; + +type ChartPropsWithChildren = PropsWithChildren< + Partial< + ChartContainer & { + settings: am5stock.IStockChartSettings; + } + > +>; + +export type StockChart = { + stockChart: am5stock.StockChart | null; +} & ChartContainer; + +export default function StockChart({ + chartRoot, + settings = {}, + children, +}: ChartPropsWithChildren) { + if (!chartRoot) { + return null; + } + + const stockChart = chartRoot?.container.children.push( + am5stock.StockChart.new(chartRoot, settings), + ); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { chartRoot, stockChart }); + } + return child; + }); + + return childrenWithProps; +} diff --git a/src/features/tradeview/ui/Chart/StockToolBar.tsx b/src/features/tradeview/ui/Chart/StockToolBar.tsx new file mode 100644 index 0000000..be90c09 --- /dev/null +++ b/src/features/tradeview/ui/Chart/StockToolBar.tsx @@ -0,0 +1,64 @@ +import * as am5stock from '@amcharts/amcharts5/stock'; +import { type PropsWithChildren, useEffect } from 'react'; +import type { MainPanel } from './MainPanel'; + +type StockToolBarProps = PropsWithChildren>; + +export default function StockToolBar({ + stockChartRef, + chartRootRef, + chartToolbarContainerRef, + valueLegendRef, + children, +}: StockToolBarProps) { + useEffect(() => { + if ( + !chartRootRef || + !chartRootRef.current || + !stockChartRef || + !stockChartRef.current || + !chartToolbarContainerRef || + !chartToolbarContainerRef.current || + !valueLegendRef || + !valueLegendRef.current + ) + return; + const toolbar = am5stock.StockToolbar.new(chartRootRef.current, { + container: chartToolbarContainerRef.current, + stockChart: stockChartRef.current, + controls: [ + am5stock.IndicatorControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + legend: valueLegendRef.current, + }), + am5stock.DateRangeSelector.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.PeriodSelector.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.SeriesTypeControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.DrawingControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.DataSaveControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.ResetControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + am5stock.SettingsControl.new(chartRootRef.current, { + stockChart: stockChartRef.current, + }), + ], + }); + + return () => { + toolbar.dispose(); + }; + }, [stockChartRef, chartRootRef, chartToolbarContainerRef, valueLegendRef]); + + return children; +} diff --git a/src/features/tradeview/ui/Chart/ValueSeries.tsx b/src/features/tradeview/ui/Chart/ValueSeries.tsx new file mode 100644 index 0000000..d140cb4 --- /dev/null +++ b/src/features/tradeview/ui/Chart/ValueSeries.tsx @@ -0,0 +1,68 @@ +import * as am5xy from '@amcharts/amcharts5/xy'; +import { type PropsWithChildren, useEffect, useState } from 'react'; +import type { CandlestickData } from '../../types/tradeview.type'; +import type { StockAxis } from './StockAxis'; + +type ValueSeriesProps = PropsWithChildren< + Partial & { + pastTimeData?: CandlestickData[]; + } +>; + +export default function ValueSeries({ + children, + chartRoot, + stockChart, + mainPanel, + valueAxis, + dateAxis, + pastTimeData, +}: ValueSeriesProps) { + const [valueSeries, setValueSeries] = + useState(null); + + useEffect(() => { + if (!valueSeries || !pastTimeData) return; + valueSeries.data.setAll(pastTimeData); + }, [valueSeries, pastTimeData]); + + useEffect(() => { + if (!mainPanel || !chartRoot || !stockChart || !valueAxis || !dateAxis) + return; + const newValueSeries = mainPanel.series.push( + am5xy.CandlestickSeries.new(chartRoot, { + 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: '', + }), + ); + + stockChart.set('stockSeries', newValueSeries); + mainPanel.set( + 'cursor', + am5xy.XYCursor.new(chartRoot, { + yAxis: valueAxis, + xAxis: dateAxis, + snapToSeries: [newValueSeries], + snapToSeriesBy: 'y!', + }), + ); + setValueSeries(newValueSeries); + + return () => { + newValueSeries.dispose(); + }; + }, [valueAxis, mainPanel, stockChart, dateAxis, chartRoot]); + + return children; +} diff --git a/src/features/tradeview/ui/Chart/XScrollBar.tsx b/src/features/tradeview/ui/Chart/XScrollBar.tsx new file mode 100644 index 0000000..467fe6f --- /dev/null +++ b/src/features/tradeview/ui/Chart/XScrollBar.tsx @@ -0,0 +1,54 @@ +import * as am5xy from '@amcharts/amcharts5/xy'; +import type { PropsWithChildren } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { MainPanel } from './MainPanel'; + +type XScrollBarProps = PropsWithChildren>; + +export type XScrollBar = { + scrollbar: am5xy.XYChartScrollbar | null; +} & MainPanel; + +export default function XScrollBar({ + children, + stockChart, + chartRoot, + mainPanel, +}: XScrollBarProps) { + const [scrollbar, setScrollbar] = useState( + null, + ); + + const childrenWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + scrollbar, + chartRoot, + stockChart, + mainPanel, + }); + } + return child; + }); + + useEffect(() => { + if (!mainPanel || !chartRoot || !stockChart) return; + const newScrollbar = mainPanel.set( + 'scrollbarX', + am5xy.XYChartScrollbar.new(chartRoot, { + orientation: 'horizontal', + height: 50, + }), + ); + + stockChart.toolsContainer.children.push(newScrollbar); + + setScrollbar(newScrollbar); + + return () => { + stockChart.toolsContainer.children.removeValue(newScrollbar); + }; + }, [chartRoot, mainPanel, stockChart]); + + return childrenWithProps; +} diff --git a/src/features/tradeview/ui/Chart/index.tsx b/src/features/tradeview/ui/Chart/index.tsx new file mode 100644 index 0000000..bb54912 --- /dev/null +++ b/src/features/tradeview/ui/Chart/index.tsx @@ -0,0 +1,73 @@ +import type { CandlestickData } from '../../types/tradeview.type'; +import ChartContainer from './ChartContainer'; +import MainPanel from './MainPanel'; +import SbSeries from './SbSeries'; +import StockAxis from './StockAxis'; +import StockChart from './StockChart'; +import ValueSeries from './ValueSeries'; +import XScrollBar from './XScrollBar'; + +// 최근 2시간 동안의 분봉 더미 캔들스틱 데이터 생성 +const DUMMY_DATA: CandlestickData[] = (() => { + const data: CandlestickData[] = []; + const now = new Date(); + const basePrice = 50000; // 기준 가격 + const baseVolume = 1000; // 기준 거래량 + + // 최근 2시간 (120분)의 분봉 데이터 + for (let i = 119; i >= 0; i--) { + const date = new Date(); + date.setMinutes(now.getMinutes() - i); + + // 랜덤 변동폭 (이전 봉 종가의 -0.5%~0.5%) + const changePercent = (Math.random() * 1 - 0.5) / 100; + + // 시가는 이전 봉 종가에서 시작 + const open = data.length ? data[data.length - 1].Close : basePrice; + + // 종가는 시가에서 랜덤 변동 + const close = open * (1 + changePercent); + + // 고가는 시가와 종가 중 큰 값보다 0-0.5% 높게 + const highBaseValue = Math.max(open, close); + const high = highBaseValue * (1 + Math.random() * 0.005); + + // 저가는 시가와 종가 중 작은 값보다 0-0.5% 낮게 + const lowBaseValue = Math.min(open, close); + const low = lowBaseValue * (1 - Math.random() * 0.005); + + // 거래량은 기준 거래량의 50-150%, 분봉이므로 일봉보다 적게 + const volume = baseVolume * (0.5 + Math.random()); + + // 각 분 단위 타임스탬프 (ms) + const timestamp = date.getTime(); + + data.push({ + Timestamp: timestamp, + Open: Number(open.toFixed(2)), + Close: Number(close.toFixed(2)), + High: Number(high.toFixed(2)), + Low: Number(low.toFixed(2)), + Volume: Math.round(volume), + }); + } + + return data; +})(); + +export default function Chart() { + return ( + + + + + + + + + + + + + ); +} diff --git a/stats.html b/stats.html index b527ac8..8cd7d22 100644 --- a/stats.html +++ b/stats.html @@ -4929,7 +4929,7 @@