diff --git a/FE/src/components/Search/index.tsx b/FE/src/components/Search/index.tsx index 0173c5b1..0fc866c2 100644 --- a/FE/src/components/Search/index.tsx +++ b/FE/src/components/Search/index.tsx @@ -45,12 +45,10 @@ export default function SearchModal() { <> toggleSearchModal()} />
diff --git a/FE/src/components/StockIndex/Card.tsx b/FE/src/components/StockIndex/Card.tsx index f472fa3f..bd1e5dce 100644 --- a/FE/src/components/StockIndex/Card.tsx +++ b/FE/src/components/StockIndex/Card.tsx @@ -5,7 +5,7 @@ import { } from 'components/TopFive/type'; import { useEffect, useRef, useState } from 'react'; import { socket } from 'utils/socket.ts'; -import { drawChart } from 'utils/chart'; +import { drawChart } from 'utils/chart/drawChart.ts'; // const X_LENGTH = 79; diff --git a/FE/src/components/StocksDetail/Chart.tsx b/FE/src/components/StocksDetail/Chart.tsx index 26984c75..e5a8d690 100644 --- a/FE/src/components/StocksDetail/Chart.tsx +++ b/FE/src/components/StocksDetail/Chart.tsx @@ -1,14 +1,14 @@ import { useEffect, useRef, useState } from 'react'; -import { TiemCategory } from 'types'; -import { - drawBarChart, - drawCandleChart, - drawLineChart, - drawLowerYLabel, - drawUpperYLabel, -} from 'utils/chart'; +import { Padding, TiemCategory } from 'types'; import { useQuery } from '@tanstack/react-query'; import { getStocksChartDataByCode } from 'service/stocks'; +import { drawLineChart } from '../../utils/chart/drawLineChart.ts'; +import { drawCandleChart } from '../../utils/chart/drawCandleChart.ts'; +import { drawBarChart } from '../../utils/chart/drawBarChart.ts'; +import { drawXAxis } from '../../utils/chart/drawXAxis.ts'; +import { drawUpperYAxis } from '../../utils/chart/drawUpperYAxis.ts'; +import { drawLowerYAxis } from '../../utils/chart/drawLowerYAxis.ts'; +import { useDimensionsHook } from './useDimensionsHook.ts'; const categories: { label: string; value: TiemCategory }[] = [ { label: '일', value: 'D' }, @@ -17,6 +17,21 @@ const categories: { label: string; value: TiemCategory }[] = [ { label: '년', value: 'Y' }, ]; +const padding: Padding = { + top: 20, + right: 80, + bottom: 10, + left: 20, +}; + +const CHART_SIZE_CONFIG = { + upperHeight: 0.5, + lowerHeight: 0.4, + chartWidth: 0.92, + yAxisWidth: 0.08, + xAxisHeight: 0.1, +}; + type StocksDeatailChartProps = { code: string; }; @@ -36,6 +51,8 @@ export default function Chart({ code }: StocksDeatailChartProps) { () => getStocksChartDataByCode(code, timeCategory), ); + const dimension = useDimensionsHook(containerRef); + useEffect(() => { if (isLoading || !data) return; @@ -44,76 +61,71 @@ export default function Chart({ code }: StocksDeatailChartProps) { const lowerChartCanvas = lowerChartCanvasRef.current; const upperChartYCanvas = upperChartY.current; const lowerChartYCanvas = lowerChartY.current; + const chartXCanvas = chartX.current; if ( !parent || !upperChartCanvas || !lowerChartCanvas || !upperChartYCanvas || - !lowerChartYCanvas + !lowerChartYCanvas || + !chartXCanvas ) return; - const displayWidth = parent.clientWidth; - const displayHeight = parent.clientHeight; - - const upperHeight = displayHeight * 0.5; - const lowerHeight = displayHeight * 0.3; - const chartWidth = displayWidth * 0.9; - const yAxisWidth = displayWidth * 0.1; - - // 차트 영역 설정 - upperChartCanvas.width = chartWidth * 2; - upperChartCanvas.height = upperHeight * 2; - upperChartCanvas.style.width = `${chartWidth}px`; - upperChartCanvas.style.height = `${upperHeight}px`; - - upperChartYCanvas.width = yAxisWidth * 2; - upperChartYCanvas.height = upperHeight * 2; - upperChartYCanvas.style.width = `${yAxisWidth}px`; - upperChartYCanvas.style.height = `${upperHeight}px`; - - lowerChartCanvas.width = chartWidth * 2; - lowerChartCanvas.height = lowerHeight * 2; - lowerChartCanvas.style.width = `${chartWidth}px`; - lowerChartCanvas.style.height = `${lowerHeight}px`; - - lowerChartYCanvas.width = yAxisWidth * 2; - lowerChartYCanvas.height = lowerHeight * 2; - lowerChartYCanvas.style.width = `${yAxisWidth}px`; - lowerChartYCanvas.style.height = `${lowerHeight}px`; + upperChartCanvas.width = dimension.width * CHART_SIZE_CONFIG.chartWidth * 2; + upperChartCanvas.height = + dimension.height * CHART_SIZE_CONFIG.upperHeight * 2; + upperChartCanvas.style.width = `${dimension.width * CHART_SIZE_CONFIG.chartWidth}px`; + upperChartCanvas.style.height = `${dimension.height * CHART_SIZE_CONFIG.upperHeight}px`; + + upperChartYCanvas.width = + dimension.width * CHART_SIZE_CONFIG.yAxisWidth * 2; + upperChartYCanvas.height = + dimension.height * CHART_SIZE_CONFIG.upperHeight * 2; + upperChartYCanvas.style.width = `${dimension.width * CHART_SIZE_CONFIG.yAxisWidth}px`; + upperChartYCanvas.style.height = `${dimension.height * CHART_SIZE_CONFIG.upperHeight}px`; + + lowerChartCanvas.width = dimension.width * CHART_SIZE_CONFIG.chartWidth * 2; + lowerChartCanvas.height = + dimension.height * CHART_SIZE_CONFIG.lowerHeight * 2; + lowerChartCanvas.style.width = `${dimension.width * CHART_SIZE_CONFIG.chartWidth}px`; + lowerChartCanvas.style.height = `${dimension.height * CHART_SIZE_CONFIG.lowerHeight}px`; + + lowerChartYCanvas.width = + dimension.width * CHART_SIZE_CONFIG.yAxisWidth * 2; + lowerChartYCanvas.height = + dimension.height * CHART_SIZE_CONFIG.lowerHeight * 2; + lowerChartYCanvas.style.width = `${dimension.width * CHART_SIZE_CONFIG.yAxisWidth}px`; + lowerChartYCanvas.style.height = `${dimension.height * CHART_SIZE_CONFIG.lowerHeight}px`; + + chartXCanvas.width = dimension.width * CHART_SIZE_CONFIG.chartWidth * 2; + chartXCanvas.height = dimension.height * CHART_SIZE_CONFIG.xAxisHeight * 2; + chartXCanvas.style.width = `${dimension.width * CHART_SIZE_CONFIG.chartWidth}px`; + chartXCanvas.style.height = `${dimension.height * CHART_SIZE_CONFIG.xAxisHeight}px`; const UpperChartCtx = upperChartCanvas.getContext('2d'); const LowerChartCtx = lowerChartCanvas.getContext('2d'); const UpperYCtx = upperChartYCanvas.getContext('2d'); const LowerYCtx = lowerChartYCanvas.getContext('2d'); + const ChartXCtx = chartXCanvas.getContext('2d'); - if (!UpperChartCtx || !LowerChartCtx || !UpperYCtx || !LowerYCtx) return; - - const padding = { - top: 20, - right: 60, - bottom: 10, - left: 20, - }; - - const upperChartWidth = - upperChartCanvas.width - padding.left - padding.right; - const upperChartHeight = - upperChartCanvas.height - padding.top - padding.bottom; - const lowerChartWidth = - lowerChartCanvas.width - padding.left - padding.right; - const lowerChartHeight = lowerChartCanvas.height; - - const arr = data.map((e) => +e.stck_oprc); + if ( + !UpperChartCtx || + !LowerChartCtx || + !UpperYCtx || + !LowerYCtx || + !ChartXCtx + ) + return; drawLineChart( UpperChartCtx, - arr, + data, 0, 0, - upperChartWidth, - upperChartHeight, + upperChartCanvas.width - padding.left - padding.right, + upperChartCanvas.height - padding.top - padding.bottom, padding, 0.1, ); @@ -123,8 +135,8 @@ export default function Chart({ code }: StocksDeatailChartProps) { data, 0, 0, - upperChartWidth, - upperChartHeight, + upperChartCanvas.width - padding.left - padding.right, + upperChartCanvas.height - padding.top - padding.bottom, padding, 0.1, ); @@ -133,14 +145,12 @@ export default function Chart({ code }: StocksDeatailChartProps) { drawBarChart( LowerChartCtx, data, - 0, - 0, - lowerChartWidth, - lowerChartHeight - padding.bottom, + lowerChartCanvas.width - padding.left - padding.right, + lowerChartCanvas.height - padding.top - padding.bottom, padding, ); - drawUpperYLabel( + drawUpperYAxis( UpperYCtx, data, upperChartYCanvas.width - padding.left - padding.right, @@ -149,17 +159,26 @@ export default function Chart({ code }: StocksDeatailChartProps) { 0.1, ); - drawLowerYLabel( + drawLowerYAxis( LowerYCtx, + data, lowerChartYCanvas.width - padding.left - padding.right, lowerChartYCanvas.height - padding.top - padding.bottom, padding, ); + + drawXAxis( + ChartXCtx, + data, + chartXCanvas.width - padding.left - padding.right, + chartXCanvas.height, + padding, + ); }, [timeCategory, data, isLoading]); return ( -
-
+
+

