diff --git a/src/entities/market/model/apis/candle.api.ts b/src/entities/market/model/apis/candle.api.ts new file mode 100644 index 0000000..82a28e5 --- /dev/null +++ b/src/entities/market/model/apis/candle.api.ts @@ -0,0 +1,36 @@ +import { UPBIT_URL } from "@/shared"; + +import { CandleData, CandleParams, MinutesCandleParams } from "../types"; + +export const candleAPI = async (params: CandleParams): Promise => { + const { market, to, count, type } = params; + + // URL 파라미터 구성 + const urlParams = new URLSearchParams({ + market, + count: count.toString(), + }); + + if (to) { + urlParams.append("to", to); + } + + // 타입별 엔드포인트 구성 + let endpoint = `${UPBIT_URL}/candles/${type}`; + + // 분 캔들의 경우 unit을 Path 파라미터로 추가 + if (type === "minutes") { + const unit = (params as MinutesCandleParams).unit; + endpoint = `${UPBIT_URL}/candles/minutes/${unit}`; + } + + const url = `${endpoint}?${urlParams.toString()}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Candle API error: ${response.status} ${response.statusText}`); + } + + const data: CandleData[] = await response.json(); + return data; +}; diff --git a/src/entities/market/model/apis/index.ts b/src/entities/market/model/apis/index.ts index 0c0333e..d83889a 100644 --- a/src/entities/market/model/apis/index.ts +++ b/src/entities/market/model/apis/index.ts @@ -1,3 +1,4 @@ +export * from "./candle.api"; export * from "./market-all.api"; export * from "./market-order.api"; export * from "./ticker.api"; diff --git a/src/entities/market/model/hooks/index.ts b/src/entities/market/model/hooks/index.ts index a81e6bb..0ae7419 100644 --- a/src/entities/market/model/hooks/index.ts +++ b/src/entities/market/model/hooks/index.ts @@ -1,3 +1,4 @@ export * from "./useGetMarketOrder"; export * from "./useMarketOrderRealtime"; export * from "./useMarket"; +export * from "./useGetCandle"; diff --git a/src/entities/market/model/hooks/useGetCandle.ts b/src/entities/market/model/hooks/useGetCandle.ts new file mode 100644 index 0000000..a55d241 --- /dev/null +++ b/src/entities/market/model/hooks/useGetCandle.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +import { candleAPI } from "../apis"; +import type { CandleParams, MinutesCandleParams } from "../types"; + +export const useGetCandle = (params: CandleParams) => { + const { market, count, type } = params; + + // 분 캔들일 때만 unit을 queryKey에 포함 + const unit = type === "minutes" ? (params as MinutesCandleParams).unit : undefined; + + const queryKey = unit ? ["candle", market, count, type, unit] : ["candle", market, count, type]; + + return useQuery({ + queryKey, + queryFn: () => candleAPI(params), + + refetchInterval: type === "seconds" || type === "minutes" ? 500 : false, + }); +}; diff --git a/src/entities/market/model/types/candle.type.ts b/src/entities/market/model/types/candle.type.ts new file mode 100644 index 0000000..ce71215 --- /dev/null +++ b/src/entities/market/model/types/candle.type.ts @@ -0,0 +1,36 @@ +/** + * 캔들 타입 + * - seconds: 초 단위 (1초 고정) + * - minutes: 분 단위 (1, 3, 5, 10, 15, 30, 60, 240분) + * - days: 일 단위 + * - weeks: 주 단위 + * - months: 월 단위 + */ +export type CandleType = "seconds" | "minutes" | "days" | "weeks" | "months"; + +/** + * 분 단위 캔들 옵션 + * Upbit API에서 지원하는 분 단위 + */ +export type MinuteUnit = 1 | 3 | 5 | 10 | 15 | 30 | 60 | 240; + +type SecondsCandleParams = { + type: "seconds"; +} & BaseCandleParams; + +export type MinutesCandleParams = { + type: "minutes"; + unit: MinuteUnit; +} & BaseCandleParams; + +type OtherCandleParams = { + type: "days" | "weeks" | "months"; +} & BaseCandleParams; + +type BaseCandleParams = { + market: string; + to?: string; + count: number; +}; + +export type CandleParams = SecondsCandleParams | MinutesCandleParams | OtherCandleParams; diff --git a/src/entities/market/model/types/chart.type.ts b/src/entities/market/model/types/chart.type.ts new file mode 100644 index 0000000..9dcd526 --- /dev/null +++ b/src/entities/market/model/types/chart.type.ts @@ -0,0 +1,23 @@ +export type CandleData = { + market: string; + candle_date_time_utc: string; + candle_date_time_kst: string; + opening_price: number; + high_price: number; + low_price: number; + trade_price: number; + timestamp: number; + candle_acc_trade_price: number; + candle_acc_trade_volume: number; + unit: number; +}; + +export type ChartDataPoint = { + x: number; + y: number | number[] | string; +}; + +export type ChartSeriesData = { + prices: ChartDataPoint[]; + volume: ChartDataPoint[]; +}; diff --git a/src/entities/market/model/types/index.ts b/src/entities/market/model/types/index.ts index c4906b2..e35e8a1 100644 --- a/src/entities/market/model/types/index.ts +++ b/src/entities/market/model/types/index.ts @@ -1,3 +1,5 @@ +export * from "./chart.type"; export * from "./market-info.type"; export * from "./orderbook-units.type"; export * from "./trade.type"; +export * from "./candle.type"; diff --git a/src/features/home/components/common/button/TimeframeButton.tsx b/src/features/home/components/common/button/TimeframeButton.tsx index c5ec380..64c5d03 100644 --- a/src/features/home/components/common/button/TimeframeButton.tsx +++ b/src/features/home/components/common/button/TimeframeButton.tsx @@ -1,9 +1,11 @@ import { Button, Separator } from "@/shared"; +import type { ChartTimeframe } from "../../../store"; + type Props = { - timeframe: string; - selectedTimeframe: string; - setSelectedTimeframe: (timeframe: string) => void; + timeframe: ChartTimeframe; + selectedTimeframe: ChartTimeframe; + setSelectedTimeframe: (timeframe: ChartTimeframe) => void; }; export const TimeframeButton = ({ timeframe, selectedTimeframe, setSelectedTimeframe }: Props) => { diff --git a/src/features/home/components/features/coin-chart/ChartControls.tsx b/src/features/home/components/features/coin-chart/ChartControls.tsx index 09b3255..7265adb 100644 --- a/src/features/home/components/features/coin-chart/ChartControls.tsx +++ b/src/features/home/components/features/coin-chart/ChartControls.tsx @@ -1,15 +1,15 @@ "use client"; -import { useState } from "react"; - import { Separator } from "@/shared"; +import { type ChartTimeframe, useChartStore } from "../../../store"; import { ChartOptionButton, TimeframeButton } from "../../common"; -const timeframes = ["1초", "1분", "5분", "30분", "1시간", "주", "달", "년"]; +const timeframes: ChartTimeframe[] = ["1초", "1분", "5분", "30분", "1시간", "주", "달", "년"]; export const ChartControls = () => { - const [selectedTimeframe, setSelectedTimeframe] = useState("1초"); + const selectedTimeframe = useChartStore((state) => state.selectedTimeframe); + const setSelectedTimeframe = useChartStore((state) => state.setSelectedTimeframe); return (
diff --git a/src/features/home/components/features/coin-chart/CoinChartDisplay.tsx b/src/features/home/components/features/coin-chart/CoinChartDisplay.tsx index ff40e44..49cfb06 100644 --- a/src/features/home/components/features/coin-chart/CoinChartDisplay.tsx +++ b/src/features/home/components/features/coin-chart/CoinChartDisplay.tsx @@ -2,381 +2,112 @@ import dynamic from "next/dynamic"; -import type { ApexOptions } from "apexcharts"; +import { Skeleton } from "@/shared"; +import { ApexOptions } from "apexcharts"; -// ReactApexChart를 dynamic import로 변경 (SSR 비활성화) -const ReactApexChart = dynamic(() => import("react-apexcharts"), { ssr: false }); - -// 더미 데이터: 이미지와 유사한 패턴 (30분 간격, 약 48시간) -const ohlcData = [ - // 11/9 6:00부터 시작 - 초기 하락 구간 - { x: new Date("2025-11-09T06:00:00").getTime(), y: [152300000, 152500000, 151800000, 152000000] }, - { x: new Date("2025-11-09T06:30:00").getTime(), y: [152000000, 152200000, 151600000, 151700000] }, - { x: new Date("2025-11-09T07:00:00").getTime(), y: [151700000, 151900000, 151400000, 151500000] }, - { x: new Date("2025-11-09T07:30:00").getTime(), y: [151500000, 151700000, 151200000, 151400000] }, - { x: new Date("2025-11-09T08:00:00").getTime(), y: [151400000, 151600000, 151100000, 151300000] }, - { x: new Date("2025-11-09T08:30:00").getTime(), y: [151300000, 151500000, 151000000, 151200000] }, - { x: new Date("2025-11-09T09:00:00").getTime(), y: [151200000, 151400000, 150900000, 151000000] }, - { x: new Date("2025-11-09T09:30:00").getTime(), y: [151000000, 151200000, 150700000, 150900000] }, - { x: new Date("2025-11-09T10:00:00").getTime(), y: [150900000, 151100000, 150600000, 150800000] }, - { x: new Date("2025-11-09T10:30:00").getTime(), y: [150800000, 151000000, 150500000, 150700000] }, - { x: new Date("2025-11-09T11:00:00").getTime(), y: [150700000, 150900000, 150400000, 150600000] }, - { x: new Date("2025-11-09T11:30:00").getTime(), y: [150600000, 150800000, 150300000, 150500000] }, - - // 횡보 구간 - { x: new Date("2025-11-09T12:00:00").getTime(), y: [150500000, 150700000, 150300000, 150600000] }, - { x: new Date("2025-11-09T12:30:00").getTime(), y: [150600000, 150800000, 150400000, 150500000] }, - { x: new Date("2025-11-09T13:00:00").getTime(), y: [150500000, 150700000, 150300000, 150600000] }, - { x: new Date("2025-11-09T13:30:00").getTime(), y: [150600000, 150800000, 150400000, 150500000] }, - { x: new Date("2025-11-09T14:00:00").getTime(), y: [150500000, 150700000, 150300000, 150400000] }, - { x: new Date("2025-11-09T14:30:00").getTime(), y: [150400000, 150600000, 150200000, 150500000] }, - { x: new Date("2025-11-09T15:00:00").getTime(), y: [150500000, 150700000, 150300000, 150400000] }, - { x: new Date("2025-11-09T15:30:00").getTime(), y: [150400000, 150600000, 150200000, 150300000] }, - - // 급등 구간 시작 - { x: new Date("2025-11-09T16:00:00").getTime(), y: [150300000, 151500000, 150200000, 151200000] }, - { x: new Date("2025-11-09T16:30:00").getTime(), y: [151200000, 152000000, 151000000, 151800000] }, - { x: new Date("2025-11-09T17:00:00").getTime(), y: [151800000, 152500000, 151600000, 152300000] }, - { x: new Date("2025-11-09T17:30:00").getTime(), y: [152300000, 153000000, 152100000, 152800000] }, - { x: new Date("2025-11-09T18:00:00").getTime(), y: [152800000, 153500000, 152600000, 153200000] }, - - // 조정 후 재상승 - { x: new Date("2025-11-09T18:30:00").getTime(), y: [153200000, 153400000, 152800000, 153000000] }, - { x: new Date("2025-11-09T19:00:00").getTime(), y: [153000000, 153200000, 152600000, 152800000] }, - { x: new Date("2025-11-09T19:30:00").getTime(), y: [152800000, 153000000, 152400000, 152700000] }, - { x: new Date("2025-11-09T20:00:00").getTime(), y: [152700000, 152900000, 152300000, 152600000] }, - { x: new Date("2025-11-09T20:30:00").getTime(), y: [152600000, 152800000, 152200000, 152500000] }, - { x: new Date("2025-11-09T21:00:00").getTime(), y: [152500000, 152700000, 152100000, 152400000] }, - { x: new Date("2025-11-09T21:30:00").getTime(), y: [152400000, 152600000, 152000000, 152300000] }, - { x: new Date("2025-11-09T22:00:00").getTime(), y: [152300000, 152500000, 151900000, 152200000] }, - - // 11/10 - 상승 추세 지속 - { x: new Date("2025-11-10T00:00:00").getTime(), y: [152200000, 152800000, 152100000, 152600000] }, - { x: new Date("2025-11-10T00:30:00").getTime(), y: [152600000, 153200000, 152500000, 153000000] }, - { x: new Date("2025-11-10T01:00:00").getTime(), y: [153000000, 153600000, 152900000, 153400000] }, - { x: new Date("2025-11-10T01:30:00").getTime(), y: [153400000, 154000000, 153300000, 153800000] }, - { x: new Date("2025-11-10T02:00:00").getTime(), y: [153800000, 154400000, 153700000, 154200000] }, - { x: new Date("2025-11-10T02:30:00").getTime(), y: [154200000, 154800000, 154100000, 154600000] }, - { x: new Date("2025-11-10T03:00:00").getTime(), y: [154600000, 155200000, 154500000, 155000000] }, - { x: new Date("2025-11-10T03:30:00").getTime(), y: [155000000, 155600000, 154900000, 155400000] }, - { x: new Date("2025-11-10T04:00:00").getTime(), y: [155400000, 156000000, 155300000, 155800000] }, - - // 조정 및 횡보 - { x: new Date("2025-11-10T06:00:00").getTime(), y: [155800000, 156000000, 155400000, 155600000] }, - { x: new Date("2025-11-10T06:30:00").getTime(), y: [155600000, 155800000, 155200000, 155400000] }, - { x: new Date("2025-11-10T07:00:00").getTime(), y: [155400000, 155600000, 155000000, 155200000] }, - { x: new Date("2025-11-10T07:30:00").getTime(), y: [155200000, 155400000, 154800000, 155000000] }, - { x: new Date("2025-11-10T08:00:00").getTime(), y: [155000000, 155200000, 154600000, 154800000] }, - { x: new Date("2025-11-10T08:30:00").getTime(), y: [154800000, 155000000, 154400000, 154600000] }, - { x: new Date("2025-11-10T09:00:00").getTime(), y: [154600000, 154800000, 154200000, 154400000] }, - - // 급락 구간 - { x: new Date("2025-11-10T09:30:00").getTime(), y: [154400000, 154500000, 153500000, 153800000] }, - { x: new Date("2025-11-10T10:00:00").getTime(), y: [153800000, 154000000, 152800000, 153200000] }, - { x: new Date("2025-11-10T10:30:00").getTime(), y: [153200000, 153400000, 152400000, 152800000] }, - - // 회복 및 상승 - { x: new Date("2025-11-10T11:00:00").getTime(), y: [152800000, 153400000, 152700000, 153200000] }, - { x: new Date("2025-11-10T11:30:00").getTime(), y: [153200000, 153800000, 153100000, 153600000] }, - { x: new Date("2025-11-10T12:00:00").getTime(), y: [153600000, 154200000, 153500000, 154000000] }, - { x: new Date("2025-11-10T12:30:00").getTime(), y: [154000000, 154600000, 153900000, 154400000] }, - { x: new Date("2025-11-10T13:00:00").getTime(), y: [154400000, 155000000, 154300000, 154800000] }, - { x: new Date("2025-11-10T13:30:00").getTime(), y: [154800000, 155400000, 154700000, 155200000] }, - { x: new Date("2025-11-10T14:00:00").getTime(), y: [155200000, 155800000, 155100000, 155600000] }, - { x: new Date("2025-11-10T14:30:00").getTime(), y: [155600000, 156200000, 155500000, 156000000] }, - { x: new Date("2025-11-10T15:00:00").getTime(), y: [156000000, 156600000, 155900000, 156400000] }, +import { useCoinChartViewModel } from "../../../hooks"; - // 11/11 - 최근 상승 및 조정 - { x: new Date("2025-11-11T00:00:00").getTime(), y: [156400000, 157000000, 156300000, 156800000] }, - { x: new Date("2025-11-11T00:30:00").getTime(), y: [156800000, 157400000, 156700000, 157200000] }, - { x: new Date("2025-11-11T01:00:00").getTime(), y: [157200000, 157800000, 157100000, 157600000] }, - { x: new Date("2025-11-11T01:30:00").getTime(), y: [157600000, 158200000, 157500000, 158000000] }, - { x: new Date("2025-11-11T02:00:00").getTime(), y: [158000000, 158600000, 157900000, 158400000] }, - { x: new Date("2025-11-11T02:30:00").getTime(), y: [158400000, 159000000, 158300000, 158800000] }, - { x: new Date("2025-11-11T03:00:00").getTime(), y: [158800000, 159400000, 158700000, 159200000] }, - { x: new Date("2025-11-11T03:30:00").getTime(), y: [159200000, 159800000, 159100000, 159600000] }, - { x: new Date("2025-11-11T04:00:00").getTime(), y: [159600000, 160200000, 159500000, 160000000] }, - { x: new Date("2025-11-11T04:30:00").getTime(), y: [160000000, 160600000, 159900000, 160400000] }, - { x: new Date("2025-11-11T05:00:00").getTime(), y: [160400000, 161000000, 160300000, 160800000] }, - { x: new Date("2025-11-11T05:30:00").getTime(), y: [160800000, 161400000, 160700000, 161200000] }, - { x: new Date("2025-11-11T06:00:00").getTime(), y: [161200000, 161800000, 161100000, 161600000] }, - { x: new Date("2025-11-11T06:30:00").getTime(), y: [161600000, 162200000, 161500000, 162000000] }, - { x: new Date("2025-11-11T07:00:00").getTime(), y: [162000000, 162600000, 161900000, 162400000] }, - { x: new Date("2025-11-11T07:30:00").getTime(), y: [162400000, 163000000, 162300000, 162800000] }, - { x: new Date("2025-11-11T08:00:00").getTime(), y: [162800000, 163400000, 162700000, 163200000] }, - { x: new Date("2025-11-11T08:30:00").getTime(), y: [163200000, 163800000, 163100000, 163600000] }, - { x: new Date("2025-11-11T09:00:00").getTime(), y: [163600000, 164200000, 163500000, 164000000] }, - { x: new Date("2025-11-11T09:30:00").getTime(), y: [164000000, 164600000, 163900000, 164400000] }, - { x: new Date("2025-11-11T10:00:00").getTime(), y: [164400000, 165000000, 164300000, 164800000] }, - { x: new Date("2025-11-11T10:30:00").getTime(), y: [164800000, 165400000, 164700000, 165200000] }, - { x: new Date("2025-11-11T11:00:00").getTime(), y: [165200000, 165800000, 165100000, 165600000] }, - { x: new Date("2025-11-11T11:30:00").getTime(), y: [165600000, 166200000, 165500000, 166000000] }, - { x: new Date("2025-11-11T12:00:00").getTime(), y: [166000000, 166600000, 165900000, 166400000] }, -]; - -const volumeData = [ - { x: new Date("2025-11-09T06:00:00").getTime(), y: 45 }, - { x: new Date("2025-11-09T06:30:00").getTime(), y: 50 }, - { x: new Date("2025-11-09T07:00:00").getTime(), y: 55 }, - { x: new Date("2025-11-09T07:30:00").getTime(), y: 48 }, - { x: new Date("2025-11-09T08:00:00").getTime(), y: 52 }, - { x: new Date("2025-11-09T08:30:00").getTime(), y: 58 }, - { x: new Date("2025-11-09T09:00:00").getTime(), y: 62 }, - { x: new Date("2025-11-09T09:30:00").getTime(), y: 70 }, - { x: new Date("2025-11-09T10:00:00").getTime(), y: 65 }, - { x: new Date("2025-11-09T10:30:00").getTime(), y: 60 }, - { x: new Date("2025-11-09T11:00:00").getTime(), y: 55 }, - { x: new Date("2025-11-09T11:30:00").getTime(), y: 50 }, - { x: new Date("2025-11-09T12:00:00").getTime(), y: 48 }, - { x: new Date("2025-11-09T12:30:00").getTime(), y: 45 }, - { x: new Date("2025-11-09T13:00:00").getTime(), y: 42 }, - { x: new Date("2025-11-09T13:30:00").getTime(), y: 40 }, - { x: new Date("2025-11-09T14:00:00").getTime(), y: 38 }, - { x: new Date("2025-11-09T14:30:00").getTime(), y: 35 }, - { x: new Date("2025-11-09T15:00:00").getTime(), y: 40 }, - { x: new Date("2025-11-09T15:30:00").getTime(), y: 45 }, - { x: new Date("2025-11-09T16:00:00").getTime(), y: 180 }, - { x: new Date("2025-11-09T16:30:00").getTime(), y: 150 }, - { x: new Date("2025-11-09T17:00:00").getTime(), y: 140 }, - { x: new Date("2025-11-09T17:30:00").getTime(), y: 130 }, - { x: new Date("2025-11-09T18:00:00").getTime(), y: 120 }, - { x: new Date("2025-11-09T18:30:00").getTime(), y: 110 }, - { x: new Date("2025-11-09T19:00:00").getTime(), y: 100 }, - { x: new Date("2025-11-09T19:30:00").getTime(), y: 95 }, - { x: new Date("2025-11-09T20:00:00").getTime(), y: 90 }, - { x: new Date("2025-11-09T20:30:00").getTime(), y: 85 }, - { x: new Date("2025-11-09T21:00:00").getTime(), y: 80 }, - { x: new Date("2025-11-09T21:30:00").getTime(), y: 75 }, - { x: new Date("2025-11-09T22:00:00").getTime(), y: 70 }, - { x: new Date("2025-11-10T00:00:00").getTime(), y: 88 }, - { x: new Date("2025-11-10T00:30:00").getTime(), y: 95 }, - { x: new Date("2025-11-10T01:00:00").getTime(), y: 102 }, - { x: new Date("2025-11-10T01:30:00").getTime(), y: 110 }, - { x: new Date("2025-11-10T02:00:00").getTime(), y: 118 }, - { x: new Date("2025-11-10T02:30:00").getTime(), y: 125 }, - { x: new Date("2025-11-10T03:00:00").getTime(), y: 132 }, - { x: new Date("2025-11-10T03:30:00").getTime(), y: 140 }, - { x: new Date("2025-11-10T04:00:00").getTime(), y: 135 }, - { x: new Date("2025-11-10T06:00:00").getTime(), y: 85 }, - { x: new Date("2025-11-10T06:30:00").getTime(), y: 80 }, - { x: new Date("2025-11-10T07:00:00").getTime(), y: 75 }, - { x: new Date("2025-11-10T07:30:00").getTime(), y: 70 }, - { x: new Date("2025-11-10T08:00:00").getTime(), y: 68 }, - { x: new Date("2025-11-10T08:30:00").getTime(), y: 65 }, - { x: new Date("2025-11-10T09:00:00").getTime(), y: 72 }, - { x: new Date("2025-11-10T09:30:00").getTime(), y: 165 }, - { x: new Date("2025-11-10T10:00:00").getTime(), y: 155 }, - { x: new Date("2025-11-10T10:30:00").getTime(), y: 145 }, - { x: new Date("2025-11-10T11:00:00").getTime(), y: 128 }, - { x: new Date("2025-11-10T11:30:00").getTime(), y: 120 }, - { x: new Date("2025-11-10T12:00:00").getTime(), y: 112 }, - { x: new Date("2025-11-10T12:30:00").getTime(), y: 105 }, - { x: new Date("2025-11-10T13:00:00").getTime(), y: 98 }, - { x: new Date("2025-11-10T13:30:00").getTime(), y: 92 }, - { x: new Date("2025-11-10T14:00:00").getTime(), y: 88 }, - { x: new Date("2025-11-10T14:30:00").getTime(), y: 85 }, - { x: new Date("2025-11-10T15:00:00").getTime(), y: 82 }, - { x: new Date("2025-11-11T00:00:00").getTime(), y: 95 }, - { x: new Date("2025-11-11T00:30:00").getTime(), y: 102 }, - { x: new Date("2025-11-11T01:00:00").getTime(), y: 108 }, - { x: new Date("2025-11-11T01:30:00").getTime(), y: 115 }, - { x: new Date("2025-11-11T02:00:00").getTime(), y: 122 }, - { x: new Date("2025-11-11T02:30:00").getTime(), y: 128 }, - { x: new Date("2025-11-11T03:00:00").getTime(), y: 135 }, - { x: new Date("2025-11-11T03:30:00").getTime(), y: 142 }, - { x: new Date("2025-11-11T04:00:00").getTime(), y: 148 }, - { x: new Date("2025-11-11T04:30:00").getTime(), y: 155 }, - { x: new Date("2025-11-11T05:00:00").getTime(), y: 162 }, - { x: new Date("2025-11-11T05:30:00").getTime(), y: 170 }, - { x: new Date("2025-11-11T06:00:00").getTime(), y: 178 }, - { x: new Date("2025-11-11T06:30:00").getTime(), y: 185 }, - { x: new Date("2025-11-11T07:00:00").getTime(), y: 192 }, - { x: new Date("2025-11-11T07:30:00").getTime(), y: 200 }, - { x: new Date("2025-11-11T08:00:00").getTime(), y: 195 }, - { x: new Date("2025-11-11T08:30:00").getTime(), y: 188 }, - { x: new Date("2025-11-11T09:00:00").getTime(), y: 180 }, - { x: new Date("2025-11-11T09:30:00").getTime(), y: 175 }, - { x: new Date("2025-11-11T10:00:00").getTime(), y: 168 }, - { x: new Date("2025-11-11T10:30:00").getTime(), y: 160 }, - { x: new Date("2025-11-11T11:00:00").getTime(), y: 155 }, - { x: new Date("2025-11-11T11:30:00").getTime(), y: 150 }, - { x: new Date("2025-11-11T12:00:00").getTime(), y: 145 }, -]; - -const series = [ - { - type: "candlestick" as const, - name: "BTC/KRW", - data: ohlcData, - }, - { - type: "bar" as const, - name: "Volume", - data: volumeData, - }, -]; +// SSR 비활성화 +const ReactApexChart = dynamic(() => import("react-apexcharts"), { ssr: false }); -const options: ApexOptions = { - chart: { - type: "candlestick" as const, - height: 500, - id: "candles", - toolbar: { - show: true, - tools: { - download: true, - selection: true, - zoom: true, - zoomin: true, - zoomout: true, - pan: true, - reset: true, +export const CoinChartDisplay = () => { + const { prices, volume, isLoading } = useCoinChartViewModel(50); + + const candleOptions: ApexOptions = { + chart: { + type: "candlestick", + id: "candles", + toolbar: { autoSelected: "pan", show: false }, + zoom: { enabled: false }, + background: "transparent", + }, + theme: { mode: "dark" }, + plotOptions: { + candlestick: { + colors: { + upward: "var(--increase)", + downward: "var(--decrease)", // 하락 (초록/파랑) + }, }, }, - zoom: { enabled: true }, - background: "transparent", - }, - grid: { - show: true, - borderColor: "#2d2d2d", - strokeDashArray: 0, - position: "back", xaxis: { - lines: { - show: true, - }, + type: "datetime", + axisBorder: { show: false }, + axisTicks: { show: false }, + labels: { show: true }, // 메인 차트 X축 라벨 보이기 }, yaxis: { - lines: { - show: true, - }, - }, - }, - plotOptions: { - bar: { - columnWidth: "50%", - colors: { - ranges: [{ from: 0, to: 300, color: "#4a9eff" }], - }, - }, - candlestick: { - colors: { - upward: "#ef5350", - downward: "#26a69a", - }, - wick: { - useFillColor: true, + tooltip: { enabled: true }, + labels: { + formatter: (val) => Math.floor(val).toLocaleString(), }, }, - }, - xaxis: { - type: "datetime" as const, - labels: { - style: { - colors: "#9ca3af", - fontSize: "11px", + grid: { borderColor: "#333" }, + }; + + const barOptions: ApexOptions = { + chart: { + type: "bar", + id: "brush", + brush: { + enabled: true, + target: "candles", }, - datetimeFormatter: { - year: "yyyy", - month: "MMM 'yy", - day: "dd MMM", - hour: "HH:mm", + selection: { + enabled: true, + fill: { color: "#ccc", opacity: 0.4 }, + stroke: { color: "#0D47A1" }, + // 초기 선택 범위: 데이터의 마지막 20% 구간 + xaxis: { + min: prices.length > 30 ? prices[prices.length - 30].x : undefined, + max: prices.length > 0 ? prices[prices.length - 1].x : undefined, + }, }, + background: "transparent", }, - axisBorder: { - show: true, - color: "#2d2d2d", - }, - axisTicks: { - show: true, - color: "#2d2d2d", - }, - }, - yaxis: [ - { - seriesName: "BTC/KRW", - opposite: true, - tooltip: { enabled: false }, - labels: { - style: { - colors: "#9ca3af", - fontSize: "11px", + theme: { mode: "dark" }, + dataLabels: { enabled: false }, + plotOptions: { + bar: { + columnWidth: "100%", + colors: { + ranges: [{ from: 0, to: 1000000000, color: "#555" }], // 거래량 색상 통일 }, - formatter: (value) => { - return `${(value / 1000000).toFixed(1)}M`; - }, - }, - axisBorder: { - show: true, - color: "#2d2d2d", }, }, - { - seriesName: "Volume", - show: false, - min: 0, - max: 400, - }, - ], - tooltip: { - enabled: true, - shared: false, - theme: "dark", - style: { - fontSize: "12px", + stroke: { width: 0 }, + xaxis: { + type: "datetime", + tooltip: { enabled: false }, + axisBorder: { offsetX: 13 }, }, - custom: function ({ seriesIndex, dataPointIndex, w }) { - const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex]; - if (seriesIndex === 0 && data?.y) { - const [open, high, low, close] = data.y; - const change = close - open; - const changePercent = ((change / open) * 100).toFixed(2); - const color = change >= 0 ? "#ef5350" : "#26a69a"; + yaxis: { labels: { show: false } }, + grid: { show: false }, + }; - return ` -
-
BTC/KRW
-
- 시가: - ₩${open.toLocaleString()} -
-
- 고가: - ₩${high.toLocaleString()} -
-
- 저가: - ₩${low.toLocaleString()} -
-
- 종가: - ₩${close.toLocaleString()} -
-
- ${change >= 0 ? "▲" : "▼"} ${Math.abs(change).toLocaleString()} (${changePercent}%) -
-
- `; - } - return ""; - }, - }, - legend: { - show: false, - }, - theme: { - mode: "dark", - }, - annotations: { - xaxis: [], - yaxis: [], - }, -}; + if (isLoading) return ; -export const CoinChartDisplay = () => { return ( -
- +
+
+ +
+ +
+ +
); }; diff --git a/src/features/home/hooks/index.ts b/src/features/home/hooks/index.ts index 1cfbdc1..5526ff1 100644 --- a/src/features/home/hooks/index.ts +++ b/src/features/home/hooks/index.ts @@ -1,3 +1,5 @@ +export * from "./useChartData"; +export * from "./useChartViewModel"; export * from "./useGetMarketInfo"; export * from "./useRealtimeTicker"; export * from "./useMarketInfo"; diff --git a/src/features/home/hooks/useChartData.ts b/src/features/home/hooks/useChartData.ts new file mode 100644 index 0000000..e83a4fc --- /dev/null +++ b/src/features/home/hooks/useChartData.ts @@ -0,0 +1,28 @@ +import { CandleData, ChartSeriesData } from "@/entities"; + +export const useChartData = (candleData: CandleData[] | undefined): ChartSeriesData => { + // 1. 방어 코드: 데이터가 없으면 빈 배열 반환 + if (!candleData || candleData.length === 0) { + return { prices: [], volume: [] }; + } + + const sortedData = [...candleData].sort( + (a, b) => new Date(a.candle_date_time_kst).getTime() - new Date(b.candle_date_time_kst).getTime(), + ); + + const parseKstToUtc = (kstString: string) => { + return new Date(kstString + "Z").getTime(); + }; + + const prices = sortedData.map((item) => ({ + x: parseKstToUtc(item.candle_date_time_kst), + y: [item.opening_price, item.high_price, item.low_price, item.trade_price], + })); + + const volume = sortedData.map((item) => ({ + x: parseKstToUtc(item.candle_date_time_kst), + y: item.candle_acc_trade_volume.toFixed(3), + })); + + return { prices, volume }; +}; diff --git a/src/features/home/hooks/useChartViewModel.ts b/src/features/home/hooks/useChartViewModel.ts new file mode 100644 index 0000000..35ab93a --- /dev/null +++ b/src/features/home/hooks/useChartViewModel.ts @@ -0,0 +1,27 @@ +import { useGetCandle, useGetMarket } from "@/entities"; + +import { useCandleParams } from "../store"; +import { formattedMarketName } from "../utils"; + +import { useChartData } from "./useChartData"; + +export const useCoinChartViewModel = (count: number = 50) => { + const { market } = useGetMarket(); + + const currentMarket = formattedMarketName(market); + + // 현재 선택된 타임프레임에 따른 API 파라미터 자동 생성 + const candleParams = useCandleParams(currentMarket, count); + + const { data: candleData, isLoading, isError } = useGetCandle(candleParams); + + const { prices, volume } = useChartData(candleData); + + return { + prices, + volume, + isLoading, + isError, + currentMarket: market, + }; +}; diff --git a/src/features/home/store/index.ts b/src/features/home/store/index.ts new file mode 100644 index 0000000..b430f9e --- /dev/null +++ b/src/features/home/store/index.ts @@ -0,0 +1 @@ +export { useChartStore, useCandleParams, type ChartTimeframe } from "./useChartStore"; diff --git a/src/features/home/store/useChartStore.ts b/src/features/home/store/useChartStore.ts new file mode 100644 index 0000000..0ffbceb --- /dev/null +++ b/src/features/home/store/useChartStore.ts @@ -0,0 +1,66 @@ +import type { CandleParams } from "@/entities"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +export type ChartTimeframe = "1초" | "1분" | "5분" | "30분" | "1시간" | "주" | "달" | "년"; + +interface ChartState { + selectedTimeframe: ChartTimeframe; + setSelectedTimeframe: (timeframe: ChartTimeframe) => void; + // 현재 선택된 타임프레임에 따른 API 파라미터 생성 + getCandleParams: (market: string, count: number) => CandleParams; +} + +/** + * 타임프레임을 API 파라미터로 변환 + */ +const timeframeToAPIParams = (timeframe: ChartTimeframe, market: string, count: number): CandleParams => { + const baseParams = { market, count }; + + switch (timeframe) { + case "1초": + return { ...baseParams, type: "seconds" }; + case "1분": + return { ...baseParams, type: "minutes", unit: 1 }; + case "5분": + return { ...baseParams, type: "minutes", unit: 5 }; + case "30분": + return { ...baseParams, type: "minutes", unit: 30 }; + case "1시간": + return { ...baseParams, type: "minutes", unit: 60 }; + case "주": + return { ...baseParams, type: "weeks" }; + case "달": + return { ...baseParams, type: "months" }; + case "년": + return { ...baseParams, type: "days" }; + default: + return { ...baseParams, type: "seconds" }; + } +}; + +export const useChartStore = create()( + devtools( + (set) => ({ + selectedTimeframe: "1초", + setSelectedTimeframe: (timeframe: ChartTimeframe) => set({ selectedTimeframe: timeframe }), + getCandleParams: (market: string, count: number) => + timeframeToAPIParams( + // state에서 현재 timeframe을 가져오기 위해서는 별도 처리 필요 + // 이 함수는 직접 사용하지 않고, useCandleParams hook 사용 권장 + "1초", + market, + count, + ), + }), + { name: "ChartStore" }, + ), +); + +/** + * 현재 선택된 타임프레임을 API 파라미터로 변환하는 hook + */ +export const useCandleParams = (market: string, count: number): CandleParams => { + const selectedTimeframe = useChartStore((state) => state.selectedTimeframe); + return timeframeToAPIParams(selectedTimeframe, market, count); +}; diff --git a/src/features/home/utils/formatted-market-name.ts b/src/features/home/utils/formatted-market-name.ts new file mode 100644 index 0000000..24c01da --- /dev/null +++ b/src/features/home/utils/formatted-market-name.ts @@ -0,0 +1,12 @@ +export const formattedMarketName = (market: string | undefined): string => { + if (!market) { + return "KRW-BTC"; // 기본값 설정 + } + + if (market.includes("/")) { + const [coin, currency] = market.split("/"); + return `${currency}-${coin}`; + } + + return market; +}; diff --git a/src/features/home/utils/index.ts b/src/features/home/utils/index.ts new file mode 100644 index 0000000..49d397c --- /dev/null +++ b/src/features/home/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatted-market-name";