차트

-
+
{/* Upper 차트 영역 */} -
- - +
+ + +
+
+
{/* Lower 차트 영역 */} -
- - +
+ +
{/* X축 영역 */} -
- +
+
diff --git a/FE/src/components/StocksDetail/PriceSection.tsx b/FE/src/components/StocksDetail/PriceSection.tsx index 8266d8b5..0030c070 100644 --- a/FE/src/components/StocksDetail/PriceSection.tsx +++ b/FE/src/components/StocksDetail/PriceSection.tsx @@ -3,51 +3,48 @@ import PriceTableColumn from './PriceTableColumn.tsx'; import PriceTableLiveCard from './PriceTableLiveCard.tsx'; import PriceTableDayCard from './PriceTableDayCard.tsx'; import { useParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { DailyPriceDataType, PriceDataType } from './PriceDataType.ts'; import { getTradeHistory } from 'service/getTradeHistory.ts'; -// import { createSSEConnection } from './PriceSectionSseHook.ts'; +import { createSSEConnection } from './PriceSectionSseHook.ts'; export default function PriceSection() { + const { id } = useParams(); const [buttonFlag, setButtonFlag] = useState(true); const indicatorRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); - const { id } = useParams(); - // const [tradeData, setTradeData] = useState([]); - // const queryClient = useQueryClient(); - const { data: initialData = [], isLoading } = useQuery({ + const queryClient = useQueryClient(); + + const { data: tradeData = [], isLoading } = useQuery({ queryKey: ['detail', id, buttonFlag], queryFn: () => getTradeHistory(id as string, buttonFlag), - // refetchInterval: 1000, cacheTime: 30000, staleTime: 1000, }); - // const addData = (newData: PriceDataType) => { - // setTradeData((prev) => [...prev, newData]); - // - // queryClient.setQueryData( - // ['detail', id, buttonFlag], - // (old: PriceDataType[] = []) => { - // return [...old, newData]; - // }, - // ); - // }; + const addData = (newData: PriceDataType) => { + queryClient.setQueryData( + ['detail', id, buttonFlag], + (old: PriceDataType[] = []) => { + return [newData, ...old].slice(0, 30); + }, + ); + }; + + useEffect(() => { + if (!buttonFlag) return; + const eventSource = createSSEConnection( + `http://223.130.151.42:3000/api/stocks/trade-history/${id}/today-sse`, + addData, + ); - // useEffect(() => { - // const targetWord = buttonFlag ? 'today' : 'daily'; - // - // const eventSource = createSSEConnection( - // `http://223.130.151.42:3000/api/stocks/trade-history/${id}/${targetWord}-sse`, - // addData, - // ); - // - // return () => { - // if (eventSource) { - // eventSource.close(); - // } - // }; - // }, [buttonFlag, id]); + return () => { + if (eventSource) { + console.log('SSE connection close'); + eventSource.close(); + } + }; + }, [buttonFlag, id]); useEffect(() => { const tmpIndex = buttonFlag ? 0 : 1; @@ -60,10 +57,6 @@ export default function PriceSection() { } }, [buttonFlag]); - // useEffect(() => { - // setTradeData(initialData); - // }, [initialData]); - return (
Loading... - ) : !initialData ? ( + ) : !tradeData ? ( No data available ) : buttonFlag ? ( - initialData.map((eachData: PriceDataType, index: number) => ( + tradeData.map((eachData: PriceDataType, index: number) => ( )) ) : ( - initialData.map( - (eachData: DailyPriceDataType, index: number) => ( - - ), - ) + tradeData.map((eachData: DailyPriceDataType, index: number) => ( + + )) )} diff --git a/FE/src/components/StocksDetail/PriceSectionSseHook.ts b/FE/src/components/StocksDetail/PriceSectionSseHook.ts index e384b06c..237657e2 100644 --- a/FE/src/components/StocksDetail/PriceSectionSseHook.ts +++ b/FE/src/components/StocksDetail/PriceSectionSseHook.ts @@ -9,7 +9,7 @@ export const createSSEConnection = ( eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); - onMessage(data); + onMessage(data.tradeData); } catch (error) { console.error('Failed to parse SSE message:', error); } diff --git a/FE/src/components/StocksDetail/useDimensionsHook.ts b/FE/src/components/StocksDetail/useDimensionsHook.ts new file mode 100644 index 00000000..e0102ef2 --- /dev/null +++ b/FE/src/components/StocksDetail/useDimensionsHook.ts @@ -0,0 +1,28 @@ +import { RefObject, useCallback, useEffect, useState } from 'react'; + +type ChartDimensions = { + width: number; + height: number; +}; + +export const useDimensionsHook = (containerRef: RefObject) => { + const [dimensions, setDimensions] = useState({ + width: 0, + height: 0, + }); + + const updateDimensions = useCallback(() => { + if (!containerRef.current) return; + + setDimensions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + }, []); + + useEffect(() => { + updateDimensions(); + }, [updateDimensions]); + + return dimensions; +}; diff --git a/FE/src/components/TopFive/Card.tsx b/FE/src/components/TopFive/Card.tsx index eb27b660..819efcf0 100644 --- a/FE/src/components/TopFive/Card.tsx +++ b/FE/src/components/TopFive/Card.tsx @@ -1,4 +1,7 @@ +import { useNavigate } from 'react-router-dom'; + type CardProps = { + code: string; name: string; price: string; changePercentage: string; @@ -7,6 +10,7 @@ type CardProps = { }; export default function Card({ + code, name, price, changePercentage, @@ -20,8 +24,17 @@ export default function Card({ const changeColor = changeValue > 0 ? 'text-juga-red-60' : 'text-juga-blue-50'; + const navigation = useNavigate(); + + const handleClick = () => { + navigation(`/stocks/${code}`); + }; + return ( -
+
{index + 1}

diff --git a/FE/src/components/TopFive/List.tsx b/FE/src/components/TopFive/List.tsx index a5690518..49a8f6f3 100644 --- a/FE/src/components/TopFive/List.tsx +++ b/FE/src/components/TopFive/List.tsx @@ -10,7 +10,7 @@ type ListProps = { export default function List({ listTitle, data, isLoading }: ListProps) { return ( -

+
{listTitle}
종목
@@ -29,6 +29,7 @@ export default function List({ listTitle, data, isLoading }: ListProps) { className='transition-colors hover:bg-gray-50' > d)) * (1 + weight)); - const yMin = Math.round(Math.min(...data.map((d) => d)) * (1 - weight)); - - data.forEach((e, i) => { - const cx = x + padding.left + (width * i) / (n - 1); - const cy = y + padding.top + height - (height * (e - yMin)) / (yMax - yMin); - - if (i === 0) { - ctx.moveTo(cx, cy); - } else { - ctx.lineTo(cx, cy); - } - }); - - ctx.lineWidth = lineWidth; - ctx.stroke(); -} - -export function drawBarChart( - ctx: CanvasRenderingContext2D, - data: StockChartUnit[], - x: number, - y: number, - width: number, - height: number, - padding: Padding, -) { - if (data.length === 0) return; - const n = data.length; - - // 캔버스 초기화 - ctx.clearRect( - 0, - 0, - width + padding.left + padding.right, - height + padding.top + padding.bottom, - ); - - const yMax = Math.round(Math.max(...data.map((d) => +d.acml_vol)) * 1.2); - const yMin = Math.round(Math.min(...data.map((d) => +d.acml_vol)) * 0.8); - - const gap = Math.floor(width / n); - - const blue = '#2175F3'; - const red = '#FF3700'; - - ctx.beginPath(); - ctx.moveTo(padding.left, height + 4); - ctx.lineTo(width + padding.left + padding.right, height + 4); - ctx.strokeStyle = '#D2DAE0'; - ctx.lineWidth = 2; - ctx.stroke(); - - data.forEach((e, i) => { - ctx.beginPath(); - const cx = x + padding.left + (width * i) / (n - 1); - const cy = - padding.top + ((height - y) * (+e.acml_vol - yMin)) / (yMax - yMin); - - ctx.fillStyle = +e.stck_oprc < +e.stck_clpr ? red : blue; - ctx.fillRect(cx, height, gap, -cy); - }); -} - -export function drawCandleChart( - ctx: CanvasRenderingContext2D, - data: StockChartUnit[], - x: number, - y: number, - width: number, - height: number, - padding: Padding, - weight: number = 0, // 0~1 y축 범위 가중치 -) { - ctx.beginPath(); - - const n = data.length; - - const arr = data.map((d) => - Math.max(+d.stck_clpr, +d.stck_oprc, +d.stck_hgpr, +d.stck_lwpr), - ); - - const yMax = Math.round(Math.max(...arr) * (1 + weight)); - const yMin = Math.round(Math.min(...arr) * (1 - weight)); - - data.forEach((e, i) => { - ctx.beginPath(); - - const { stck_oprc, stck_clpr, stck_hgpr, stck_lwpr } = e; - const gap = Math.floor(width / n); - const cx = x + padding.left + (width * i) / (n - 1); - - const openY = - y + padding.top + height - (height * (+stck_oprc - yMin)) / (yMax - yMin); - const closeY = - y + padding.top + height - (height * (+stck_clpr - yMin)) / (yMax - yMin); - const highY = - y + padding.top + height - (height * (+stck_hgpr - yMin)) / (yMax - yMin); - const lowY = - y + padding.top + height - (height * (+stck_lwpr - yMin)) / (yMax - yMin); - - const blue = '#2175F3'; - const red = '#FF3700'; - - if (+stck_oprc > +stck_clpr) { - ctx.fillStyle = blue; - ctx.strokeStyle = blue; - ctx.fillRect(cx, closeY, gap, openY - closeY); - } else { - ctx.fillStyle = red; - ctx.strokeStyle = red; - ctx.fillRect(cx, openY, gap, closeY - openY); - } - - const middle = cx + Math.floor(gap / 2); - - ctx.moveTo(middle, highY); - ctx.lineTo(middle, lowY); - ctx.stroke(); - }); -} - -export const drawChart = ( - ctx: CanvasRenderingContext2D, - data: { time: string; value: string; diff: string }[], - xLength: number, -) => { - const n = data.length; - - const canvas = ctx.canvas; - const width = canvas.width; - const height = canvas.height; - - ctx.clearRect(0, 0, width, height); - - const padding = { - top: 10, - right: 10, - bottom: 10, - left: 10, - }; - - const chartWidth = width - padding.left - padding.right; - const chartHeight = height - padding.top - padding.bottom; - - const MIDDLE = - n > 0 - ? Number( - (parseFloat(data[0].value) - parseFloat(data[0].diff)).toFixed(2), - ) - : 50; - - const yMax = Math.max( - Math.round(Math.max(...data.map((d) => Number(d.value))) * 1.006 * 100), - MIDDLE * 100, - ); - const yMin = Math.min( - Math.round(Math.min(...data.map((d) => Number(d.value))) * 0.994 * 100), - MIDDLE * 100, - ); - - data.sort((a, b) => { - if (a.time < b.time) return -1; - if (a.time > b.time) return 1; - return 0; - }); - - const middleY = - padding.top + - chartHeight - - (chartHeight * (MIDDLE * 100 - yMin)) / (yMax - yMin); - ctx.beginPath(); - ctx.setLineDash([10, 10]); - ctx.moveTo(padding.left, middleY); - ctx.lineTo(width - padding.right, middleY); - ctx.strokeStyle = '#6E8091'; - ctx.lineWidth = 1; - ctx.stroke(); - ctx.setLineDash([]); - - // 데이터 선 그리기 - if (n > 1) { - ctx.beginPath(); - data.forEach((point, i) => { - const value = Math.round(Number(point.value) * 100); - const x = padding.left + (chartWidth * i) / (xLength - 1); - const y = - padding.top + - chartHeight - - (chartHeight * (value - yMin)) / (yMax - yMin); - - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - }); - - const currentValue = Number(data[n - 1].value); - if (currentValue >= MIDDLE) { - ctx.strokeStyle = '#FF3700'; - } else { - ctx.strokeStyle = '#2175F3'; - } - ctx.lineWidth = 3; - ctx.stroke(); - } -}; - -export const drawUpperYLabel = ( - ctx: CanvasRenderingContext2D, - data: StockChartUnit[], - width: number, - height: number, - padding: Padding, - weight: number = 0, -) => { - const values = data - .map((d) => [+d.stck_hgpr, +d.stck_lwpr, +d.stck_clpr, +d.stck_oprc]) - .flat(); - - const yMax = Math.round(Math.max(...values) * (1 + weight)); - const yMin = Math.round(Math.min(...values) * (1 - weight)); - - ctx.clearRect( - 0, - 0, - width + padding.left + padding.right, - height + padding.top + padding.bottom, - ); - - // 라벨 갯수 - const step = Math.ceil((yMax - yMin) / 3); - const labels = []; - for (let value = yMin; value <= yMax; value += step) { - labels.push(Math.round(value)); - } - if (!labels.includes(yMax)) { - labels.push(yMax); - } - - ctx.font = '24px sans-serif'; - ctx.fillStyle = '#000'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - - labels.forEach((label) => { - const yPos = - padding.top + height - ((label - yMin) / (yMax - yMin)) * height; - - const formattedValue = label.toLocaleString(); - ctx.fillText(formattedValue, width / 2, yPos); - }); - - ctx.beginPath(); - ctx.moveTo(padding.left, 0); - ctx.lineTo(padding.left, height + padding.top + padding.bottom); - ctx.strokeStyle = '#D2DAE0'; - ctx.lineWidth = 2; - ctx.stroke(); -}; - -export const drawLowerYLabel = ( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - padding: Padding, -) => { - ctx.clearRect( - 0, - 0, - width + padding.left + padding.right, - height + padding.top + padding.bottom, - ); - - // Y축 선 그리기 - ctx.beginPath(); - ctx.moveTo(padding.left, 0); - ctx.lineTo(padding.left, height + padding.top + 4); - ctx.strokeStyle = '#D2DAE0'; - ctx.lineWidth = 2; - ctx.stroke(); - - // X축 선 그리기 (바닥 선) - ctx.beginPath(); - ctx.moveTo(0, height + padding.top + 4); - ctx.lineTo(padding.left, height + padding.top + 4); - ctx.stroke(); -}; diff --git a/FE/src/utils/chart/drawBarChart.ts b/FE/src/utils/chart/drawBarChart.ts new file mode 100644 index 00000000..6fdda083 --- /dev/null +++ b/FE/src/utils/chart/drawBarChart.ts @@ -0,0 +1,60 @@ +import { Padding, StockChartUnit } from '../../types.ts'; +import { makeYLabels } from './makeLabels.ts'; + +export function drawBarChart( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + width: number, + height: number, + padding: Padding, +) { + if (data.length === 0) return; + + ctx.clearRect( + 0, + 0, + width + padding.left + padding.right, + height + padding.top + padding.bottom, + ); + + const volumes = data.map((d) => +d.acml_vol); + const yMax = Math.round(Math.max(...volumes) * 1.2); + const yMin = Math.round(Math.min(...volumes) * 0.8); + const barWidth = Math.floor(width / data.length); + + const labels = makeYLabels(yMax, yMin, 2); + + ctx.beginPath(); + + labels.forEach((label) => { + const valueRatio = (label - yMin) / (yMax - yMin); + const yPos = height - valueRatio * height; + + ctx.moveTo(0, yPos + padding.top); + ctx.lineTo(width + padding.left + padding.right, yPos + padding.top); + }); + + ctx.moveTo(0, height + padding.top); + ctx.lineTo(width + padding.left + padding.right, height + padding.top); + ctx.strokeStyle = '#D2DAE0'; + ctx.lineWidth = 2; + ctx.stroke(); + + data.forEach((item, i) => { + const value = +item.acml_vol; + const valueRatio = (value - yMin) / (yMax - yMin); + + const barX = padding.left + (width * i) / (data.length - 1); + const barHeight = valueRatio * height; + + ctx.beginPath(); + ctx.fillStyle = +item.stck_oprc < +item.stck_clpr ? '#FF3700' : '#2175F3'; + + ctx.fillRect( + barX, + height + padding.top, + barWidth, + -(barHeight + padding.bottom), + ); + }); +} diff --git a/FE/src/utils/chart/drawCandleChart.ts b/FE/src/utils/chart/drawCandleChart.ts new file mode 100644 index 00000000..13402943 --- /dev/null +++ b/FE/src/utils/chart/drawCandleChart.ts @@ -0,0 +1,73 @@ +import { Padding, StockChartUnit } from '../../types.ts'; +import { makeYLabels } from './makeLabels.ts'; + +export function drawCandleChart( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + x: number, + y: number, + width: number, + height: number, + padding: Padding, + weight: number = 0, +) { + ctx.beginPath(); + + const n = data.length; + + const values = data + .map((d) => [+d.stck_hgpr, +d.stck_lwpr, +d.stck_clpr, +d.stck_oprc]) + .flat(); + const yMax = Math.round(Math.max(...values) * (1 + weight)); + const yMin = Math.round(Math.min(...values) * (1 - weight)); + + const labels = makeYLabels(yMax, yMin, 3); + + ctx.beginPath(); + labels.forEach((label) => { + const yPos = + padding.top + height - ((label - yMin) / (yMax - yMin)) * height; + + ctx.moveTo(0, yPos); + ctx.lineTo(width + padding.left + padding.right, yPos); + }); + ctx.strokeStyle = '#D2DAE0'; + ctx.lineWidth = 2; + ctx.stroke(); + + data.forEach((e, i) => { + ctx.beginPath(); + + const { stck_oprc, stck_clpr, stck_hgpr, stck_lwpr } = e; + const gap = Math.floor(width / n); + const cx = x + padding.left + (width * i) / (n - 1); + + const openY = + y + padding.top + height - (height * (+stck_oprc - yMin)) / (yMax - yMin); + const closeY = + y + padding.top + height - (height * (+stck_clpr - yMin)) / (yMax - yMin); + const highY = + y + padding.top + height - (height * (+stck_hgpr - yMin)) / (yMax - yMin); + const lowY = + y + padding.top + height - (height * (+stck_lwpr - yMin)) / (yMax - yMin); + + const blue = '#2175F3'; + const red = '#FF3700'; + + if (+stck_oprc > +stck_clpr) { + ctx.fillStyle = blue; + ctx.strokeStyle = blue; + ctx.fillRect(cx, closeY, gap, openY - closeY); + } else { + ctx.fillStyle = red; + ctx.strokeStyle = red; + ctx.fillRect(cx, openY, gap, closeY - openY); + } + + const middle = cx + Math.floor(gap / 2); + + ctx.moveTo(middle, highY); + ctx.lineTo(middle, lowY); + ctx.stroke(); + }); +} diff --git a/FE/src/utils/chart/drawChart.ts b/FE/src/utils/chart/drawChart.ts new file mode 100644 index 00000000..8c22f7a0 --- /dev/null +++ b/FE/src/utils/chart/drawChart.ts @@ -0,0 +1,86 @@ +export const drawChart = ( + ctx: CanvasRenderingContext2D, + data: { time: string; value: string; diff: string }[], + xLength: number, +) => { + const n = data.length; + + const canvas = ctx.canvas; + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + + const padding = { + top: 10, + right: 10, + bottom: 10, + left: 10, + }; + + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + const MIDDLE = + n > 0 + ? Number( + (parseFloat(data[0].value) - parseFloat(data[0].diff)).toFixed(2), + ) + : 50; + + const yMax = Math.max( + Math.round(Math.max(...data.map((d) => Number(d.value))) * 1.006 * 100), + MIDDLE * 100, + ); + const yMin = Math.min( + Math.round(Math.min(...data.map((d) => Number(d.value))) * 0.994 * 100), + MIDDLE * 100, + ); + + data.sort((a, b) => { + if (a.time < b.time) return -1; + if (a.time > b.time) return 1; + return 0; + }); + + const middleY = + padding.top + + chartHeight - + (chartHeight * (MIDDLE * 100 - yMin)) / (yMax - yMin); + ctx.beginPath(); + ctx.setLineDash([10, 10]); + ctx.moveTo(padding.left, middleY); + ctx.lineTo(width - padding.right, middleY); + ctx.strokeStyle = '#6E8091'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.setLineDash([]); + + // 데이터 선 그리기 + if (n > 1) { + ctx.beginPath(); + data.forEach((point, i) => { + const value = Math.round(Number(point.value) * 100); + const x = padding.left + (chartWidth * i) / (xLength - 1); + const y = + padding.top + + chartHeight - + (chartHeight * (value - yMin)) / (yMax - yMin); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + + const currentValue = Number(data[n - 1].value); + if (currentValue >= MIDDLE) { + ctx.strokeStyle = '#FF3700'; + } else { + ctx.strokeStyle = '#2175F3'; + } + ctx.lineWidth = 3; + ctx.stroke(); + } +}; diff --git a/FE/src/utils/chart/drawLineChart.ts b/FE/src/utils/chart/drawLineChart.ts new file mode 100644 index 00000000..bc0da5dc --- /dev/null +++ b/FE/src/utils/chart/drawLineChart.ts @@ -0,0 +1,43 @@ +import { Padding, StockChartUnit } from '../../types.ts'; + +export function drawLineChart( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + x: number, + y: number, + width: number, + height: number, + padding: Padding, + weight: number = 0, + lineWidth: number = 1, +) { + if (data.length === 0) return; + + ctx.beginPath(); + + const n = data.length; + const yMax = Math.round( + Math.max(...data.map((d: StockChartUnit) => +d.stck_oprc)) * (1 + weight), + ); + const yMin = Math.round( + Math.min(...data.map((d: StockChartUnit) => +d.stck_oprc)) * (1 - weight), + ); + + data.forEach((e, i) => { + const cx = x + padding.left + (width * i) / (n - 1); + const cy = + y + + padding.top + + height - + (height * (+e.stck_oprc - yMin)) / (yMax - yMin); + + if (i === 0) { + ctx.moveTo(cx, cy); + } else { + ctx.lineTo(cx, cy); + } + }); + + ctx.lineWidth = lineWidth; + ctx.stroke(); +} diff --git a/FE/src/utils/chart/drawLowerYAxis.ts b/FE/src/utils/chart/drawLowerYAxis.ts new file mode 100644 index 00000000..48268d7d --- /dev/null +++ b/FE/src/utils/chart/drawLowerYAxis.ts @@ -0,0 +1,81 @@ +import { Padding, StockChartUnit } from '../../types.ts'; +import { makeYLabels } from './makeLabels.ts'; + +export const drawLowerYAxis = ( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + width: number, + height: number, + padding: Padding, +) => { + ctx.clearRect( + 0, + 0, + width + padding.left + padding.right, + height + padding.top + padding.bottom, + ); + + ctx.beginPath(); + ctx.moveTo(padding.left, 0); + ctx.lineTo(padding.left, height + padding.top); + ctx.strokeStyle = '#D2DAE0'; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, height + padding.top); + ctx.lineTo(padding.left, height + padding.top); + ctx.stroke(); + + const yMax = Math.round(Math.max(...data.map((d) => +d.acml_vol)) * 1.2); + const yMin = Math.round(Math.min(...data.map((d) => +d.acml_vol)) * 0.8); + + const labels = makeYLabels(yMax, yMin, 2); + ctx.font = '24px sans-serif'; + ctx.fillStyle = '#000'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + ctx.beginPath(); + labels.forEach((label) => { + const valueRatio = (label - yMin) / (yMax - yMin); + const yPos = height - valueRatio * height; + const formattedValue = formatNumber(label); + ctx.moveTo(0, yPos + padding.top); + ctx.lineTo(padding.left, yPos + padding.top); + ctx.fillText(formattedValue, width / 2 + padding.left, yPos + padding.top); + }); + ctx.strokeStyle = '#D2DAE0'; + ctx.lineWidth = 2; + ctx.stroke(); +}; + +const formatNumber = (value: number) => { + const absValue = Math.abs(value); + + if (absValue >= 1_000_000_000) { + const inBillions = value / 1_000_000_000; + const rounded = Math.round(inBillions * 10) / 10; + return rounded % 1 === 0 + ? `${rounded.toFixed(0)}B` + : `${rounded.toFixed(1)}B`; + } + + if (absValue >= 1_000_000) { + const inMillions = value / 1_000_000; + const rounded = Math.round(inMillions * 10) / 10; + return rounded % 1 === 0 + ? `${rounded.toFixed(0)}M` + : `${rounded.toFixed(1)}M`; + } + + if (absValue >= 1_000) { + const inThousands = value / 1_000; + const rounded = Math.round(inThousands * 10) / 10; + return rounded % 1 === 0 + ? `${rounded.toFixed(0)}K` + : `${rounded.toFixed(1)}K`; + } + + return value.toString(); +}; diff --git a/FE/src/utils/chart/drawUpperYAxis.ts b/FE/src/utils/chart/drawUpperYAxis.ts new file mode 100644 index 00000000..e6b1ae18 --- /dev/null +++ b/FE/src/utils/chart/drawUpperYAxis.ts @@ -0,0 +1,48 @@ +import { Padding, StockChartUnit } from '../../types.ts'; +import { makeYLabels } from './makeLabels.ts'; + +export const drawUpperYAxis = ( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + width: number, + height: number, + padding: Padding, + weight: number = 0, +) => { + const values = data + .map((d) => [+d.stck_hgpr, +d.stck_lwpr, +d.stck_clpr, +d.stck_oprc]) + .flat(); + const yMax = Math.round(Math.max(...values) * (1 + weight)); + const yMin = Math.round(Math.min(...values) * (1 - weight)); + + ctx.clearRect( + 0, + 0, + width + padding.left + padding.right, + height + padding.top + padding.bottom, + ); + + const labels = makeYLabels(yMax, yMin, 3); + + ctx.font = '24px sans-serif'; + ctx.fillStyle = '#000'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.beginPath(); + + labels.forEach((label) => { + const yPos = + padding.top + height - ((label - yMin) / (yMax - yMin)) * height; + + const formattedValue = label.toLocaleString(); + ctx.moveTo(0, yPos); + ctx.lineTo(padding.left, yPos); + ctx.fillText(formattedValue, width / 2 + padding.left, yPos); + }); + + ctx.moveTo(padding.left, 0); + ctx.lineTo(padding.left, height + padding.top + padding.bottom); + ctx.strokeStyle = '#D2DAE0'; + ctx.lineWidth = 2; + ctx.stroke(); +}; diff --git a/FE/src/utils/chart/drawXAxis.ts b/FE/src/utils/chart/drawXAxis.ts new file mode 100644 index 00000000..15b754d5 --- /dev/null +++ b/FE/src/utils/chart/drawXAxis.ts @@ -0,0 +1,34 @@ +import { Padding, StockChartUnit } from '../../types.ts'; +import { makeXLabels } from './makeLabels.ts'; + +export const drawXAxis = ( + ctx: CanvasRenderingContext2D, + data: StockChartUnit[], + width: number, + height: number, + padding: Padding, +) => { + const labels = makeXLabels(data); + + ctx.clearRect( + 0, + 0, + width + padding.left + padding.right, + height + padding.top + padding.bottom, + ); + + ctx.font = '20px Arial'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#000'; + + const barWidth = Math.floor(width / data.length); + data.forEach((item, i) => { + if (labels.includes(item.stck_bsop_date) || i === data.length - 1) { + ctx.fillText( + item.stck_bsop_date, + padding.left + (width * i) / (data.length - 1) + barWidth / 2, + height / 2, + ); + } + }); +}; diff --git a/FE/src/utils/chart/makeLabels.ts b/FE/src/utils/chart/makeLabels.ts new file mode 100644 index 00000000..a2f20e81 --- /dev/null +++ b/FE/src/utils/chart/makeLabels.ts @@ -0,0 +1,40 @@ +import { StockChartUnit } from '../../types.ts'; + +export const makeYLabels = ( + yMax: number, + yMin: number, + divideNumber: number, +) => { + const labels = []; + const rawTickInterval = Math.ceil((yMax - yMin) / divideNumber); + const magnitude = 10 ** (String(rawTickInterval).length - 1); + const tickInterval = Math.floor(rawTickInterval / magnitude) * magnitude; + const startValue = Math.ceil(yMin / tickInterval) * tickInterval; + + for (let value = startValue; value <= yMax; value += tickInterval) { + labels.push(Math.round(value)); + } + + return labels; +}; + +export const makeXLabels = (data: StockChartUnit[]) => { + const totalData = data.length; + + // 데이터 양에 따른 표시 간격 결정 + let interval: number; + if (totalData <= 10) { + interval = 1; + } else if (totalData <= 20) { + interval = 2; + } else if (totalData <= 30) { + interval = 5; + } else { + interval = 6; + } + + // 선택된 날짜만 라벨로 반환 + return data + .filter((_, index) => index % interval === 0) + .map((item) => item.stck_bsop_date); +};