├── .github
├── ISSUE_TEMPLATE
│ └── cg-issue-template.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── deploy.yml
├── README.md
├── api_server
├── .gitignore
├── README.md
├── app.js
├── data
│ ├── configData.js
│ └── getData.js
└── package.json
└── client
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── README.md
├── components
├── Candlechart
│ ├── chartController.ts
│ ├── index.tsx
│ └── mobileTouchEventClosure.ts
├── ChartHeader
│ └── index.tsx
├── ChartSelectController
│ └── index.tsx
├── ChartTagController
│ └── index.tsx
├── CoinDetailedInfo
│ └── index.tsx
├── CoinSelectController
│ ├── MakeCoinDict.tsx
│ ├── SearchCoin.tsx
│ └── index.tsx
├── GNB
│ ├── LogoImg.tsx
│ ├── SearchInput.tsx
│ ├── index.tsx
│ ├── logo-only-white.svg
│ └── logo-white.svg
├── InfoSidebarContainer
│ └── index.tsx
├── LinkButton
│ └── index.tsx
├── Modal
│ └── index.tsx
├── NoSelectedCoinAlertView
│ └── index.tsx
├── RealTimeCoinPrice
│ └── index.tsx
├── Runningchart
│ └── index.tsx
├── SortSelectController
│ └── index.tsx
├── SwiperableDrawer
│ └── index.tsx
├── TabBox
│ └── index.tsx
├── TabContainer
│ └── index.tsx
└── Treechart
│ ├── GetCoinData.tsx
│ └── index.tsx
├── constants
└── ChartConstants.ts
├── hooks
├── useCoinMetaData.ts
├── useInterval.ts
├── useRealTimeCoinListData.ts
├── useRealTimeUpbitData.ts
├── useRefElementSize.ts
├── useURL.ts
└── useUserAgent.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pages
├── 404.tsx
├── _app.tsx
├── _document.tsx
├── detail
│ └── [market].tsx
├── doge.svg
└── index.tsx
├── public
├── btc.svg
├── doge.svg
├── doge_thumbnail.png
├── favicon.ico
├── fonts
│ ├── LINESeedKR-Bd.woff
│ ├── LINESeedKR-Rg.woff
│ ├── LINESeedKR-Th.woff
│ └── style.css
├── menu-open.svg
├── openBtn-mobile.svg
├── openBtn.svg
└── userInfo.svg
├── style
├── colorScale.ts
├── createEmotionCache.ts
└── theme.ts
├── theme.d.ts
├── tsconfig.json
├── types
├── ChartTypes.ts
├── CoinDataTypes.ts
└── CoinPriceTypes.ts
└── utils
├── apiManager.ts
├── chartManager.ts
├── dateManager.ts
├── inputBarManager.ts
├── metaDataManages.ts
└── upbitManager.ts
/.github/ISSUE_TEMPLATE/cg-issue-template.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: CG issue template
3 | about: Describe this issue template's purpose here.
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | ### 요약
11 |
12 | 해당 이슈 작업에 관한 간단한 요약,
13 |
14 | ### 예상 작업 내용 (커밋단위)
15 |
16 | - [ ] 작업 내용
17 |
18 | ### 이슈가 완료된 후 (TOBE)
19 | 이슈가 완료된 이후 변화한 점/기대할 수 있는 점 등을 간단히 요약
20 |
21 | 예시 :
22 | 작성된 `CandleChart` 컴포넌트의 update함수에 D3코드를 작성하기만 하면 차트를 그릴 수 있다. 차트를 그림에 있어 더이상 리액트 레벨의 데이터 fetching을 신경 쓸 필요 없이 주어진 데이터만으로 D3 svg를 그리는 것에 신경쓸 수 있게 된다.
23 |
24 | ### 기타
25 | 해당 이슈에서 예상되는 trouble? 혹은 리팩터링 포인트, 아니면 공유하고 싶은 기타 등등
26 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## 개요
2 | - 필수
3 | - 어떤 목적을 갖는 PR인지 작성
4 |
5 | ## 작업사항
6 | - 필수
7 | - 목적을 달성하기 위해 작업한 내용들
8 | - 중요한 부분이 있다고 생각되면 파일명과 로직까지 상세하게 적어주면 다른사람이 보기 편할 것 같습니다.
9 |
10 | ## 생각해볼점
11 | - 선택
12 | - 일단은 구현했지만, 약간은 찜찜한 부분들, 수정이 필요할 것 같은 부분들을 적으면 다른 분들이 보고 한번씩 생각해보고 스크럼때 얘기해보는 방향이 좋을 것 같습니다.
13 |
14 | ## 이미지
15 | - 선택
16 | - 작업 이전과 작업이후에 시각적으로 크게 변한 부분이 있다면 이전사진과 이후사진을 첨부하면 다른분들이 보기 편할 것 같습니다. (gif강추)
17 |
18 | PR에 해당하는 이슈는 우측의 Development에서 등록해주세요!!
19 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 | jobs:
6 | Auto-Deploy:
7 | runs-on: ubuntu-18.04
8 | steps:
9 | - name: SSH RemoteCommands
10 | uses: appleboy/ssh-action@v0.1.5
11 | with:
12 | host: ${{secrets.SERVER_HOST}}
13 | port: ${{secrets.SERVER_PORT}}
14 | username: ${{secrets.SERVER_USERNAME}}
15 | password: ${{secrets.SERVER_PASSWORD}}
16 | script: |
17 | cd ~/CryptoGraph
18 | git pull origin main
19 | export NVM_DIR=~/.nvm
20 | source ~/.nvm/nvm.sh
21 | cd ./client
22 | npm install
23 | npm run build
24 | pm2 reload client
25 | cd ../api_server
26 | npm install
27 | pm2 reload server
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 실시간 암호화폐 데이터 시각화 웹 서비스 - CryptoGraph📊
2 |
3 | - 부스트캠프 멤버십 웹 7기 그룹 프로젝트
4 | **Web35-teamMars**
5 |
6 | 
7 |
8 | 
9 |
10 | - 배포 URL: [배포 URL 바로가기](https://cryptograph.servehttp.com/)
11 | - 팀 Notion:[Notion 바로가기](https://www.notion.so/boostcamp-wm/Web35-CryptoGraph-035444908f1f4beb8d4c1ba40e2beb4d)
12 | - 팀 Wiki:[ Wiki 바로가기](https://github.com/boostcampwm-2022/Web35-CryptoGraph/wiki/Introduce)
13 |
14 | ### 프로젝트 소개
15 |
16 | ---
17 |
18 |
19 | - CryptoGraph는 Upbit Open API, CoinMarketCap API를 이용하여 암호화폐 데이터들을 차트로 시각화하는 서비스입니다.
20 | - 트리맵 차트, 러닝 바 차트를 통해 전체적인 코인 시황을 파악할 수 있습니다. 각 코인을 선택하여 그 코인의 가격 추이를 캔들 차트로 확인할 수 있습니다.
21 | - 반응형 디자인을 적용하여 여러 크기의 디바이스에 대응합니다.
22 |
23 |
24 | # 🥈팀원 소개 🧑💻
25 |
26 |
84 |
85 |
86 | # 👨💻 역할
87 |
88 | ---
89 |
90 | |아이디|이름|역할|해결 이슈|
91 | |---|----|---|---|
92 | |J006|강재민|Running Bar 차트 개발, 상단바 검색창 자동완성 적용|[github](https://github.com/boostcampwm-2022/Web35-CryptoGraph/issues?q=is%3Aissue+assignee%3Arkdwoals159)|
93 | |J013|공윤배|캔들차트 개발, 실시간 코인정보 컴포넌트 개발|[github](https://github.com/boostcampwm-2022/Web35-CryptoGraph/issues?q=is%3Aissue+assignee%3Akongyb)|
94 | |J038|김상훈|레이아웃 마크업 제작 및 미디어 쿼리 활용한 반응형 적용, 캔들차트 개발, 프로젝트 리딩|[github](https://github.com/boostcampwm-2022/Web35-CryptoGraph/issues?q=is%3Aissue+assignee%3AbaldwinIV+)|
95 | |J054|김준태|트리맵 차트 개발, 코인 선택 컴포넌트 개발|[github](https://github.com/boostcampwm-2022/Web35-CryptoGraph/issues?q=is%3Aissue+assignee%3Asronger)|
96 |
97 | # 📈 주요 기능
98 |
99 | ---
100 |
101 | ## 차트
102 |
103 | ### 1. 캔들차트
104 |
105 | ---
106 |
107 |
108 | - 1분봉/5분봉/../1시간봉/일봉/주봉 등 캔들 기간을 변경할 수 있습니다.
109 | - 차트를 일정 이상 드래그(패닝)하면 필요한 데이터를 prefetch합니다.
110 | - 데스크탑에서는 마우스 휠로, 모바일에서는 핀치 줌을 통해 한 화면에 렌더되는 봉 갯수를 조절할 수 있습니다.
111 |
112 |
113 | ### 2. 트리맵 차트
114 |
115 | ---
116 |
117 |
118 | - coinmarketcap API 정보를 전달하는 백엔드 서버 및 업비트 서버에 데이터를 요청하여, 실시간으로 데이터가 렌더링됩니다.
119 | - 시가총액, 24시간 등락률(절댓값 내림차순, 내림차순, 오름차순), 거래량 등의 데이터를 트리맵 차트로 렌더링할 수 있습니다.
120 | - 원하는 코인만 선택하여 그래프로 렌더링하는 것도 가능하며, 그래프의 코인 요소를 선택하면, 캔들 차트 페이지로 이동할 수 있습니다.
121 |
122 | ### 3. 러닝 바 차트
123 |
124 | ---
125 |
126 |
127 | - coinmarketcap API 정보를 전달하는 백엔드 서버 및 업비트 서버에 데이터를 요청하여, 실시간으로 데이터가 렌더링됩니다.
128 | - 시가총액, 24시간 등락률(절댓값 내림차순, 내림차순, 오름차순), 거래량 등의 데이터를 바 차트로 렌더링할 수 있습니다.
129 | - 원하는 코인만 선택하여 그래프로 렌더링하는 것도 가능하며, 그래프의 코인 요소를 선택하면, 캔들 차트 페이지로 이동할 수 있습니다.
130 |
131 | ### 4. 반응형 지원 & 모바일 대응
132 |
133 |
134 |
135 |
136 |
137 | - 적응형이 아닌 반응형을 지원해 모든 크기의 브라우저 사이즈에 대응합니다.
138 | - 일정 width 이하로 줄어들면, 컴포넌트 배치가 모바일 모드로 변경됩니다.
139 |
140 |
141 | # 📚 프로젝트 구조
142 |
143 | ---
144 |
145 |
146 |
147 | # 📚 기술 스택
148 |
149 | ---
150 |
151 |
152 |
153 | ## Environment setup
154 |
155 | 1. ```git clone https://github.com/boostcampwm-2022/Web35-CryptoGraph.git CryptoGraph```
156 | 2. ```cd CryptoGraph```
157 | 3. ```npm i api_server```
158 | 4. ```npm i client```
159 | 5. ```touch client/.env```
160 | 6. ```NEXT_PUBLIC_SERVER_URL=http://localhost:{클라이언트 포트번호}```
161 | 7. ```touch api_server/.env```
162 |
163 | 8. ```COINMARKETCAP_API_KEY={코인마켓캡 API 키}``` (https://coinmarketcap.com/api/)
164 | 9. ```CLIENT_URL=http://localhost:{서버 포트번호}```
165 | 10. ```npm run client/build```
166 |
167 | ## 실행방법
168 | ### Web Server Run
169 | 터미널 1
170 | ```
171 | cd CryptoGraph
172 | npm run start api_server
173 | ```
174 | ### Web Client Server Run
175 | 터미널 2
176 | ```
177 | cd CryptoGraph
178 | npm run start client
179 | ```
180 |
--------------------------------------------------------------------------------
/api_server/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | #custom
4 | package-lock.json
5 |
6 | # dependencies
7 | /node_modules
8 | /.pnp
9 | .pnp.js
10 |
11 | # testing
12 | /coverage
13 |
14 | # next.js
15 | /.next/
16 | /out/
17 |
18 | # production
19 | /build
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 | .pnpm-debug.log*
30 |
31 | # local env files
32 | .env*.local
33 | .env
34 |
35 | # vercel
36 | .vercel
37 |
--------------------------------------------------------------------------------
/api_server/README.md:
--------------------------------------------------------------------------------
1 | # CryptoGraph API 서버
--------------------------------------------------------------------------------
/api_server/app.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const cors = require("cors");
3 | require("dotenv").config();
4 | const { getCoinInfo, getMarketCapInfos, getPriceData } = require("./data/configData");
5 |
6 | const PORT = 8080;
7 | let coinInfos = null;
8 |
9 | getCoinInfo().then((result) => {
10 | coinInfos = result;
11 | });
12 |
13 | setInterval(() => {
14 | getCoinInfo().then((result) => {
15 | coinInfos = result;
16 | });
17 | }, 60 * 60 * 1000);
18 |
19 | const app = express();
20 | app.use(
21 | cors({
22 | origin: process.env.CLIENT_URL,
23 | method: "GET",
24 | })
25 | );
26 |
27 | app.get("/coin-info/:code", (req, res) => {
28 | const code = req.params.code;
29 | if (coinInfos === null) {
30 | res.status(503).end();
31 | return;
32 | }
33 | if (!code || !coinInfos[code]) {
34 | res.status(404).end();
35 | return;
36 | }
37 | res.status(200).send(coinInfos[code]);
38 | });
39 |
40 | app.get("/market-cap-info", async (req, res) => {
41 | const marketCapInfos = await getMarketCapInfos(coinInfos);
42 | if (marketCapInfos === null) {
43 | res.status(503).end();
44 | return;
45 | }
46 | res.status(200).send(marketCapInfos);
47 | });
48 |
49 | app.get("/market-price-info", async (req, res) => {
50 | const priceData = await getPriceData(coinInfos);
51 | if (priceData === null) {
52 | res.status(503).end();
53 | return;
54 | }
55 | res.status(200).send(priceData);
56 | });
57 |
58 | app.listen(PORT, () => {
59 | console.log(`server listening port ${PORT}`);
60 | });
61 |
--------------------------------------------------------------------------------
/api_server/data/configData.js:
--------------------------------------------------------------------------------
1 | const {
2 | getCoinData,
3 | getCoinMetaData,
4 | getUpbitMarketCode,
5 | getUpbitMarketDatas,
6 | } = require("./getData");
7 |
8 | // 코인정보 반환하는 함수
9 | // 업비트API를 이용하여 코인종류 확인하고 해당 코인들 정보 코인마켓캡에서 받아와 조합
10 | async function getCoinInfo() {
11 | const time = getTime();
12 | const upbitMarketCodes = await getUpbitMarketCode();
13 | const result = {};
14 | const coinIds = [];
15 | // listing/latest에서 얻는 정보들 정리 result에 1차로 저장
16 | const coinDatas = await getCoinData();
17 | for (const { code, name, name_kr } of upbitMarketCodes) {
18 | const coinInfo = {};
19 | // coinData를 순회하며 알맞은 info를 찾아 저장한다.
20 | for (const data of coinDatas) {
21 | // MFT => HIFI 예외처리 (수정필요)
22 | if (data.symbol === "MFT" && code === "HIFI") {
23 | coinInfo.id = data.id;
24 | coinInfo.symbol = code;
25 | coinInfo.name = data.name;
26 | coinInfo.name_kr = name_kr;
27 | coinInfo.slug = data.slug;
28 | coinInfo.market_cap_dominance = data.quote.KRW.market_cap_dominance;
29 | coinInfo.market_cap = data.quote.KRW.market_cap;
30 | coinInfo.percent_change_24h = data.quote.KRW.percent_change_24h;
31 | coinInfo.market_cap_kr = transPrice(data.quote.KRW.market_cap);
32 | coinInfo.max_supply = data.max_supply;
33 | coinInfo.circulating_supply = data.circulating_supply;
34 | coinInfo.total_supply = data.total_supply;
35 | coinInfo.cmc_rank = data.cmc_rank;
36 | coinInfo.time = time;
37 | coinInfo.volume_24h = transPrice(data.quote.KRW.volume_24h);
38 | coinIds.push(data.id);
39 | break;
40 | }
41 |
42 | // FCT2 예외처리
43 | if (data.symbol === code || data.name === name) {
44 | coinInfo.id = data.id;
45 | coinInfo.symbol = code;
46 | coinInfo.name = data.name;
47 | coinInfo.name_kr = name_kr;
48 | coinInfo.slug = data.slug;
49 | coinInfo.market_cap_dominance = data.quote.KRW.market_cap_dominance;
50 | coinInfo.market_cap = data.quote.KRW.market_cap;
51 | coinInfo.percent_change_24h = data.quote.KRW.percent_change_24h;
52 | coinInfo.market_cap_kr = transPrice(data.quote.KRW.market_cap);
53 | coinInfo.max_supply = data.max_supply;
54 | coinInfo.circulating_supply = data.circulating_supply;
55 | coinInfo.total_supply = data.total_supply;
56 | coinInfo.cmc_rank = data.cmc_rank;
57 | coinInfo.time = time;
58 | coinInfo.volume_24h = transPrice(data.quote.KRW.volume_24h);
59 | coinIds.push(data.id);
60 | break;
61 | }
62 | }
63 | result[code] = coinInfo;
64 | }
65 | // info에서 얻는 메타데이터 추가 result에 2차로 저장
66 | const coinMetaDatas = await getCoinMetaData(coinIds.join(","));
67 | for (const { code } of upbitMarketCodes) {
68 | const coinInfo = result[code];
69 | const id = coinInfo.id;
70 | const metaData = coinMetaDatas[id];
71 | coinInfo.website = metaData.urls.website.length === 0 ? "" : metaData.urls.website[0];
72 | coinInfo.logo = metaData.logo;
73 | coinInfo.description = metaData.description;
74 | }
75 | return result;
76 | }
77 |
78 | // 업데이트 당시 시간 구하는 함수
79 | function getTime() {
80 | const curr = new Date();
81 | const utcCurr = curr.getTime() + curr.getTimezoneOffset() * 60 * 1000;
82 | const diffFromKst = 9 * 60 * 60 * 1000;
83 | const kstCurr = new Date(utcCurr + diffFromKst);
84 | const dateString = `${kstCurr.getMonth() + 1}/${kstCurr.getDate()} ${kstCurr.getHours()}시`;
85 | return dateString;
86 | }
87 |
88 | const priceUnit = { 10000: "만", 100000000: "억", 1000000000000: "조" };
89 |
90 | // 시가총액 변환하는 함수
91 | function transPrice(price) {
92 | let unit = 10000;
93 | if (price < unit) {
94 | return Math.floor(price * 100) / 100 + "";
95 | }
96 | while (unit < Number.MAX_SAFE_INTEGER) {
97 | if (price >= unit && price < unit * 10000) {
98 | return Math.floor((price * 100) / unit) / 100 + priceUnit[unit];
99 | }
100 | unit *= 10000;
101 | }
102 | return price;
103 | }
104 |
105 | // 메인페이지에 전달할 데이터 조합
106 | // 기존 coinInfo의 키에 저장된 코인종류를 이용하여 업비트 API로 현재 가격 변동률받아와 조합
107 | async function getMarketCapInfos(coinInfos) {
108 | if (!coinInfos) {
109 | return null;
110 | }
111 | const upbitMarketDatas = await getUpbitMarketDatas(
112 | Object.keys(coinInfos)
113 | .map((code) => `KRW-${code}`)
114 | .join(",")
115 | );
116 | const result = upbitMarketDatas.map((marketData) => {
117 | const code = marketData.market.split("-")[1];
118 | return {
119 | name: code,
120 | name_kr: coinInfos[code].name_kr,
121 | name_es: coinInfos[code].name,
122 | cmc_rank: coinInfos[code].cmc_rank,
123 | logo: coinInfos[code].logo,
124 | market_cap: coinInfos[code].market_cap,
125 | acc_trade_price_24h: marketData.acc_trade_price_24h,
126 | signed_change_rate: marketData.signed_change_rate,
127 | };
128 | });
129 | return result;
130 | }
131 |
132 | // detail페이지 전달할 데이터 조합
133 | async function getPriceData(coinInfos) {
134 | if (!coinInfos) {
135 | return null;
136 | }
137 | const marketCodes = await getUpbitMarketCode();
138 | const priceData = await getUpbitMarketDatas(
139 | marketCodes.map(({ code }) => `KRW-${code}`).join(",")
140 | );
141 | const result = {};
142 | priceData.forEach((data, index) => {
143 | const info = {};
144 | const code = data.market.split("-")[1];
145 | info.logo = coinInfos[code].logo;
146 | info.name_kr = coinInfos[code].name_kr;
147 | info.name = coinInfos[code].symbol;
148 | info.price = priceData[index].trade_price;
149 | info.signed_change_price = priceData[index].signed_change_price;
150 | info.signed_change_rate = priceData[index].signed_change_rate;
151 | info.acc_trade_price_24h = priceData[index].acc_trade_price_24h;
152 | result[code] = info;
153 | });
154 | return result;
155 | }
156 | module.exports = { getCoinInfo, getPriceData, getMarketCapInfos };
157 |
--------------------------------------------------------------------------------
/api_server/data/getData.js:
--------------------------------------------------------------------------------
1 | const axios = require("axios");
2 | require("dotenv").config();
3 |
4 | // 업비트에서 marketCode받는 함수
5 | async function getUpbitMarketCode() {
6 | const marketCodes = await axios({
7 | method: "get",
8 | baseURL: "https://api.upbit.com",
9 | url: "/v1/market/all",
10 | }).then((response) => response.data);
11 | return marketCodes.reduce((acc, curr) => {
12 | const [moneyType, coinCode] = curr.market.split("-");
13 | if (moneyType === "KRW") {
14 | acc.push({
15 | code: coinCode,
16 | name: curr.english_name,
17 | name_kr: curr.korean_name,
18 | });
19 | }
20 | return acc;
21 | }, []);
22 | }
23 |
24 | // 업비트에서 실시간 시세정보 가져오는 함수
25 | async function getUpbitMarketDatas(marketCodes) {
26 | const responseBody = await axios({
27 | method: "get",
28 | baseURL: "https://api.upbit.com",
29 | url: `v1/ticker?markets=${marketCodes}`,
30 | }).then((response) => response.data);
31 | return responseBody;
32 | }
33 |
34 | // 코인마켓캡에서 코인정보 가져오는 함수 (시가총액, 최대발급량, 현재발급량 필요)
35 | async function getCoinData() {
36 | const responseBody = await axios({
37 | method: "get",
38 | baseURL: "https://pro-api.coinmarketcap.com",
39 | url: "v1/cryptocurrency/listings/latest?start=1&limit=2000&convert=KRW",
40 | headers: {
41 | Accept: "application/json",
42 | "X-CMC_PRO_API_KEY": process.env.COINMARKETCAP_API_KEY,
43 | },
44 | }).then((response) => response.data);
45 | return responseBody.data;
46 | }
47 |
48 | // 코인마켓캡에서 코인의 메타데이터 가져오는 함수 (코인 이미지, 코인 사이트주소, 코인설명 필요)
49 | /**
50 | *
51 | * @param {string} coinIds 조회할 코인ID들을 ,로 연결한 문자열
52 | * @returns
53 | */
54 | async function getCoinMetaData(coinIds) {
55 | const responseBody = await axios({
56 | method: "get",
57 | baseURL: "https://pro-api.coinmarketcap.com",
58 | url: `/v2/cryptocurrency/info?id=${coinIds}`,
59 | headers: {
60 | Accept: "application/json",
61 | "X-CMC_PRO_API_KEY": process.env.COINMARKETCAP_API_KEY,
62 | },
63 | }).then((response) => response.data);
64 | return responseBody.data;
65 | }
66 |
67 | module.exports = {
68 | getUpbitMarketCode,
69 | getCoinData,
70 | getCoinMetaData,
71 | getUpbitMarketDatas,
72 | };
73 |
--------------------------------------------------------------------------------
/api_server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "api-server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./app.js",
7 | "test": "node ./dataSample/test.js"
8 | },
9 | "dependencies": {
10 | "axios": "^0.27.2",
11 | "cors": "^2.8.5",
12 | "debug": "~2.6.9",
13 | "dotenv": "^16.0.3",
14 | "express": "~4.16.1",
15 | "http-errors": "~1.6.3"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "next",
8 | "next/core-web-vitals",
9 | "plugin:prettier/recommended",
10 | "plugin:@typescript-eslint/recommended"
11 | ],
12 | "parser": "@typescript-eslint/parser",
13 | "parserOptions": {
14 | "ecmaFeatures": {
15 | "jsx": true
16 | },
17 | "ecmaVersion": 12,
18 | "sourceType": "module"
19 | },
20 | "plugins": ["react", "@typescript-eslint"],
21 | "rules": {}
22 | }
23 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | #custom
4 | package-lock.json
5 |
6 | # dependencies
7 | /node_modules
8 | /.pnp
9 | .pnp.js
10 |
11 | # testing
12 | /coverage
13 |
14 | # next.js
15 | /.next/
16 | /out/
17 |
18 | # production
19 | /build
20 |
21 | # misc
22 | .DS_Store
23 | *.pem
24 |
25 | # debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 | .pnpm-debug.log*
30 |
31 | # local env files
32 | .env*.local
33 | .env
34 |
35 | # vercel
36 | .vercel
37 |
38 | # typescript
39 | *.tsbuildinfo
40 | next-env.d.ts
41 |
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "endOfLine": "auto",
8 | "arrowParens": "avoid"
9 | }
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/client/components/Candlechart/chartController.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CHART_Y_AXIS_MARGIN,
3 | CHART_X_AXIS_MARGIN,
4 | CANDLE_CHART_GRID_COLOR,
5 | CANDLE_COLOR_RED,
6 | CANDLE_COLOR_BLUE,
7 | DEFAULT_POINTER_DATA
8 | } from '@/constants/ChartConstants'
9 | import { RefElementSize } from '@/hooks/useRefElementSize'
10 | import {
11 | CandleData,
12 | ChartPeriod,
13 | CandleChartRenderOption,
14 | PointerData
15 | } from '@/types/ChartTypes'
16 | import {
17 | getYAxisScale,
18 | getXAxisScale,
19 | updateCurrentPrice,
20 | handleMouseEvent,
21 | getVolumeHeightScale
22 | } from '@/utils/chartManager'
23 | import * as d3 from 'd3'
24 | import { throttle } from 'lodash'
25 | import { RefObject, Dispatch, SetStateAction } from 'react'
26 | import { mobileEventClosure } from './mobileTouchEventClosure'
27 |
28 | export function initCandleChart(
29 | svgRef: RefObject,
30 | translateXSetter: Dispatch>,
31 | optionSetter: Dispatch>,
32 | pointerPositionSetter: Dispatch>,
33 | refElementSize: RefElementSize
34 | ) {
35 | initCandleChartSVG(svgRef, refElementSize)
36 | addEventsToChart(
37 | svgRef,
38 | optionSetter,
39 | translateXSetter,
40 | pointerPositionSetter,
41 | refElementSize
42 | )
43 | }
44 |
45 | function initCandleChartSVG(
46 | svgRef: RefObject,
47 | refElementSize: RefElementSize
48 | ) {
49 | const chartContainerXsize = refElementSize.width
50 | const chartContainerYsize = refElementSize.height
51 | const chartAreaXsize = chartContainerXsize - CHART_Y_AXIS_MARGIN
52 | const chartAreaYsize = chartContainerYsize - CHART_X_AXIS_MARGIN
53 | //margin값도 크기에 맞춰 변수화 시켜야함.
54 | const chartContainer = d3.select(svgRef.current)
55 | chartContainer.attr('width', chartContainerXsize)
56 | chartContainer.attr('height', chartContainerYsize)
57 | const chartArea = chartContainer.select('svg#chart-area')
58 | chartArea.attr('width', chartAreaXsize)
59 | chartArea.attr('height', chartAreaYsize)
60 | // xAxis초기값 설정
61 | chartContainer
62 | .select('svg#x-axis-container')
63 | .attr('width', chartAreaXsize)
64 | .attr('height', chartAreaYsize + 20)
65 | // currentPrice초기값 설정
66 | chartContainer.select('svg#current-price').attr('height', chartAreaYsize)
67 | // text 위치설정 매직넘버? 반응형 고려하면 변수화도 고려되어야할듯
68 | chartContainer.select('text#price-info').attr('x', 20).attr('y', 20)
69 | chartContainer.select('svg#mouse-pointer-UI').attr('pointer-events', 'none')
70 | }
71 |
72 | // let prevDistance = -1
73 | //불가피하게 지역변수 사용.. ㅠㅠ IIFE를 활용한 클로저 사용해보고 싶었으나, 세개의 콜백에 대해서 묶을 수 없어 지역변수 사용함
74 | export function addEventsToChart(
75 | svgRef: RefObject,
76 | optionSetter: Dispatch>,
77 | translateXSetter: Dispatch>,
78 | pointerPositionSetter: Dispatch>,
79 | refElementSize: RefElementSize
80 | ) {
81 | const chartContainerXsize = refElementSize.width
82 | const chartContainerYsize = refElementSize.height
83 | const chartAreaXsize = chartContainerXsize - CHART_Y_AXIS_MARGIN
84 | const chartAreaYsize = chartContainerYsize - CHART_X_AXIS_MARGIN
85 | if (svgRef.current === null) return
86 | const [start, drag, end] = mobileEventClosure(
87 | optionSetter,
88 | translateXSetter,
89 | pointerPositionSetter,
90 | chartAreaXsize,
91 | chartAreaYsize
92 | )
93 | d3.select(svgRef.current)
94 | .call(
95 | d3
96 | .drag()
97 | .on('start', start)
98 | .on('drag', drag)
99 | .on('end', end)
100 | )
101 | .on('wheel', (e: WheelEvent) => {
102 | e.preventDefault()
103 | optionSetter((prev: CandleChartRenderOption) => {
104 | const newCandleWidth = Math.min(
105 | Math.max(
106 | prev.candleWidth + (e.deltaY > 0 ? -1 : 1),
107 | prev.minCandleWidth
108 | ),
109 | prev.maxCandleWidth
110 | )
111 | const newRenderCandleCount = Math.ceil(chartAreaXsize / newCandleWidth)
112 | if (prev.maxDataLength !== Infinity) {
113 | const newMaxRenderStartDataIndex = Math.max(
114 | 0,
115 | prev.maxDataLength - newRenderCandleCount + 1
116 | )
117 | return {
118 | ...prev,
119 | maxRenderStartDataIndex: newMaxRenderStartDataIndex,
120 | renderCandleCount:
121 | newMaxRenderStartDataIndex === 0
122 | ? prev.renderCandleCount
123 | : newRenderCandleCount,
124 | candleWidth:
125 | newMaxRenderStartDataIndex === 0
126 | ? prev.candleWidth
127 | : newCandleWidth,
128 | renderStartDataIndex: Math.min(
129 | prev.renderStartDataIndex,
130 | newMaxRenderStartDataIndex
131 | )
132 | }
133 | }
134 | return {
135 | ...prev,
136 | renderCandleCount: newRenderCandleCount,
137 | candleWidth: newCandleWidth
138 | }
139 | })
140 | })
141 | d3.select(svgRef.current).on(
142 | 'mousemove',
143 | throttle((event: MouseEvent) => {
144 | handleMouseEvent(
145 | event,
146 | pointerPositionSetter,
147 | chartAreaXsize,
148 | chartAreaYsize
149 | )
150 | }, 50)
151 | )
152 | d3.select(svgRef.current)
153 | .select('svg#chart-area')
154 | .on('mouseleave', () => {
155 | pointerPositionSetter(DEFAULT_POINTER_DATA)
156 | })
157 | }
158 |
159 | // xAxis와 캔들유닛들 translate시키는 함수
160 | export function translateCandleChart(
161 | svgRef: RefObject,
162 | translateX: number
163 | ) {
164 | const chartArea = d3.select(svgRef.current).select('svg#chart-area')
165 | const $xAxis = d3.select(svgRef.current).select('g#x-axis')
166 | chartArea.selectAll('g').attr('transform', `translate(${translateX})`)
167 | if (!$xAxis.attr('transform')) {
168 | return
169 | }
170 | $xAxis.attr(
171 | 'transform',
172 | $xAxis.attr('transform').replace(/\(([0-9.\-]*),/, `(${translateX},`)
173 | )
174 | return
175 | }
176 |
177 | // 차트에 표시되는 데이터가 변경됨으로써 다시 data join이 일어나는 함수
178 | export function updateCandleChart(
179 | svgRef: RefObject,
180 | data: CandleData[],
181 | option: CandleChartRenderOption,
182 | refElementSize: RefElementSize,
183 | candlePeriod: ChartPeriod,
184 | translateX: number
185 | ) {
186 | const chartContainerXsize = refElementSize.width
187 | const chartContainerYsize = refElementSize.height
188 | const chartAreaXsize = chartContainerXsize - CHART_Y_AXIS_MARGIN
189 | const chartAreaYsize = chartContainerYsize - CHART_X_AXIS_MARGIN
190 | const candleWidth = option.candleWidth
191 | const chartContainer = d3.select(svgRef.current)
192 | const chartArea = chartContainer.select('svg#chart-area')
193 | const yAxisScale = getYAxisScale(
194 | data.slice(
195 | option.renderStartDataIndex,
196 | option.renderStartDataIndex + option.renderCandleCount
197 | ),
198 | chartAreaYsize
199 | )
200 | if (!yAxisScale) {
201 | return
202 | }
203 |
204 | const xAxisScale = getXAxisScale(option, data, chartAreaXsize, candlePeriod)
205 | UpdateAxis(
206 | chartContainer,
207 | xAxisScale,
208 | yAxisScale,
209 | chartAreaXsize,
210 | chartAreaYsize,
211 | candleWidth,
212 | translateX
213 | ) // axis를 업데이트한다.
214 |
215 | // 볼륨 스케일 함수, 추후 볼륨 추가시 해금예정
216 | // const volumeHeightScale = getVolumeHeightScale(
217 | // data.slice(
218 | // option.renderStartDataIndex,
219 | // option.renderStartDataIndex + option.renderCandleCount
220 | // ),
221 | // chartAreaYsize
222 | // )
223 |
224 | // 현재 가격을 업데이트한다.
225 | updateCurrentPrice(yAxisScale, data, option, chartAreaXsize, chartAreaYsize)
226 |
227 | chartArea
228 | .selectAll('g')
229 | .data(
230 | data.slice(
231 | option.renderStartDataIndex,
232 | option.renderStartDataIndex + option.renderCandleCount
233 | )
234 | )
235 | .join(
236 | enter => {
237 | const $g = enter.append('g')
238 | /* $g.append('rect').classed('volumeRect', true)
239 | $g = placeVolumeRect($g, chartAreaXsize, candleWidth, yAxisScale) */
240 | // 거래량, 개발예정, 성능 문제로 보류
241 | // $g.append('line')
242 | // $g.append('rect').classed('candleRect', true)
243 | placeCandleLine($g, chartAreaXsize, candleWidth, yAxisScale, xAxisScale)
244 | placeCandleRect($g, chartAreaXsize, candleWidth, yAxisScale, xAxisScale)
245 | placeFullRect($g, candleWidth, xAxisScale, chartAreaYsize)
246 | return $g
247 | },
248 | update => {
249 | update
250 | .select('rect.candleRect')
251 | .attr('width', candleWidth * 0.6)
252 | .attr('height', d =>
253 | Math.abs(yAxisScale(d.trade_price) - yAxisScale(d.opening_price)) <=
254 | 0
255 | ? 1
256 | : Math.abs(
257 | yAxisScale(d.trade_price) - yAxisScale(d.opening_price)
258 | )
259 | )
260 | .attr(
261 | 'x',
262 | d =>
263 | xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth * 0.8
264 | )
265 | .attr('y', d =>
266 | Math.min(yAxisScale(d.trade_price), yAxisScale(d.opening_price))
267 | )
268 | .attr('fill', d =>
269 | d.opening_price < d.trade_price
270 | ? CANDLE_COLOR_RED
271 | : CANDLE_COLOR_BLUE
272 | )
273 | update
274 | .select('rect.fullRect')
275 | .attr('width', candleWidth)
276 | .attr('height', chartAreaYsize)
277 | .attr(
278 | 'x',
279 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth
280 | )
281 | .attr('y', 0)
282 | update
283 | .select('line')
284 | .attr(
285 | 'x1',
286 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth / 2
287 | )
288 | .attr(
289 | 'x2',
290 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth / 2
291 | )
292 | .attr('y1', d => yAxisScale(d.low_price))
293 | .attr('y2', d => yAxisScale(d.high_price))
294 | // update
295 | // .select('rect.volumeRect')
296 | // .attr('width', candleWidth * 0.6)
297 | // .attr('height', 10)
298 | // .attr('height', d => d.candle_acc_trade_price)
299 | // .attr('x', (d, i) => chartAreaXsize - candleWidth * (i + 0.8))
300 | // .attr('y', 300)
301 | // .attr('fill', 'yellow')
302 | // .attr('opacity', 0.5)
303 | // 거래량
304 | return update
305 | },
306 | exit => {
307 | exit.remove()
308 | }
309 | )
310 | }
311 |
312 | function UpdateAxis(
313 | chartContainer: d3.Selection,
314 | xAxisScale: d3.ScaleTime,
315 | yAxisScale: d3.ScaleLinear,
316 | chartAreaXsize: number,
317 | chartAreaYsize: number,
318 | candleWidth: number,
319 | translateX: number
320 | ) {
321 | chartContainer
322 | .select('g#y-axis')
323 | .attr('transform', `translate(${chartAreaXsize},0)`)
324 | .call(d3.axisRight(yAxisScale).tickSizeInner(-1 * chartAreaXsize))
325 | .call(g => {
326 | g.selectAll('.tick line').attr('stroke', CANDLE_CHART_GRID_COLOR)
327 | g.selectAll('.tick text')
328 | .attr('stroke', 'black')
329 | .attr('font-size', '12px')
330 | })
331 | chartContainer
332 | .select('g#x-axis')
333 | .attr('transform', `translate(${translateX},${chartAreaYsize})`)
334 | .call(
335 | d3
336 | .axisBottom(xAxisScale)
337 | .tickSizeInner(-1 * chartAreaYsize)
338 | .tickSizeOuter(0)
339 | .ticks(5)
340 | )
341 | .call(g => {
342 | g.selectAll('.tick line').attr('stroke', CANDLE_CHART_GRID_COLOR)
343 | g.selectAll('.tick text')
344 | .attr('stroke', 'black')
345 | .attr('font-size', '12px')
346 | })
347 | }
348 |
349 | function placeCandleLine(
350 | $g: d3.Selection,
351 | chartAreaXsize: number,
352 | candleWidth: number,
353 | yAxisScale: d3.ScaleLinear,
354 | xAxisScale: d3.ScaleTime
355 | ) {
356 | $g.append('line')
357 | .attr(
358 | 'x1',
359 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth / 2
360 | )
361 | .attr(
362 | 'x2',
363 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth / 2
364 | )
365 | .attr('y1', d => yAxisScale(d.low_price))
366 | .attr('y2', d => yAxisScale(d.high_price))
367 | .attr('stroke', 'black')
368 | return $g
369 | }
370 |
371 | function placeCandleRect(
372 | $g: d3.Selection,
373 | chartAreaXsize: number,
374 | candleWidth: number,
375 | yAxisScale: d3.ScaleLinear,
376 | xAxisScale: d3.ScaleTime
377 | ) {
378 | $g.append('rect')
379 | .classed('candleRect', true)
380 | .attr('width', candleWidth * 0.6)
381 | .attr(
382 | 'height',
383 | d =>
384 | Math.abs(yAxisScale(d.trade_price) - yAxisScale(d.opening_price)) || 0.1
385 | )
386 | .attr(
387 | 'x',
388 | d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth * 0.8
389 | )
390 | .attr('y', d =>
391 | Math.min(yAxisScale(d.trade_price), yAxisScale(d.opening_price))
392 | )
393 | .attr('fill', d =>
394 | d.opening_price <= d.trade_price ? CANDLE_COLOR_RED : CANDLE_COLOR_BLUE
395 | )
396 | .attr('stroke', 'black')
397 | return
398 | }
399 |
400 | function placeFullRect(
401 | $g: d3.Selection,
402 | candleWidth: number,
403 | xAxisScale: d3.ScaleTime,
404 | chartAreaYsize: number
405 | ) {
406 | $g.append('rect')
407 | .classed('fullRect', true)
408 | .attr('x', d => xAxisScale(new Date(d.candle_date_time_kst)) - candleWidth)
409 | .attr('y', 0)
410 | .attr('width', candleWidth)
411 | .attr('height', chartAreaYsize)
412 | .attr('fill', 'transparent')
413 | }
414 |
415 | function placeVolumeRect(
416 | $g: d3.Selection,
417 | chartAreaXsize: number,
418 | candleWidth: number,
419 | yAxisScale: d3.ScaleLinear
420 | ) {
421 | $g.attr('width', candleWidth * 0.6)
422 | .attr('height', d => yAxisScale(d.trade_volume))
423 | .attr('x', (d, i) => chartAreaXsize - candleWidth * (i + 0.8))
424 | .attr('y', 30)
425 | .attr('fill', d =>
426 | d.opening_price <= d.trade_price ? CANDLE_COLOR_RED : CANDLE_COLOR_BLUE
427 | )
428 | return
429 | }
430 |
--------------------------------------------------------------------------------
/client/components/Candlechart/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useRef,
3 | useState,
4 | useEffect,
5 | Dispatch,
6 | SetStateAction,
7 | FunctionComponent
8 | } from 'react'
9 | import {
10 | CandleChartOption,
11 | CandleChartRenderOption,
12 | CandleData,
13 | PointerData
14 | } from '@/types/ChartTypes'
15 | import {
16 | checkNeedFetch,
17 | getInitRenderOption,
18 | getRenderOptionByWindow,
19 | updatePointerUI
20 | } from '@/utils/chartManager'
21 | import {
22 | DEFAULT_POINTER_DATA,
23 | MAX_FETCH_CANDLE_COUNT
24 | } from '@/constants/ChartConstants'
25 | import { getCandleDataArray } from '@/utils/upbitManager'
26 | import { useRefElementSize } from 'hooks/useRefElementSize'
27 | import { styled } from '@mui/material'
28 | import {
29 | initCandleChart,
30 | translateCandleChart,
31 | updateCandleChart
32 | } from './chartController'
33 | export interface CandleChartProps {
34 | chartOption: CandleChartOption
35 | candleData: CandleData[]
36 | candleDataSetter: Dispatch>
37 | }
38 |
39 | export const CandleChart: FunctionComponent = props => {
40 | const chartSvg = useRef(null)
41 | const chartContainerRef = useRef(null)
42 | const refElementSize = useRefElementSize(chartContainerRef)
43 | // 렌더링에 관여하는 모든 속성들
44 | const [option, setOption] = useState(
45 | getInitRenderOption(refElementSize.width)
46 | )
47 | // 캔들유닛들이 얼마나 translate되어있는지 분리
48 | const [translateX, setTranslateX] = useState(0)
49 | const [pointerInfo, setPointerInfo] =
50 | useState(DEFAULT_POINTER_DATA)
51 | const isFetching = useRef(false)
52 |
53 | // 차트 초기화 차트 구성요소 크기지정 및 렌더링 옵션 지정(창의 크기에 맞추어 변경)
54 | useEffect(() => {
55 | initCandleChart(
56 | chartSvg,
57 | setTranslateX,
58 | setOption,
59 | setPointerInfo,
60 | refElementSize
61 | )
62 | setOption(prev => getRenderOptionByWindow(refElementSize.width, prev))
63 | setPointerInfo(DEFAULT_POINTER_DATA)
64 | }, [refElementSize])
65 |
66 | // period혹은 market이 변경되면 모든 렌더옵션 초기화
67 | useEffect(() => {
68 | setOption(getInitRenderOption(refElementSize.width))
69 | }, [props.chartOption])
70 |
71 | // translateX의 변경에 따라 기존의 문서요소들을 이동만 시킨다.
72 | useEffect(() => {
73 | if (translateX < 0) {
74 | if (option.renderStartDataIndex === 0) {
75 | setTranslateX(0)
76 | return
77 | }
78 | setTranslateX(prev => option.candleWidth + (prev % option.candleWidth))
79 | setOption(prev => {
80 | const newOption = { ...prev }
81 | newOption.renderStartDataIndex = Math.max(
82 | newOption.renderStartDataIndex +
83 | Math.floor(translateX / option.candleWidth),
84 | 0
85 | )
86 | return newOption
87 | })
88 | return
89 | }
90 | if (option.renderStartDataIndex === option.maxRenderStartDataIndex) {
91 | setTranslateX(0)
92 | return
93 | }
94 | if (translateX >= option.candleWidth) {
95 | setTranslateX(prev => prev % option.candleWidth)
96 | setOption(prev => {
97 | const newRenderStartDataIndex = Math.min(
98 | prev.maxRenderStartDataIndex,
99 | prev.renderStartDataIndex +
100 | Math.floor(translateX / option.candleWidth)
101 | )
102 | return { ...prev, renderStartDataIndex: newRenderStartDataIndex }
103 | })
104 | return
105 | }
106 | translateCandleChart(chartSvg, translateX)
107 | }, [translateX, refElementSize, option])
108 |
109 | // 문서요소들을 다시 join해야할때
110 | // 더 최적화하려면 소켓을 통해 들어오는 0번 데이터 처리하기
111 | useEffect(() => {
112 | const needFetch = checkNeedFetch(props.candleData, option)
113 | if (needFetch && option.maxDataLength === Infinity) {
114 | if (!isFetching.current) {
115 | isFetching.current = true
116 | getCandleDataArray(
117 | props.chartOption.candlePeriod,
118 | props.chartOption.marketType,
119 | MAX_FETCH_CANDLE_COUNT,
120 | props.candleData[props.candleData.length - 1].candle_date_time_utc
121 | ).then(res => {
122 | if (res === null) {
123 | console.error('코인 쿼리 실패, 404에러')
124 | return
125 | }
126 | if (res.length === 0) {
127 | isFetching.current = false
128 | setOption(prev => {
129 | return {
130 | ...prev,
131 | maxDataLength: props.candleData.length,
132 | maxRenderStartDataIndex:
133 | props.candleData.length - prev.renderCandleCount + 1
134 | }
135 | })
136 | return
137 | }
138 | isFetching.current = false
139 | props.candleDataSetter(prev => {
140 | const lastDate = new Date(
141 | prev[prev.length - 1].candle_date_time_kst
142 | )
143 | const newDate = new Date(res[0].candle_date_time_kst)
144 | if (newDate < lastDate) {
145 | return [...prev, ...res]
146 | }
147 | return [...prev]
148 | })
149 | })
150 | }
151 | return
152 | }
153 | updateCandleChart(
154 | chartSvg,
155 | props.candleData,
156 | option,
157 | refElementSize,
158 | props.chartOption.candlePeriod,
159 | translateX
160 | )
161 | }, [props, option, refElementSize])
162 |
163 | useEffect(() => {
164 | updatePointerUI(
165 | pointerInfo,
166 | option,
167 | props.candleData,
168 | refElementSize,
169 | props.chartOption.candlePeriod
170 | )
171 | }, [pointerInfo, refElementSize, option, props])
172 |
173 | return (
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 | )
192 | }
193 |
194 | const ChartContainer = styled('div')`
195 | display: flex;
196 | height: 100%;
197 | width: 100%;
198 | background: #ffffff;
199 | ${props => props.theme.breakpoints.down('tablet')} {
200 | height: calc(100% - 50px);
201 | min-height: 300px;
202 | }
203 | `
204 |
--------------------------------------------------------------------------------
/client/components/Candlechart/mobileTouchEventClosure.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CandleData,
3 | CandleChartRenderOption,
4 | PointerData
5 | } from '@/types/ChartTypes'
6 | import { handleMouseEvent } from '@/utils/chartManager'
7 | import { D3DragEvent } from 'd3'
8 | import { Dispatch, SetStateAction } from 'react'
9 |
10 | const getEuclideanDistance = (touches: Touch[]): number => {
11 | return Math.sqrt(
12 | Math.pow(touches[0].clientX - touches[1].clientX, 2) +
13 | Math.pow(touches[0].clientY - touches[1].clientY, 2)
14 | )
15 | }
16 |
17 | export const mobileEventClosure = (
18 | optionSetter: Dispatch>,
19 | translateXSetter: Dispatch>,
20 | pointerPositionSetter: Dispatch>,
21 | chartAreaXsize: number,
22 | chartAreaYsize: number
23 | ) => {
24 | let prevDistance = -1
25 | const start = (event: D3DragEvent) => {
26 | if (event.identifier === 'mouse' || event.sourceEvent.touches.length !== 2)
27 | //마우스거나 (==데스크탑이거나), 2개의 멀티터치가 아니면 아무것도 하지 않음
28 | return
29 | prevDistance = getEuclideanDistance(event.sourceEvent.touches)
30 | }
31 | const drag = (event: D3DragEvent) => {
32 | if (event.identifier !== 'mouse' && event.sourceEvent.touches.length == 2) {
33 | //여기는 줌 코드
34 | const nowDistance = getEuclideanDistance(event.sourceEvent.touches)
35 | if (nowDistance === prevDistance) return
36 | const isZoomIn = prevDistance < nowDistance ? 0.5 : -0.5
37 | prevDistance = getEuclideanDistance(event.sourceEvent.touches)
38 | optionSetter((prev: CandleChartRenderOption) => {
39 | const newCandleWidth = Math.min(
40 | Math.max(prev.candleWidth + isZoomIn, prev.minCandleWidth),
41 | prev.maxCandleWidth
42 | )
43 | const newRenderCandleCount = Math.ceil(chartAreaXsize / newCandleWidth)
44 | return {
45 | ...prev,
46 | renderCandleCount: newRenderCandleCount,
47 | candleWidth: newCandleWidth
48 | }
49 | })
50 | return
51 | }
52 | //여기는 패닝 코드
53 | translateXSetter(prev => prev + event.dx)
54 | handleMouseEvent(
55 | event.sourceEvent,
56 | pointerPositionSetter,
57 | chartAreaXsize,
58 | chartAreaYsize
59 | )
60 | }
61 | const end = () => {
62 | prevDistance = -1
63 | }
64 | return [start, drag, end]
65 | }
66 |
--------------------------------------------------------------------------------
/client/components/ChartHeader/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled, useTheme } from '@mui/material/styles'
2 | import { CandleChartOption, ChartPeriod } from '@/types/ChartTypes'
3 | import { Dispatch, SetStateAction } from 'react'
4 | import InputLabel from '@mui/material/InputLabel'
5 | import Box from '@mui/material/Box'
6 | import MenuItem from '@mui/material/MenuItem'
7 | import FormControl from '@mui/material/FormControl'
8 | import Select, { SelectChangeEvent } from '@mui/material/Select'
9 | import { ChartPeriodList } from '@/types/ChartTypes'
10 | import { CoinPrice } from '@/types/CoinPriceTypes'
11 | import Image from 'next/image'
12 | import { Typography, useMediaQuery } from '@mui/material'
13 |
14 | interface ChartHeaderProps {
15 | chartOption: CandleChartOption
16 | chartOptionSetter: Dispatch>
17 | coinPriceInfo: CoinPrice
18 | }
19 |
20 | // 분봉선택과 코인정보를 나타내는 컴포넌트를 포함하는 컨테이너
21 | export default function ChartHeader(props: ChartHeaderProps) {
22 | return (
23 |
24 |
25 |
29 |
30 | )
31 | }
32 |
33 | interface HeaderCoinPriceInfoProps {
34 | coinPriceInfo: CoinPrice
35 | }
36 | // 코인의 정보를 표시하는 컴포넌트
37 | function HeaderCoinInfo(props: HeaderCoinPriceInfoProps) {
38 | const theme = useTheme()
39 | const coinPrice = props.coinPriceInfo
40 | const isMinus = coinPrice.signed_change_price <= 0
41 | const textColor =
42 | coinPrice.signed_change_price === 0
43 | ? 'black'
44 | : coinPrice.signed_change_price < 0
45 | ? theme.palette.custom.blue
46 | : theme.palette.custom.red
47 | const isSmallDesktop = useMediaQuery(
48 | theme.breakpoints.between('tablet', 'desktop')
49 | )
50 | return (
51 |
52 |
58 |
59 |
60 | {coinPrice.name_kr} {' '}
61 | {coinPrice.name + '/KRW'}
62 |
63 |
64 |
65 |
68 | {coinPrice.price.toLocaleString() + 'KRW'}
69 |
70 |
73 | {`${
74 | (isMinus ? '' : '+') +
75 | coinPrice.signed_change_price.toLocaleString() +
76 | 'KRW'
77 | }
78 | ${
79 | (isMinus ? '' : '+') +
80 | Math.floor(coinPrice.signed_change_rate * 10000) / 100
81 | }`}
82 | %
83 |
84 |
85 |
86 | )
87 | }
88 |
89 | // 기존의 ChartPeriodSelector
90 | interface ChartPeriodSelectorProps {
91 | selected: ChartPeriod
92 | selectedSetter: Dispatch>
93 | }
94 | function ChartPeriodSelector(props: ChartPeriodSelectorProps) {
95 | const theme = useTheme()
96 | const isSmallDesktop = useMediaQuery(
97 | theme.breakpoints.between('tablet', 'desktop')
98 | )
99 | const handleChange = (event: SelectChangeEvent) => {
100 | props.selectedSetter(prev => {
101 | return { ...prev, candlePeriod: event.target.value as ChartPeriod }
102 | })
103 | // as 사용을 지양해야하지만, 런타임 중에
104 | // ChartPeriod 이외에 다른 value가 들어올
105 | // 가능성이 없으므로 사용함.
106 | }
107 | return (
108 |
109 |
113 | 분봉 선택
114 |
121 | {ChartPeriodList.map(value => {
122 | return (
123 |
124 | {value}
125 |
126 | )
127 | })}
128 |
129 |
130 |
131 | )
132 | }
133 |
134 | const ChartHeaderContainer = styled('div')`
135 | display: flex;
136 | width: 100%;
137 | height: 10%;
138 | background-color: #ffffff;
139 | box-sizing: border-box;
140 | justify-content: space-around;
141 | align-items: center;
142 | ${props => props.theme.breakpoints.down('tablet')} {
143 | flex-direction: column;
144 | height: 150px;
145 | }
146 | `
147 |
148 | const HeaderCoinInfoContainer = styled('div')`
149 | display: flex;
150 | width: 50%;
151 | gap: 20px;
152 | align-items: center;
153 | text-align: right;
154 | & > div.name {
155 | & span {
156 | font-size: 10px;
157 | }
158 | & .big {
159 | font-size: 20px;
160 | font-weight: 600;
161 | }
162 | }
163 | ${props => props.theme.breakpoints.down('tablet')} {
164 | width: 100%;
165 | justify-content: center;
166 | }
167 | ${props => props.theme.breakpoints.between('tablet', 'desktop')} {
168 | gap: 5px;
169 | & > div.name {
170 | & span {
171 | font-size: 8px;
172 | }
173 | & .big {
174 | font-size: 16px;
175 | }
176 | }
177 | }
178 | `
179 |
--------------------------------------------------------------------------------
/client/components/ChartSelectController/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles'
2 | import { Dispatch, SetStateAction } from 'react'
3 | import InputLabel from '@mui/material/InputLabel'
4 | import Box from '@mui/material/Box'
5 | import MenuItem from '@mui/material/MenuItem'
6 | import FormControl from '@mui/material/FormControl'
7 | import Select, { SelectChangeEvent } from '@mui/material/Select'
8 | import { ChartTypeArr, ChartType } from '@/types/ChartTypes'
9 |
10 | interface ChartSelectControllerProps {
11 | selected: ChartType
12 | selectedSetter: Dispatch>
13 | }
14 | const ChartPeriodSelectorContainer = styled(Box)`
15 | display: flex;
16 | width: 100%;
17 | height: 10%;
18 | box-sizing: border-box;
19 | justify-content: center;
20 | align-items: center;
21 | margin-bottom: 10px;
22 | `
23 | //메인페이지 트리맵/러닝차트 선택 컴포넌트
24 | export default function ChartSelectController({
25 | selected,
26 | selectedSetter
27 | }: ChartSelectControllerProps) {
28 | const handleChange = (event: SelectChangeEvent) => {
29 | selectedSetter(event.target.value as ChartType)
30 | // as 사용을 지양해야하지만, 런타임 중에
31 | // ChartPeriod 이외에 다른 value가 들어올
32 | // 가능성이 없으므로 사용함.
33 | }
34 | return (
35 |
36 |
37 |
38 | 차트 선택
39 |
40 | {ChartTypeArr.map(value => {
41 | return (
42 |
43 | {value}
44 |
45 | )
46 | })}
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/client/components/ChartTagController/index.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box'
2 | import { convertUnit } from '@/utils/chartManager'
3 | import { MainChartPointerData } from '@/types/ChartTypes'
4 |
5 | interface ChartTagControllerProps {
6 | pointerInfo: MainChartPointerData
7 | }
8 |
9 | export default function ChartTagController({
10 | pointerInfo
11 | }: ChartTagControllerProps) {
12 | return (
13 | <>
14 |
36 | 코인명 : {pointerInfo.data?.name}
37 | 종목코드 : {pointerInfo.data?.ticker.split('-')[1]}
38 | 시가총액 : {convertUnit(Number(pointerInfo.data?.market_cap))}
39 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/client/components/CoinDetailedInfo/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react'
2 | import { TabProps } from '@/components/TabContainer'
3 | import { styled } from '@mui/material'
4 | import Image from 'next/image'
5 | import { CoinMetaData } from '@/types/CoinDataTypes'
6 | import { useCoinMetaData } from 'hooks/useCoinMetaData'
7 |
8 | //코인 상세정보
9 | interface CoinDetailedInfoProps extends TabProps {
10 | market: string
11 | }
12 | function CoinDetailedInfo({ market }: CoinDetailedInfoProps) {
13 | const coinMetaData: CoinMetaData | null = useCoinMetaData(market)
14 | return coinMetaData === null ? (
15 |
16 | ) : (
17 |
18 |
19 |
25 |
26 | {coinMetaData.name_kr}
27 | {coinMetaData.symbol}
28 |
29 |
30 |
31 |
32 | 코인 정보({coinMetaData.time} 기준, coinmarketcap 제공)
33 |
34 |
35 | 시가총액: {coinMetaData.market_cap_kr}원
36 | 시가총액 순위: {coinMetaData.cmc_rank}위
37 | 24시간 거래량: {coinMetaData.volume_24h}원
38 |
39 | 최대 공급량:{' '}
40 | {coinMetaData.max_supply === null
41 | ? '미정'
42 | : Math.floor(coinMetaData.max_supply).toLocaleString() +
43 | coinMetaData.symbol}
44 |
45 |
46 | 총 공급량:{' '}
47 | {Math.floor(coinMetaData.total_supply).toLocaleString() +
48 | coinMetaData.symbol}
49 |
50 |
51 | {coinMetaData.description}
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default memo(CoinDetailedInfo)
60 |
61 | const Container = styled('div')`
62 | display: flex;
63 | flex-direction: column;
64 | width: 100%;
65 | box-sizing: border-box;
66 | padding: 16px;
67 | background-color: #ffffff;
68 | font-size: 12px;
69 | ${props => props.theme.breakpoints.up('tablet')} {
70 | margin-bottom: 8px;
71 | }
72 | `
73 |
74 | const Header = styled('div')`
75 | display: flex;
76 | flex-direction: row;
77 | `
78 | const HeaderContent = styled('div')`
79 | display: flex;
80 | margin-left: 8px;
81 | font-size: 15px;
82 | flex-direction: column;
83 | justify-content: center;
84 | align-items: flex-start;
85 | `
86 |
87 | const Body = styled('div')``
88 | const BodyHeader = styled('div')`
89 | font-weight: bold;
90 | `
91 | const BodyContentContainer = styled('div')`
92 | height: auto;
93 | `
94 | const BodyContent = styled('div')``
95 |
--------------------------------------------------------------------------------
/client/components/CoinSelectController/MakeCoinDict.tsx:
--------------------------------------------------------------------------------
1 | import { MarketCapInfo } from '@/types/CoinDataTypes'
2 |
3 | interface dict {
4 | [key: string]: T
5 | }
6 |
7 | export default function MakeCoinDict(data: MarketCapInfo[]) {
8 | const dict: dict> = {}
9 | data.map(data => {
10 | let str_ticker = ''
11 | let str_kr = ''
12 | let str_es = ''
13 | for (const alpha of data.name) {
14 | str_ticker += alpha
15 | if (dict[str_ticker]) {
16 | dict[str_ticker].push(data.name)
17 | } else {
18 | dict[str_ticker] = [data.name]
19 | }
20 | }
21 |
22 | for (const alpha of data.name_kr) {
23 | str_kr += alpha
24 | if (!str_ticker.includes(str_kr)) {
25 | if (dict[str_kr]) {
26 | dict[str_kr].push(data.name)
27 | } else {
28 | dict[str_kr] = [data.name]
29 | }
30 | }
31 | }
32 |
33 | for (const alpha of data.name_es) {
34 | str_es += alpha.toUpperCase()
35 | if (!str_ticker.includes(str_es)) {
36 | if (dict[str_es]) {
37 | dict[str_es].push(data.name)
38 | } else {
39 | dict[str_es] = [data.name]
40 | }
41 | }
42 | }
43 | })
44 | return dict
45 | }
46 |
--------------------------------------------------------------------------------
/client/components/CoinSelectController/SearchCoin.tsx:
--------------------------------------------------------------------------------
1 | import TextField from '@mui/material/TextField'
2 | import { debounce } from 'lodash'
3 | import { Dispatch, SetStateAction } from 'react'
4 |
5 | interface SearchCoinProps {
6 | setInputCoinNameSetter: Dispatch>
7 | }
8 |
9 | export default function SearchCoin({
10 | setInputCoinNameSetter
11 | }: SearchCoinProps) {
12 | const handleChange = debounce(event => {
13 | setInputCoinNameSetter(event.target.value.toUpperCase())
14 | }, 10)
15 | return (
16 |
25 | )
26 | }
27 |
28 | const textFieldStyle = {
29 | backgroundColor: 'white',
30 | height: '48px',
31 | pr: 2,
32 | width: '100%',
33 | gap: 2
34 | }
35 |
--------------------------------------------------------------------------------
/client/components/CoinSelectController/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles'
2 | import Checkbox from '@mui/material/Checkbox'
3 | import {
4 | ChangeEvent,
5 | Dispatch,
6 | SetStateAction,
7 | useContext,
8 | useEffect,
9 | useState
10 | } from 'react'
11 | import Image from 'next/image'
12 | import { MarketCapInfo } from '@/types/CoinDataTypes'
13 | import SearchCoin from './SearchCoin'
14 | import MakeCoinDict from './MakeCoinDict'
15 | import { MyAppContext } from '@/pages/_app'
16 |
17 | interface dict {
18 | [key: string]: T
19 | }
20 |
21 | interface CoinChecked {
22 | [key: string]: boolean
23 | }
24 | interface CoinSelectControllerProps {
25 | selectedCoinListSetter: Dispatch>
26 | tabLabelInfo?: string
27 | }
28 |
29 | export default function CoinSelectController({
30 | selectedCoinListSetter
31 | }: CoinSelectControllerProps) {
32 | const data = useContext(MyAppContext)
33 | const [coinList, setCoinList] = useState(data)
34 | const [checked, setChecked] = useState(() => {
35 | const initCheckedList: CoinChecked = {
36 | all: true
37 | }
38 | if (coinList == null) return initCheckedList
39 | for (const coin of coinList) {
40 | initCheckedList[coin.name] = true
41 | }
42 | return initCheckedList
43 | })
44 | const [inputCoinName, setInputCoinName] = useState('')
45 | const [coinDict, setCoinDict] = useState>>(
46 | MakeCoinDict(data)
47 | )
48 |
49 | useEffect(() => {
50 | if (coinList == null) return
51 | for (const coin of coinList) {
52 | checked[coin.name] = true
53 | }
54 |
55 | setChecked({
56 | ...checked
57 | })
58 | }, [coinList])
59 |
60 | useEffect(() => {
61 | selectedCoinListSetter(
62 | Object.keys(checked).filter(x => {
63 | return checked[x] && x !== 'all'
64 | })
65 | )
66 | }, [checked])
67 |
68 | const coinCheckAll = (event: ChangeEvent) => {
69 | if (checked['all']) {
70 | for (const coin in checked) {
71 | checked[coin] = event.target.checked
72 | }
73 | } else {
74 | for (const coin in checked) {
75 | if (
76 | coin !== 'all' &&
77 | inputCoinName &&
78 | !coinDict[inputCoinName].includes(coin)
79 | )
80 | continue
81 | checked[coin] = event.target.checked
82 | }
83 | }
84 |
85 | setChecked({
86 | ...checked
87 | })
88 | }
89 |
90 | const coinCheck = (event: ChangeEvent) => {
91 | setChecked({
92 | ...checked,
93 | [event.target.name]: event.target.checked
94 | })
95 | }
96 |
97 | return (
98 |
99 |
100 |
101 | 코인 선택
102 |
103 | 전체 [선택/해제]
104 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | {coinList?.map((coin: MarketCapInfo, index) => {
117 | return (
118 |
129 |
130 | {coin.name_kr}
131 |
139 |
140 | )
141 | })}
142 |
143 |
144 | )
145 | }
146 |
147 | const Container = styled('div')`
148 | display: flex;
149 | background-color: #ffffff;
150 | flex-direction: column;
151 | width: 100%;
152 | height: 100%;
153 | padding-right: 8px;
154 | margin-bottom: 100px;
155 | `
156 | const Header = styled('div')`
157 | padding: 1rem;
158 | align-items: center;
159 | `
160 | const HeaderSelectCoin = styled('div')`
161 | display: flex;
162 | justify-content: space-between;
163 | padding: 1rem;
164 | align-items: center;
165 | `
166 | const HeaderSearchCoin = styled('div')`
167 | display: flex;
168 | justify-content: space-between;
169 | align-items: center;
170 | `
171 | const Body = styled('div')`
172 | overflow: scroll;
173 | display: flex;
174 | flex-direction: column;
175 | width: 100%;
176 | height: 100%;
177 | `
178 |
179 | const HeaderTitle = styled('div')`
180 | font-size: 1.5rem;
181 | `
182 |
183 | const HeaderSelectBox = styled('div')`
184 | display: flex;
185 | align-items: center;
186 | `
187 |
188 | const HeaderSelectBoxContent = styled('div')`
189 | font-size: 0.8rem;
190 | `
191 |
192 | const SelectCoinInnerLayer = styled('div')`
193 | display: flex;
194 | justify-content: space-between;
195 | align-items: center;
196 | padding: 1rem;
197 | width: 100%;
198 | `
199 |
200 | const SelectCoinInnerFont = styled('div')`
201 | align-items: center;
202 | font-size: 1rem;
203 | `
204 |
--------------------------------------------------------------------------------
/client/components/GNB/LogoImg.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import Link from 'next/link'
3 | import Image from 'next/image'
4 | import logoOnlyWhite from './logo-only-white.svg'
5 | import logoWhite from './logo-white.svg'
6 |
7 | interface LogoImgProps {
8 | isMobile: boolean
9 | }
10 |
11 | export default function LogoImg({ isMobile }: LogoImgProps) {
12 | const [checkMobile, setCheckMobile] = useState(true)
13 | useEffect(() => {
14 | setCheckMobile(isMobile)
15 | }, [isMobile])
16 |
17 | const MobileImageStyle = {
18 | paddingRight: '16px',
19 | display: checkMobile ? 'grid' : 'none'
20 | }
21 | const DesktopImageStyle = {
22 | margin: '0px 16px 0px 32px',
23 | display: checkMobile ? 'none' : 'grid'
24 | }
25 |
26 | return (
27 | <>
28 |
29 |
36 |
43 |
44 | >
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/client/components/GNB/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | import TextField from '@mui/material/TextField'
2 | import Stack from '@mui/material/Stack'
3 | import Autocomplete from '@mui/material/Autocomplete'
4 | import SearchIcon from '@mui/icons-material/Search'
5 | import { MarketCapInfo } from '@/types/CoinDataTypes'
6 | import { matchNameKRwithENG, validateInputName } from '@/utils/inputBarManager'
7 | import { MyAppContext } from '../../pages/_app'
8 | import { useMediaQuery } from '@mui/material'
9 | import theme from '@/styles/theme'
10 | import { styled } from '@mui/system'
11 | import { useContext, useRef } from 'react'
12 |
13 | export default function SearchInput() {
14 | const data = useContext(MyAppContext)
15 | const inputRef = useRef()
16 | const isMobile = useMediaQuery(theme.breakpoints.down('tablet'))
17 | function goToDetail(value: string) {
18 | const inputCoinName = value
19 | if (validateInputName(data, inputCoinName)) {
20 | const engCoinName = matchNameKRwithENG(data, inputCoinName)
21 | //생각해볼점
22 | //window.history.pushState('', 'asdf', `/detail/${engCoinName}`)
23 | // router.replace(`/detail/${engCoinName}`)
24 | window.location.href = `/detail/${engCoinName}`
25 | // router.push(`/detail/${engCoinName}`)
26 | }
27 | }
28 | return (
29 |
33 | goToDetail(value)}
38 | options={[
39 | ...data.map((coin: MarketCapInfo) => coin.name),
40 | ...data.map((coin: MarketCapInfo) => coin.name_kr)
41 | ]}
42 | renderInput={params => (
43 | {
64 | e.preventDefault()
65 | if (inputRef.current) goToDetail(inputRef.current.value)
66 | }}
67 | >
68 |
69 |
70 | )
71 | }}
72 | />
73 | )}
74 | />
75 |
76 | )
77 | }
78 | const StyledSearchIcon = styled('a')`
79 | display: flex;
80 | align-items: center;
81 | `
82 |
--------------------------------------------------------------------------------
/client/components/GNB/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles'
2 | import { Container, useMediaQuery, useTheme } from '@mui/material'
3 | import SearchInput from './SearchInput'
4 | import LogoImg from './LogoImg'
5 |
6 | export default function GNB() {
7 | const theme = useTheme()
8 | const isMobile = useMediaQuery(theme.breakpoints.down('tablet'))
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 | const ContainerStyle = {
19 | display: 'flex',
20 | alignItems: 'center',
21 | paddingLeft: '16px'
22 | }
23 |
24 | const GNBContainer = styled('div')`
25 | position: fixed;
26 | top: 0;
27 | left: 0;
28 | right: 0;
29 | z-index: 100;
30 | height: 96px;
31 | padding-top: 24px;
32 | background-color: ${props => props.theme.palette.primary.main};
33 | ${props => props.theme.breakpoints.down('tablet')} {
34 | padding-top: 8px;
35 | height: 64px;
36 | }
37 | ${props => props.theme.breakpoints.up('tablet')} {
38 | background-color: ${props => props.theme.palette.primary.dark};
39 | }
40 | align-items: center;
41 | justify-content: space-between;
42 | width: 100%;
43 | `
44 |
--------------------------------------------------------------------------------
/client/components/GNB/logo-only-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/components/GNB/logo-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/client/components/InfoSidebarContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import { Children, ReactNode } from 'react'
2 |
3 | interface InfoSidebarContainerProps {
4 | children: ReactNode
5 | }
6 | export default function InfoSidebarContainer({
7 | children
8 | }: InfoSidebarContainerProps) {
9 | return (
10 | <>
11 | {Children.map(children, child => {
12 | return child
13 | })}
14 | >
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/client/components/LinkButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@mui/material'
2 | import Link from 'next/link'
3 | import { memo } from 'react'
4 |
5 | interface ChartButtonProps {
6 | goto: string
7 | content: string
8 | style?: object
9 | }
10 | const LinkStyle = { textDecoration: 'none', width: '100%', marginTop: '8px' }
11 |
12 | function Chartbutton({
13 | goto = '/',
14 | content = 'default',
15 | style = {}
16 | }: ChartButtonProps) {
17 | return (
18 |
19 |
28 | {content}
29 |
30 |
31 | )
32 | }
33 |
34 | export default memo(Chartbutton)
35 |
--------------------------------------------------------------------------------
/client/components/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box'
2 | import Modal from '@mui/material/Modal'
3 | import { Dispatch, ReactNode, SetStateAction } from 'react'
4 |
5 | const boxStyle = {
6 | position: 'absolute',
7 | top: '50%',
8 | left: '50%',
9 | transform: 'translate(-50%, -50%)',
10 | width: 400,
11 | bgcolor: 'background.paper',
12 | border: '2px solid #000',
13 | boxShadow: 24,
14 | p: 4
15 | }
16 | interface MuiModalProps {
17 | isModalOpened: boolean
18 | setIsModalOpened: Dispatch>
19 | children: ReactNode
20 | }
21 | export default function MuiModal({
22 | isModalOpened,
23 | setIsModalOpened,
24 | children
25 | }: MuiModalProps) {
26 | const handleClose = () => setIsModalOpened(false)
27 |
28 | return (
29 |
30 |
36 | {children}
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/client/components/NoSelectedCoinAlertView/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMediaQuery, useTheme } from '@mui/material'
2 | import { styled } from '@mui/material'
3 | import Image from 'next/image'
4 | import dogeImage from '@/pages/doge.svg'
5 |
6 | const Container = styled('div')`
7 | width: 100%;
8 | height: 100%;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | h1 {
14 | font-size: 2em;
15 | font-weight: bold;
16 | }
17 |
18 | p {
19 | font-size: 1.5em;
20 | text-align: center;
21 | color: #666;
22 | }
23 | `
24 |
25 | export function NoSelectedCoinAlertView() {
26 | const isMobile = useMediaQuery(useTheme().breakpoints.down('tablet'))
27 | return (
28 |
29 | 선택된 코인이 없습니다..
30 |
36 |
37 | {isMobile
38 | ? '하단의 "차트 정보 더보기" 버튼을 클릭하여 차트에 보여줄 코인들을 선택해 주세요'
39 | : '좌측의 사이드바에서 차트에 보여줄 코인들을 선택해 주세요'}
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/client/components/RealTimeCoinPrice/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled, Typography, useTheme } from '@mui/material'
2 | import { TabProps } from '@/components/TabContainer'
3 | import Image from 'next/image'
4 | import { CoinPrice } from '@/types/CoinPriceTypes'
5 | import Link from 'next/link'
6 | import { useState, FunctionComponent, memo, useCallback } from 'react'
7 | //코인 실시간 정보
8 |
9 | const sortTypeArr = [
10 | 'signed_change_rate',
11 | 'acc_trade_price_24h',
12 | 'price'
13 | ] as const
14 | type sortType = typeof sortTypeArr[number]
15 |
16 | interface SortManual {
17 | toSort: sortType
18 | sortDirection: boolean
19 | }
20 |
21 | export default function RealTimeCoinPrice(props: TabProps) {
22 | const [sortManual, setSortManual] = useState({
23 | toSort: 'acc_trade_price_24h',
24 | sortDirection: true
25 | })
26 |
27 | const sortHandler = useCallback(
28 | (clicked: sortType) => {
29 | setSortManual(prev => {
30 | if (prev.toSort === clicked) {
31 | return { toSort: clicked, sortDirection: !prev.sortDirection }
32 | }
33 | return { toSort: clicked, sortDirection: true }
34 | })
35 | },
36 | [setSortManual]
37 | )
38 |
39 | return (
40 |
41 |
42 |
43 | {props.priceInfo &&
44 | Object.values(props.priceInfo)
45 | .sort((a, b) => {
46 | return (
47 | (sortManual.sortDirection ? 1 : -1) *
48 | (b[sortManual.toSort] - a[sortManual.toSort])
49 | )
50 | })
51 | .map(coinPrice => (
52 |
53 | ))}
54 |
55 |
56 | )
57 | }
58 |
59 | interface CoinPriceTabProps {
60 | coinPrice: CoinPrice
61 | }
62 |
63 | const CoinPriceTab: FunctionComponent = ({ coinPrice }) => {
64 | const theme = useTheme()
65 | const isMinus = coinPrice.signed_change_price <= 0
66 | const textColor =
67 | coinPrice.signed_change_price === 0
68 | ? 'black'
69 | : coinPrice.signed_change_price < 0
70 | ? theme.palette.custom.blue
71 | : theme.palette.custom.red
72 | return (
73 |
77 |
78 |
79 |
80 |
81 | {coinPrice.name_kr}
82 |
83 |
84 | {coinPrice.name}
85 |
86 |
87 |
88 |
89 | {coinPrice.price.toLocaleString()}
90 |
91 |
92 |
93 |
94 | {(isMinus ? '' : '+') +
95 | coinPrice.signed_change_price.toLocaleString()}
96 |
97 |
98 | {(isMinus ? '' : '+') +
99 | Math.floor(coinPrice.signed_change_rate * 10000) / 100}
100 | %
101 |
102 |
103 |
104 | {transPrice(coinPrice.acc_trade_price_24h)}
105 |
106 |
107 |
108 | )
109 | }
110 |
111 | const MemoCoinPriceTab = memo(CoinPriceTab)
112 |
113 | interface CoinPriceHeaderProps {
114 | sortHandler: (clicked: sortType) => void
115 | }
116 |
117 | const CoinPriceHeader: FunctionComponent = ({
118 | sortHandler
119 | }) => {
120 | return (
121 |
150 | )
151 | }
152 |
153 | const MemoCoinPriceHeader = memo(CoinPriceHeader)
154 |
155 | const Container = styled('div')`
156 | display: flex;
157 | flex-direction: column;
158 | width: 100%;
159 | overflow-y: auto;
160 | padding: 8px;
161 | background-color: #ffffff;
162 | ${props => props.theme.breakpoints.down('tablet')} {
163 | height: 100%;
164 | }
165 | `
166 |
167 | const Header = styled('div')`
168 | width: 100%;
169 | font-size: 13px;
170 | font-weight: bold;
171 | text-align: center;
172 | gap: 4px;
173 | padding-left: 40px;
174 | & > div.header {
175 | display: flex;
176 | -webkit-user-select: none;
177 | -moz-user-select: none;
178 | -ms-user-select: none;
179 | user-select: none;
180 | & > div:first-of-type {
181 | flex: 2;
182 | }
183 | & > div:nth-of-type(n + 2) {
184 | flex: 1;
185 | }
186 |
187 | & > div:not(:first-of-type) {
188 | cursor: pointer;
189 | :hover {
190 | background-color: ${props => props.theme.palette.primary.main};
191 | color: #ffffff;
192 | transition: 0.5s;
193 | transform: scale(1.1); /* default */
194 | -webkit-transform: scale(1.1); /* 크롬 */
195 | -moz-transform: scale(1.1); /* FireFox */
196 | -o-transform: scale(1.1); /* Opera */
197 | }
198 | }
199 | }
200 | `
201 |
202 | const CoinPriceContainer = styled('div')`
203 | flex: 1 1 auto;
204 | margin-top: 8px;
205 | width: 100%;
206 | overflow-y: auto;
207 | display: flex;
208 | flex-direction: column;
209 | gap: 5px;
210 | `
211 |
212 | const CoinPriceDiv = styled('div')`
213 | display: flex;
214 | width: 100%;
215 | height: 50px;
216 | font-size: 12px;
217 | gap: 4px;
218 | align-items: center;
219 | text-align: right;
220 | & a {
221 | text-decoration: none;
222 | color: black;
223 | }
224 | & > div.name {
225 | flex: 2;
226 | text-align: left;
227 | padding-left: 10px;
228 | }
229 | & > div.price {
230 | flex: 1;
231 | }
232 | & > div.yesterday {
233 | flex: 1;
234 | }
235 | & > div.amount {
236 | flex: 1;
237 | }
238 | :hover {
239 | background-color: #eee6e6;
240 | transition: 0.5s;
241 | }
242 | `
243 |
244 | const transPrice = (price: number): string => {
245 | return Math.floor(price / 1000000).toLocaleString() + '백만'
246 | }
247 |
--------------------------------------------------------------------------------
/client/components/Runningchart/index.tsx:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import {
3 | useState,
4 | useEffect,
5 | useRef,
6 | RefObject,
7 | FunctionComponent,
8 | SetStateAction,
9 | Dispatch
10 | } from 'react'
11 | import {
12 | CoinRateContentType,
13 | CoinRateType,
14 | MainChartPointerData
15 | } from '@/types/ChartTypes'
16 | import { useRefElementSize } from 'hooks/useRefElementSize'
17 | import {
18 | colorQuantizeScale,
19 | MainChartHandleMouseEvent
20 | } from '@/utils/chartManager'
21 | import { convertUnit } from '@/utils/chartManager'
22 | import {
23 | DEFAULT_RUNNING_POINTER_DATA,
24 | RUNNING_CHART_RATE_MULTIPLIER,
25 | RUNNING_CHART_NAME_MULTIPLIER
26 | } from '@/constants/ChartConstants'
27 | import ChartTagController from '../ChartTagController'
28 | import { styled } from '@mui/material'
29 |
30 | //------------------------------interface------------------------------
31 | interface RunningChartProps {
32 | data: CoinRateType //선택된 코인 리스트
33 | Market: string[]
34 | selectedSort: string
35 | modalOpenHandler: (market: string) => void
36 | durationPeriod?: number
37 | isMobile: boolean
38 | }
39 |
40 | //------------------------------setChartContainerSize------------------------------
41 | const setChartContainerSize = (
42 | svgRef: RefObject,
43 | width: number,
44 | height: number
45 | ) => {
46 | const chartContainer = d3.select(svgRef.current)
47 | chartContainer.attr('width', width)
48 | chartContainer.attr('height', height)
49 | }
50 |
51 | //------------------------------updateChart------------------------------
52 | const updateChart = (
53 | durationPeriod: number,
54 | svgRef: RefObject,
55 | data: CoinRateType,
56 | width: number,
57 | height: number,
58 | candleCount: number,
59 | selectedSort: string,
60 | nodeOnclickHandler: (market: string) => void,
61 | setPointerHandler: Dispatch>,
62 | isMobile: boolean
63 | ) => {
64 | //ArrayDataValue : 기존 Object이던 data를 data.value, 즉 실시간변동 퍼센테이지 값만 추출해서 Array로 변경
65 | const ArrayDataValue: CoinRateContentType[] = [
66 | ...Object.values(data)
67 | ].sort((a, b) => {
68 | switch (selectedSort) {
69 | case 'descending':
70 | return d3.descending(a.value, b.value) // 내림차순
71 | case 'ascending':
72 | return d3.ascending(a.value, b.value) // 오름차순
73 | case 'absolute':
74 | return d3.descending(Math.abs(a.value), Math.abs(b.value)) // 절댓값
75 | case 'trade price':
76 | return d3.descending(a.acc_trade_price_24h, b.acc_trade_price_24h) // 거래량
77 | default:
78 | return d3.descending(a.market_cap, b.market_cap) //시가총액
79 | }
80 | })
81 | const max = (() => {
82 | switch (selectedSort) {
83 | case 'descending':
84 | return d3.max(ArrayDataValue, d => Math.abs(d.value)) // 내림차순
85 | case 'ascending':
86 | return d3.max(ArrayDataValue, d => Math.abs(d.value)) // 오름차순
87 | case 'absolute':
88 | return d3.max(ArrayDataValue, d => Math.abs(d.value)) // 절댓값
89 | case 'trade price':
90 | return d3.max(ArrayDataValue, d => d.acc_trade_price_24h) // 거래량
91 | default:
92 | return d3.max(ArrayDataValue, d => d.market_cap) //시가총액
93 | }
94 | })()
95 |
96 | if (max === undefined) {
97 | console.error('정상적인 등락률 데이터가 아닙니다.')
98 | return
99 | }
100 | const threshold = max <= 66 ? (max <= 33 ? 33 : 66) : Math.max(max, 100) // 66보다 큰 경우는 시가총액 or 66% 이상
101 | const domainRange = [0, threshold]
102 |
103 | const barMargin = height / 10 / 5 //바 사이사이 마진값
104 | const barHeight = Math.max(
105 | Math.min(height / candleCount, height / 15),
106 | height / 20
107 | ) //각각의 수평 바 y 높이
108 | setChartContainerSize(svgRef, width, (barHeight + barMargin) * candleCount)
109 |
110 | const scale = d3
111 | .scaleLinear()
112 | .domain(domainRange)
113 | .range([100, width - 100])
114 |
115 | const svgChart = d3
116 | .select('#running-chart')
117 | .attr('width', width)
118 | .attr('height', (barHeight + barMargin) * candleCount)
119 |
120 | svgChart
121 | .selectAll('g')
122 | .data(ArrayDataValue, d => d.name)
123 | .join(
124 | enter => {
125 | const $g = enter
126 | .append('g')
127 | .on('click', function (e, d) {
128 | nodeOnclickHandler(d.ticker.split('-')[1])
129 | }) //this 사용을 위해 함수 선언문 형식 사용
130 | .on('mousemove', function (d, i) {
131 | if (isMobile) return
132 | d3.select(this).style('opacity', '.70')
133 | MainChartHandleMouseEvent(d, setPointerHandler, i, width, height)
134 | })
135 | //this 사용을 위해 함수 선언문 형식 사용
136 | .on('mouseleave', function (d, i) {
137 | if (isMobile) return
138 | d3.select(this).style('opacity', '1')
139 | MainChartHandleMouseEvent(d, setPointerHandler, i, width, height)
140 | })
141 | $g.attr(
142 | 'transform',
143 | (d, i) => 'translate(0,' + i * (barHeight + barMargin) + ')'
144 | )
145 | .style('opacity', 1)
146 | .style('cursor', 'pointer')
147 | $g.append('rect')
148 | .attr('height', barHeight)
149 | .transition()
150 | .duration(durationPeriod)
151 | .attr('width', d => {
152 | return scale(
153 | selectedSort !== 'trade price'
154 | ? selectedSort !== 'market capitalization'
155 | ? Math.abs(d.value)
156 | : d.market_cap
157 | : d.acc_trade_price_24h
158 | )
159 | })
160 |
161 | .style('fill', d => {
162 | if (d.value > 0) return colorQuantizeScale(max, d.value)
163 | else if (d.value === 0) return 'gray'
164 | else {
165 | return colorQuantizeScale(max, d.value)
166 | }
167 | })
168 |
169 | $g.append('text')
170 |
171 | .attr('text-anchor', 'middle')
172 | .attr('dominant-baseline', 'middle')
173 |
174 | .text(d =>
175 | selectedSort !== 'trade price'
176 | ? selectedSort === 'market capitalization'
177 | ? convertUnit(Number(d.market_cap))
178 | : String(Number(d.value).toFixed(2)) + '%'
179 | : convertUnit(Number(d.acc_trade_price_24h))
180 | )
181 | .attr('y', barHeight / 2)
182 | .transition()
183 | .duration(durationPeriod * 2)
184 | .style('font-size', `${barHeight * RUNNING_CHART_RATE_MULTIPLIER}px`)
185 | .attr('x', d => {
186 | return (
187 | scale(
188 | selectedSort !== 'trade price'
189 | ? selectedSort !== 'market capitalization'
190 | ? Math.abs(d.value)
191 | : d.market_cap
192 | : d.acc_trade_price_24h
193 | ) / 2
194 | )
195 | })
196 |
197 | $g.append('text')
198 | .attr('class', 'CoinName')
199 | .style('font-size', `${barHeight * RUNNING_CHART_NAME_MULTIPLIER}px`)
200 | .attr('text-anchor', 'start')
201 | .attr('dominant-baseline', 'middle')
202 | .text(d => d.ticker.split('-')[1])
203 | .attr('y', barHeight / 2)
204 | .transition()
205 | .duration(durationPeriod * 1)
206 | .attr('x', d => {
207 | return scale(
208 | selectedSort !== 'trade price'
209 | ? selectedSort !== 'market capitalization'
210 | ? Math.abs(d.value)
211 | : d.market_cap
212 | : d.acc_trade_price_24h
213 | )
214 | })
215 |
216 | return $g
217 | },
218 | update => {
219 | update
220 | .on('click', function (e, d) {
221 | nodeOnclickHandler(d.ticker.split('-')[1])
222 | }) //this 사용을 위해 함수 선언문 형식 사용
223 | .on('mousemove', function (d, i) {
224 | if (isMobile) return
225 | d3.select(this).style('opacity', '.70')
226 | MainChartHandleMouseEvent(d, setPointerHandler, i, width, height)
227 | })
228 | .on('mouseleave', function (d, i) {
229 | if (isMobile) return
230 | d3.select(this).style('opacity', '1')
231 | MainChartHandleMouseEvent(d, setPointerHandler, i, width, height)
232 | })
233 | update
234 | .transition()
235 | .duration(durationPeriod)
236 | .attr(
237 | 'transform',
238 | (d, i) => `translate(0, ${i * (barHeight + barMargin)} )`
239 | )
240 |
241 | update
242 | .select('rect')
243 | .transition()
244 | .duration(durationPeriod)
245 | .attr('height', barHeight)
246 | .attr('width', d => {
247 | return scale(
248 | selectedSort !== 'trade price'
249 | ? selectedSort !== 'market capitalization'
250 | ? Math.abs(d.value)
251 | : d.market_cap
252 | : d.acc_trade_price_24h
253 | )
254 | })
255 | .style('fill', d => {
256 | if (d.value > 0) return colorQuantizeScale(max, d.value)
257 | else if (d.value === 0) return 'gray'
258 | else return colorQuantizeScale(max, d.value)
259 | })
260 | update
261 | .select('text')
262 | .attr('x', d => {
263 | return (
264 | scale(
265 | selectedSort !== 'trade price'
266 | ? selectedSort !== 'market capitalization'
267 | ? Math.abs(d.value)
268 | : d.market_cap
269 | : d.acc_trade_price_24h
270 | ) / 2
271 | )
272 | })
273 | .attr('y', barHeight / 2)
274 | .attr('text-anchor', 'middle')
275 | .attr('dominant-baseline', 'middle')
276 | .style('font-size', `${barHeight * RUNNING_CHART_RATE_MULTIPLIER}px`)
277 | .text(d =>
278 | selectedSort !== 'trade price'
279 | ? selectedSort === 'market capitalization'
280 | ? convertUnit(Number(d.market_cap))
281 | : String(Number(d.value).toFixed(2)) + '%'
282 | : convertUnit(Number(d.acc_trade_price_24h))
283 | )
284 |
285 | update
286 | .select('.CoinName')
287 | .transition()
288 | .duration(durationPeriod)
289 | .attr('x', d => {
290 | return scale(
291 | selectedSort !== 'trade price'
292 | ? selectedSort !== 'market capitalization'
293 | ? Math.abs(d.value)
294 | : d.market_cap
295 | : d.acc_trade_price_24h
296 | )
297 | })
298 | .attr('y', barHeight / 2)
299 | .attr('text-anchor', 'start')
300 | .attr('dominant-baseline', 'middle')
301 | .style('font-size', `${barHeight * RUNNING_CHART_NAME_MULTIPLIER}px`)
302 | .text(d => d.ticker.split('-')[1])
303 | return update
304 | },
305 | exit => {
306 | exit.remove()
307 | }
308 | )
309 | }
310 | //------------------------------Component------------------------------
311 | export const RunningChart: FunctionComponent = ({
312 | durationPeriod = 500,
313 | data,
314 | Market,
315 | selectedSort,
316 | modalOpenHandler,
317 | isMobile
318 | }) => {
319 | const chartContainerRef = useRef(null)
320 | const chartSvg = useRef(null)
321 | const { width, height } = useRefElementSize(chartContainerRef)
322 | const [changeRate, setchangeRate] = useState({}) //선택된 코인 값만 보유
323 | const [pointerInfo, setPointerInfo] = useState(
324 | DEFAULT_RUNNING_POINTER_DATA
325 | )
326 |
327 | useEffect(() => {
328 | if (!Object.keys(changeRate).length) return
329 | updateChart(
330 | durationPeriod,
331 | chartSvg,
332 | changeRate,
333 | width,
334 | height,
335 | Market.length,
336 | selectedSort,
337 | modalOpenHandler,
338 | setPointerInfo,
339 | isMobile
340 | )
341 | }, [
342 | width,
343 | height,
344 | changeRate,
345 | selectedSort,
346 | durationPeriod,
347 | Market.length,
348 | modalOpenHandler,
349 | isMobile
350 | ]) // 창크기에 따른 차트크기 조절
351 | useEffect(() => {
352 | if (!data || !Market[0]) return
353 | const newCoinData: CoinRateType = {}
354 | for (const tick of Market) {
355 | newCoinData['KRW-' + tick] = data['KRW-' + tick]
356 | }
357 | setchangeRate(newCoinData)
358 | }, [data, Market])
359 | return (
360 |
361 |
362 |
363 |
364 |
365 |
366 | )
367 | }
368 | const ChartContainer = styled('div')`
369 | display: flex;
370 | width: 100%;
371 | background: #ffffff;
372 | height: 100%;
373 | overflow: auto;
374 | `
375 |
--------------------------------------------------------------------------------
/client/components/SortSelectController/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles'
2 | import InputLabel from '@mui/material/InputLabel'
3 | import Box from '@mui/material/Box'
4 | import MenuItem from '@mui/material/MenuItem'
5 | import FormControl from '@mui/material/FormControl'
6 | import Select, { SelectChangeEvent } from '@mui/material/Select'
7 | import { Dispatch, SetStateAction } from 'react'
8 |
9 | interface SortSelectControllerProps {
10 | selectedSort: string
11 | selectedSortSetter: Dispatch>
12 | selectedChart: string
13 | }
14 |
15 | type sortType = {
16 | [value: string]: string
17 | }
18 |
19 | export default function SortSelectController({
20 | selectedSort,
21 | selectedSortSetter,
22 | selectedChart
23 | }: SortSelectControllerProps) {
24 | const handleChange = (event: SelectChangeEvent) => {
25 | selectedSortSetter(event.target.value)
26 | }
27 | const treeSortTypeArr = [
28 | 'change rate',
29 | 'change rate(absolute)',
30 | 'market capitalization',
31 | 'trade price'
32 | ]
33 | const runningSortTypeArr = [
34 | 'ascending',
35 | 'descending',
36 | 'absolute',
37 | 'market capitalization',
38 | 'trade price'
39 | ]
40 | const sortType: sortType = {
41 | 'change rate': '등락률',
42 | 'change rate(absolute)': '등락률(절대값)',
43 | 'market capitalization': '시가총액',
44 | 'trade price': '24시간 거래량',
45 | ascending: '등락률(오름차순)',
46 | descending: '등락률(내림차순)',
47 | absolute: '등락률(절대값)'
48 | }
49 | return (
50 |
51 |
52 |
53 | 정렬 기준
54 |
55 | {selectedChart === 'RunningChart'
56 | ? runningSortTypeArr.map(value => {
57 | return (
58 |
59 | {sortType[value]}
60 |
61 | )
62 | })
63 | : treeSortTypeArr.map(value => {
64 | return (
65 |
66 | {sortType[value]}
67 |
68 | )
69 | })}
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | const SortSelectorContainer = styled(Box)`
78 | display: flex;
79 | width: 100%;
80 | height: 10%;
81 | box-sizing: border-box;
82 | justify-content: center;
83 | align-items: center;
84 | `
85 |
--------------------------------------------------------------------------------
/client/components/SwiperableDrawer/index.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box'
2 | import SwipeableDrawer from '@mui/material/SwipeableDrawer'
3 | import Fab from '@mui/material/Fab'
4 | import NavigationIcon from '@mui/icons-material/Navigation'
5 | import {
6 | Dispatch,
7 | ReactNode,
8 | SetStateAction,
9 | MouseEvent,
10 | KeyboardEvent
11 | } from 'react'
12 | interface SwipeableTemporaryDrawerProps {
13 | buttonLabel: string
14 | isDrawerOpened: boolean
15 | setIsDrawerOpened: Dispatch>
16 | children: ReactNode
17 | }
18 |
19 | export default function SwipeableTemporaryDrawer({
20 | buttonLabel,
21 | isDrawerOpened,
22 | setIsDrawerOpened,
23 | children
24 | }: SwipeableTemporaryDrawerProps) {
25 | const toggleDrawer =
26 | (open: boolean) => (event: KeyboardEvent | MouseEvent) => {
27 | if (
28 | event &&
29 | event.type === 'keydown' &&
30 | ((event as KeyboardEvent).key === 'Tab' ||
31 | (event as KeyboardEvent).key === 'Shift')
32 | ) {
33 | return
34 | }
35 | setIsDrawerOpened(open)
36 | }
37 |
38 | return (
39 |
40 |
48 |
49 | {buttonLabel}
50 |
51 |
57 | {children}
58 |
59 |
60 | )
61 | }
62 |
63 | const style = {
64 | height: '400px',
65 | maxHeight: '80vh', //400px보다 화면 높이가 작을경우, 최대 값 정의, 100vh면 drawer를 나갈 수 없다.. 적당히 80vh 설정
66 | width: '100%'
67 | }
68 |
--------------------------------------------------------------------------------
/client/components/TabBox/index.tsx:
--------------------------------------------------------------------------------
1 | import Box from '@mui/material/Box'
2 | import { ReactNode } from 'react'
3 | import { TabProps } from '@/components/TabContainer'
4 |
5 | interface TabBoxProps extends TabProps {
6 | children: ReactNode
7 | }
8 | const style = {
9 | display: 'flex',
10 | flexDirection: 'column',
11 | justifyContent: 'center',
12 | height: '100%'
13 | }
14 | export default function TabBox({ children }: TabBoxProps) {
15 | return {children}
16 | }
17 |
--------------------------------------------------------------------------------
/client/components/TabContainer/index.tsx:
--------------------------------------------------------------------------------
1 | import Tabs from '@mui/material/Tabs'
2 | import Tab from '@mui/material/Tab'
3 | import Box from '@mui/material/Box'
4 | import { CoinPriceObj } from '@/types/CoinPriceTypes'
5 | import {
6 | Dispatch,
7 | SetStateAction,
8 | ReactNode,
9 | Children,
10 | isValidElement,
11 | SyntheticEvent
12 | } from 'react'
13 |
14 | export interface TabProps {
15 | tabLabelInfo?: string
16 | priceInfo?: CoinPriceObj
17 | }
18 | interface TabContainerProps {
19 | selectedTab: number
20 | setSelectedTab: Dispatch>
21 | children: ReactNode
22 | }
23 | interface TabPanelProps {
24 | children?: ReactNode
25 | index: number
26 | value: number
27 | }
28 |
29 | function TabPanel({ children, value, index, ...other }: TabPanelProps) {
30 | return (
31 |
38 | {children}
39 | {/* {value === index && {children} } */}
40 |
41 | )
42 | }
43 |
44 | function a11yProps(index: number) {
45 | return {
46 | id: `simple-tab-${index}`,
47 | 'aria-controls': `simple-tabpanel-${index}`
48 | }
49 | }
50 |
51 | export default function TabContainer({
52 | selectedTab,
53 | setSelectedTab,
54 | children
55 | }: TabContainerProps) {
56 | const handleChange = (event: SyntheticEvent, newValue: number) => {
57 | setSelectedTab(newValue)
58 | }
59 | return (
60 |
61 |
62 |
68 | {Children.map(children, (child, index) => {
69 | if (!isValidElement(child)) {
70 | console.error('올바른 리액트 노드가 아님')
71 | return false
72 | }
73 | return (
74 |
78 | )
79 | })}
80 |
81 |
82 | {Children.map(children, (child, index) => {
83 | if (!isValidElement(child)) {
84 | console.error('올바른 리액트 노드가 아님')
85 | return false
86 | }
87 | return (
88 |
89 | {child}
90 |
91 | )
92 | })}
93 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/client/components/Treechart/GetCoinData.tsx:
--------------------------------------------------------------------------------
1 | import { getTreeMapDataArray } from '@/utils/upbitManager'
2 | import { CoinRateType } from '@/types/ChartTypes'
3 |
4 | export async function UpdateTreeData(coinRate: CoinRateType) {
5 | const tosetData = { ...coinRate }
6 | const tick = Object.keys(tosetData).join(',')
7 | //tick은 코인들의 이름배열 [KRW-BTC,KRW-ETC 등등]
8 | const allUpbitData = await getTreeMapDataArray(tick)
9 | for (const coin of allUpbitData) {
10 | if (tosetData[coin.market]) {
11 | tosetData[coin.market].value = Number(
12 | (coin.signed_change_rate * 100) //실시간 등락rate를 퍼센테이지로 변경
13 | .toFixed(2)
14 | ) //소수점 두자리로 fix
15 | }
16 | }
17 | return tosetData
18 | }
19 |
--------------------------------------------------------------------------------
/client/components/Treechart/index.tsx:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import {
3 | useState,
4 | useEffect,
5 | useRef,
6 | SetStateAction,
7 | Dispatch,
8 | RefObject
9 | } from 'react'
10 | import { useRefElementSize } from '@/hooks/useRefElementSize'
11 | import {
12 | CoinRateType,
13 | CoinRateContentType,
14 | MainChartPointerData
15 | } from '@/types/ChartTypes'
16 | import { colorQuantizeScale } from '@/utils/chartManager'
17 | import { convertUnit, MainChartHandleMouseEvent } from '@/utils/chartManager'
18 | import ChartTagController from '../ChartTagController'
19 | import { DEFAULT_RUNNING_POINTER_DATA } from '@/constants/ChartConstants'
20 | import { styled } from '@mui/system'
21 |
22 | const updateChart = (
23 | svgRef: RefObject,
24 | data: CoinRateContentType[],
25 | width: number,
26 | height: number,
27 | selectedSort: string,
28 | nodeOnclickHandler: (market: string) => void,
29 | setPointerHandler: Dispatch>,
30 | isMobile: boolean
31 | ) => {
32 | if (!svgRef.current) return
33 | const chartContainer = d3.select(
34 | svgRef.current
35 | )
36 | chartContainer.attr('width', width)
37 | chartContainer.attr('height', height)
38 | const chartArea = d3.select('svg#chart-area')
39 | const [min, max]: number[] = [
40 | d3.min(data, d => Math.abs(d.value)) as number,
41 | d3.max(data, d => d.value) as number
42 | ]
43 | const root: d3.HierarchyNode = d3
44 | .stratify()
45 | .id((d): string => {
46 | return d.name
47 | })
48 | .parentId((d): string => {
49 | return d.parent
50 | })(data)
51 | const sort = (
52 | a: d3.HierarchyNode,
53 | b: d3.HierarchyNode
54 | ) => {
55 | if (selectedSort === 'change rate') {
56 | return d3.descending(a.data.value, b.data.value)
57 | }
58 | if (selectedSort === 'change rate(absolute)') {
59 | return d3.descending(Math.abs(a.data.value), Math.abs(b.data.value))
60 | }
61 | if (selectedSort === 'trade price') {
62 | return d3.descending(
63 | a.data.acc_trade_price_24h,
64 | b.data.acc_trade_price_24h
65 | )
66 | }
67 | return d3.ascending(a.data.cmc_rank, b.data.cmc_rank)
68 | }
69 |
70 | root
71 | .sum((d): number => {
72 | if (d.name === 'Origin') {
73 | return 0
74 | }
75 | if (
76 | selectedSort === 'change rate' ||
77 | selectedSort === 'change rate(absolute)'
78 | ) {
79 | return Math.max(0.1, Math.abs(d.value))
80 | }
81 | if (selectedSort === 'trade price') {
82 | return Math.max(0.1, Math.abs(d.acc_trade_price_24h))
83 | }
84 | return Math.max(0.1, Math.abs(d.market_cap))
85 | })
86 | .sort(sort)
87 |
88 | d3.treemap().size([width, height]).padding(4)(root)
89 |
90 | chartArea
91 | .selectAll('g')
92 | .data>(
93 | root.leaves() as Array>
94 | )
95 | .join(
96 | enter => {
97 | const $g = enter
98 | .append('g')
99 | .on('click', (e, d) => {
100 | nodeOnclickHandler(d.data.ticker.split('-')[1])
101 | })
102 | //this 사용을 위해 함수 선언문 형식 사용
103 | .on('mousemove', function (d, i) {
104 | if (isMobile) return
105 | d3.select(this).style('opacity', '.70')
106 | MainChartHandleMouseEvent(
107 | d,
108 | setPointerHandler,
109 | i.data,
110 | width,
111 | height
112 | )
113 | })
114 | //this 사용을 위해 함수 선언문 형식 사용
115 | .on('mouseleave', function (d, i) {
116 | if (isMobile) return
117 | d3.select(this).style('opacity', '1')
118 | MainChartHandleMouseEvent(
119 | d,
120 | setPointerHandler,
121 | i.data,
122 | width,
123 | height
124 | )
125 | })
126 | $g.append('rect')
127 | .attr('x', d => {
128 | return d.x0
129 | })
130 | .attr('y', d => {
131 | return d.y0
132 | })
133 | .transition()
134 | .duration(500)
135 | .attr('width', d => {
136 | return d.x1 - d.x0
137 | })
138 | .attr('height', d => {
139 | return d.y1 - d.y0
140 | })
141 | .attr('fill', d => {
142 | return d.data.value >= 0
143 | ? d.data.value > 0
144 | ? colorQuantizeScale(max, d.data.value)
145 | : 'gray'
146 | : colorQuantizeScale(min, d.data.value)
147 | })
148 | .style('stroke', 'gray')
149 |
150 | $g.append('text')
151 | .attr('x', d => {
152 | return d.x0 + Math.abs(d.x1 - d.x0) / 2
153 | })
154 | .attr('y', d => {
155 | return d.y0 + Math.abs(d.y1 - d.y0) / 2
156 | })
157 | .attr('text-anchor', 'middle')
158 | .text(d => {
159 | // 초기값 changerate 아니라면 수정해줘야함
160 | return (
161 | d.data.ticker?.split('-')[1] +
162 | '\n' +
163 | String(Number(d.data.value).toFixed(2)) +
164 | '%'
165 | )
166 | })
167 | .style('font-size', d => {
168 | return `${(d.x1 - d.x0) / 9}px`
169 | })
170 | .attr('fill', 'white')
171 | return $g
172 | },
173 | update => {
174 | update
175 | .on('click', (e, d) => {
176 | nodeOnclickHandler(d.data.ticker.split('-')[1])
177 | })
178 | //this 사용을 위해 함수 선언문 형식 사용
179 | .on('mousemove', function (d, i) {
180 | if (isMobile) return
181 | d3.select(this).style('opacity', '.70')
182 | MainChartHandleMouseEvent(
183 | d,
184 | setPointerHandler,
185 | i.data,
186 | width,
187 | height
188 | )
189 | })
190 | .on('mouseleave', function (d, i) {
191 | if (isMobile) return
192 | d3.select(this).style('opacity', '1')
193 | MainChartHandleMouseEvent(
194 | d,
195 | setPointerHandler,
196 | i.data,
197 | width,
198 | height
199 | )
200 | })
201 | update
202 | .select('rect')
203 | .transition()
204 | .duration(500)
205 | .attr('x', d => {
206 | return d.x0
207 | })
208 | .attr('y', d => {
209 | return d.y0
210 | })
211 | .attr('width', d => {
212 | return d.x1 - d.x0
213 | })
214 | .attr('height', d => {
215 | return d.y1 - d.y0
216 | })
217 | .attr('fill', d => {
218 | return d.data.value >= 0
219 | ? d.data.value > 0
220 | ? colorQuantizeScale(max, d.data.value)
221 | : 'gray'
222 | : colorQuantizeScale(min, d.data.value)
223 | })
224 | .transition()
225 | .duration(500)
226 | .style('stroke', 'gray')
227 |
228 | update
229 | .select('text')
230 | .transition()
231 | .duration(500)
232 | .attr('x', d => {
233 | return d.x0 + Math.abs(d.x1 - d.x0) / 2
234 | })
235 | .attr('y', d => {
236 | return d.y0 + Math.abs(d.y1 - d.y0) / 2
237 | })
238 | .attr('text-anchor', 'middle')
239 | .text(d => {
240 | const text =
241 | selectedSort !== 'trade price'
242 | ? selectedSort === 'market capitalization'
243 | ? convertUnit(Number(d.data.market_cap))
244 | : String(Number(d.data.value).toFixed(2)) + '%'
245 | : convertUnit(Number(d.data.acc_trade_price_24h))
246 | return d.data.ticker?.split('-')[1] + '\n' + text
247 | })
248 | .style('font-size', d => {
249 | return `${(d.x1 - d.x0) / 9}px`
250 | })
251 | .attr('fill', 'white')
252 | return update
253 | },
254 | exit => {
255 | exit.remove()
256 | }
257 | )
258 | }
259 |
260 | const initChart = (
261 | svgRef: RefObject,
262 | width: number,
263 | height: number
264 | ) => {
265 | const zoom = d3
266 | .zoom()
267 | .on('zoom', handleZoom)
268 | .scaleExtent([1, 30]) //scale 제한
269 | .translateExtent([
270 | [0, 0], // top-left-corner 좌표
271 | [width, height] //bottom-right-corner 좌표
272 | ])
273 | function handleZoom(e: d3.D3ZoomEvent) {
274 | d3.selectAll('rect').attr(
275 | 'transform',
276 | `translate(${e.transform.x}, ${e.transform.y}) scale(${e.transform.k}, ${e.transform.k})`
277 | )
278 | d3.selectAll('text').attr(
279 | 'transform',
280 | `translate(${e.transform.x}, ${e.transform.y}) scale(${e.transform.k}, ${e.transform.k})`
281 | )
282 | }
283 | if (!svgRef.current) return
284 | const chartContainer = d3
285 | .select(svgRef.current)
286 | .call(zoom)
287 | chartContainer.attr('width', width)
288 | chartContainer.attr('height', height)
289 | }
290 |
291 | export interface TreeChartProps {
292 | data: CoinRateType
293 | Market?: string[] //선택된 코인 리스트
294 | selectedSort: string
295 | modalOpenHandler: (market: string) => void
296 | isMobile: boolean
297 | }
298 | export default function TreeChart({
299 | data,
300 | Market, //= ['CELO', 'ETH', 'MFT', 'WEMIX']
301 | selectedSort,
302 | modalOpenHandler,
303 | isMobile
304 | }: TreeChartProps) {
305 | const [changeRate, setChangeRate] = useState([
306 | {
307 | name: 'Origin',
308 | ticker: '',
309 | acc_trade_price_24h: 0,
310 | parent: '',
311 | value: 0,
312 | market_cap: 0
313 | }
314 | ]) //coin의 등락률 값에 parentNode가 추가된 값
315 | const chartSvg = useRef(null)
316 | const chartContainerSvg = useRef(null)
317 | const { width, height } = useRefElementSize(chartContainerSvg)
318 | const [pointerInfo, setPointerInfo] = useState(
319 | DEFAULT_RUNNING_POINTER_DATA
320 | )
321 |
322 | useEffect(() => {
323 | initChart(chartSvg, width, height)
324 | }, [width, height])
325 |
326 | useEffect(() => {
327 | // CoinRate에 코인 등락률이 업데이트되면 ChangeRate에 전달
328 | if (!data || !Market) return
329 | const newCoinData: CoinRateContentType[] = [
330 | {
331 | name: 'Origin',
332 | ticker: '',
333 | acc_trade_price_24h: 0,
334 | parent: '',
335 | value: 0,
336 | market_cap: 0
337 | }
338 | ]
339 | for (const tick of Market) {
340 | newCoinData.push(data['KRW-' + tick])
341 | }
342 | setChangeRate(newCoinData)
343 | }, [data, Market])
344 | useEffect(() => {
345 | updateChart(
346 | chartSvg,
347 | changeRate,
348 | width,
349 | height,
350 | selectedSort,
351 | modalOpenHandler,
352 | setPointerInfo,
353 | isMobile
354 | )
355 | }, [changeRate, width, height, selectedSort, modalOpenHandler])
356 | return (
357 |
358 |
359 |
360 |
361 |
362 |
363 | )
364 | }
365 |
366 | const ChartContainer = styled('div')`
367 | display: flex;
368 | width: 100%;
369 | height: 100%;
370 | background: #ffffff;
371 | cursor: pointer;
372 | `
373 |
--------------------------------------------------------------------------------
/client/constants/ChartConstants.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChartPeriod,
3 | CandleChartOption,
4 | PointerData,
5 | MainChartPointerData
6 | } from '@/types/ChartTypes'
7 |
8 | export const CHART_Y_AXIS_MARGIN = 70
9 | export const CHART_X_AXIS_MARGIN = 20
10 |
11 | export const MAX_FETCH_CANDLE_COUNT = 200
12 | export const DEFAULT_CANDLE_PERIOD: ChartPeriod = 'minutes/1'
13 | export const DEFAULT_CANDLE_COUNT = 30
14 | export const DEFAULT_RENDER_START_INDEX = 0
15 | export const DEFAULT_MAX_RENDER_START_INDEX = Infinity
16 | export const DEFAULT_MAX_CANDLE_COUNT = Infinity
17 | export const DEFAULT_CANDLE_CHART_OPTION: CandleChartOption = {
18 | marketType: 'BTC',
19 | isMovingAverageVisible: false,
20 | isVolumeVisible: false,
21 | candlePeriod: DEFAULT_CANDLE_PERIOD
22 | }
23 |
24 | export const DEFAULT_POINTER_DATA: PointerData = {
25 | positionX: -1,
26 | positionY: -1,
27 | data: null
28 | }
29 |
30 | export const DEFAULT_RUNNING_POINTER_DATA: MainChartPointerData = {
31 | offsetX: -1,
32 | offsetY: -1,
33 | data: null
34 | }
35 |
36 | export const RUNNING_CHART_RATE_MULTIPLIER = 0.3
37 | export const RUNNING_CHART_NAME_MULTIPLIER = 0.6
38 | export const CANDLE_COLOR_RED = '#D24F45'
39 | export const CANDLE_COLOR_BLUE = '#1D61C4'
40 | export const CANDLE_CHART_GRID_COLOR = '#EEEFEE'
41 | export const CANDLE_CHART_POINTER_LINE_COLOR = '#999999'
42 | export const CHART_FONT_SIZE = `12px`
43 | export const CHART_AXIS_RECT_WIDTH = 70
44 | export const CHART_AXIS_RECT_HEIGHT = 16
45 |
--------------------------------------------------------------------------------
/client/hooks/useCoinMetaData.ts:
--------------------------------------------------------------------------------
1 | import { CoinMetaData } from '@/types/CoinDataTypes'
2 | import { getCoinMetaData } from '@/utils/metaDataManages'
3 | import { useEffect, useState } from 'react'
4 |
5 | export const useCoinMetaData = (coinCode: string): CoinMetaData | null => {
6 | const [coinData, setCoinData] = useState(null)
7 | useEffect(() => {
8 | getCoinMetaData(coinCode.toUpperCase()).then(result => {
9 | setCoinData(result)
10 | })
11 | }, [coinCode])
12 | return coinData
13 | }
14 |
--------------------------------------------------------------------------------
/client/hooks/useInterval.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | export default function useInterval(callback: () => unknown, delay: number) {
4 | const savedCallback = useRef(callback) // 최근에 들어온 callback을 저장할 ref를 하나 만든다.
5 |
6 | useEffect(() => {
7 | savedCallback.current = callback // callback이 바뀔 때마다 ref를 업데이트 해준다.
8 | }, [callback])
9 |
10 | useEffect(() => {
11 | const executeCallback = () => {
12 | savedCallback.current()
13 | }
14 | if (!!delay) {
15 | const timerId = setInterval(executeCallback, delay)
16 | return () => clearInterval(timerId)
17 | }
18 | }, [])
19 | }
20 |
--------------------------------------------------------------------------------
/client/hooks/useRealTimeCoinListData.ts:
--------------------------------------------------------------------------------
1 | import { CoinRateContentType, CoinRateType } from '@/types/ChartTypes'
2 | import { MarketCapInfo } from '@/types/CoinDataTypes'
3 | import { SocketTickerData } from '@/types/CoinPriceTypes'
4 | import { useRef, useState, useEffect } from 'react'
5 | import useInterval from './useInterval'
6 |
7 | const COIN_INTERVAL_RATE = 1000
8 | let socket: WebSocket | undefined
9 | const getInitData = (data: MarketCapInfo[]): CoinRateType => {
10 | //initData
11 | const initData: CoinRateType = {}
12 | data.forEach(coinData => {
13 | const coinContent: CoinRateContentType = {
14 | name: '',
15 | ticker: '',
16 | parent: '',
17 | acc_trade_price_24h: 0,
18 | market_cap: 0,
19 | cmc_rank: 0,
20 | value: 0
21 | }
22 | coinContent.name = coinData.name_kr
23 | coinContent.ticker = 'KRW-' + coinData.name
24 | coinContent.parent = 'Origin'
25 | coinContent.acc_trade_price_24h = coinData.acc_trade_price_24h
26 | coinContent.market_cap = Number(coinData.market_cap)
27 | coinContent.cmc_rank = Number(coinData.cmc_rank)
28 | coinContent.value = Number((coinData.signed_change_rate * 100).toFixed(2))
29 | initData[coinContent.ticker] = coinContent
30 | })
31 |
32 | return initData
33 | }
34 | export function useRealTimeCoinListData(data: MarketCapInfo[]) {
35 | const [coinData, setCoinData] = useState(getInitData(data))
36 | const coinDataStoreRef = useRef(coinDataStoreGenerator())
37 | const isWindowForeGround = useRef(true)
38 | useEffect(() => {
39 | const setVisibilityCallback = () => {
40 | if (socket === undefined) location.reload()
41 | isWindowForeGround.current = !document.hidden
42 | //안보이면 false, 보이면 true
43 | }
44 | document.addEventListener('visibilitychange', setVisibilityCallback)
45 | connectWS(data, coinDataStoreRef.current.setCoinData)
46 | return () => {
47 | document.removeEventListener('visibilitychange', setVisibilityCallback)
48 | closeWS()
49 | }
50 | }, [])
51 |
52 | useInterval(() => {
53 | if (!isWindowForeGround.current) {
54 | //창이 background 상태면 바로 return
55 | return
56 | }
57 | const storedCoinData = coinDataStoreRef.current.getCoinData()
58 | setCoinData(prev => getNewCoinData(prev, storedCoinData))
59 | }, COIN_INTERVAL_RATE)
60 | return coinData
61 | }
62 |
63 | function connectWS(
64 | data: MarketCapInfo[],
65 | setCoinData: (tickData: SocketTickerData) => void
66 | ) {
67 | if (socket !== undefined) {
68 | socket.close()
69 | }
70 |
71 | socket = new WebSocket('wss://api.upbit.com/websocket/v1')
72 | socket.binaryType = 'arraybuffer'
73 |
74 | socket.onopen = function () {
75 | const markets = data.map(coinData => 'KRW-' + coinData.name).join(',')
76 | filterRequest(`[{"ticket":"test"},{"type":"ticker","codes":[${markets}]}]`)
77 | }
78 | socket.onclose = function () {
79 | socket = undefined
80 | }
81 | socket.onmessage = function (e) {
82 | const enc = new TextDecoder('utf-8')
83 | const arr = new Uint8Array(e.data)
84 | const str_d = enc.decode(arr)
85 | const d = JSON.parse(str_d)
86 | setCoinData(d)
87 | }
88 | }
89 |
90 | // 웹소켓 연결 해제
91 | function closeWS() {
92 | if (socket !== undefined) {
93 | socket.close()
94 | socket = undefined
95 | }
96 | }
97 |
98 | // 웹소켓 요청
99 | function filterRequest(filter: string) {
100 | if (socket == undefined) {
101 | return
102 | }
103 | socket.send(filter)
104 | }
105 |
106 | interface CoinDataStore {
107 | [key: string]: Partial
108 | }
109 |
110 | function coinDataStoreGenerator() {
111 | let coinDataStore: CoinDataStore = {}
112 | return {
113 | setCoinData: (newTickerData: SocketTickerData) => {
114 | coinDataStore[newTickerData.code] = transToCoinData(newTickerData)
115 | return
116 | },
117 | getCoinData: () => {
118 | const storedCoinData: CoinDataStore = { ...coinDataStore }
119 | coinDataStore = {}
120 | return storedCoinData
121 | }
122 | }
123 | }
124 |
125 | function transToCoinData(
126 | newTickerData: SocketTickerData
127 | ): Partial {
128 | return {
129 | acc_trade_price_24h: newTickerData.acc_trade_price_24h,
130 | value: Number((newTickerData.signed_change_rate * 100).toFixed(2))
131 | }
132 | }
133 |
134 | function getNewCoinData(
135 | prevData: CoinRateType,
136 | storedPrice: CoinDataStore
137 | ): CoinRateType {
138 | const newCoinData = { ...prevData }
139 | Object.keys(storedPrice).forEach(code => {
140 | newCoinData[code] = { ...newCoinData[code], ...storedPrice[code] }
141 | })
142 | return newCoinData
143 | }
144 |
--------------------------------------------------------------------------------
/client/hooks/useRealTimeUpbitData.ts:
--------------------------------------------------------------------------------
1 | import { CandleChartOption, CandleData } from '@/types/ChartTypes'
2 | import { useEffect, useState, useRef, Dispatch, SetStateAction } from 'react'
3 | import { ChartPeriod } from '@/types/ChartTypes'
4 | import { getCandleDataArray } from '@/utils/upbitManager'
5 | import { transDate } from '@/utils/dateManager'
6 | import {
7 | CoinPrice,
8 | CoinPriceObj,
9 | SocketTickerData
10 | } from '@/types/CoinPriceTypes'
11 | import useInterval from '@/hooks/useInterval'
12 | let socket: WebSocket | undefined
13 |
14 | export const useRealTimeUpbitData = (
15 | candleChartOption: CandleChartOption,
16 | initData: CandleData[],
17 | priceInfo: CoinPriceObj
18 | ): [CandleData[], Dispatch>, CoinPriceObj] => {
19 | const [period, market] = [
20 | candleChartOption.candlePeriod,
21 | candleChartOption.marketType
22 | ]
23 | const [realtimeCandleData, setRealtimeCandleData] =
24 | useState(initData)
25 | const [realtimePriceInfo, setRealtimePriceInfo] =
26 | useState(priceInfo)
27 | const isInitialMount = useRef(true)
28 | const priceStoreRef = useRef(priceStoreGenerator())
29 |
30 | // 1초 간격으로 store에 저장된 가격정보 변동을 realtimePriceInfo상태에 반영 -> 실시간 가격정보 컴포넌트 리렌더링
31 | useInterval(() => {
32 | setRealtimePriceInfo(prev =>
33 | getNewRealTimePrice(prev, priceStoreRef.current.getPrice())
34 | )
35 | }, 1000)
36 |
37 | useEffect(() => {
38 | connectWS(priceInfo)
39 | return () => {
40 | closeWS()
41 | }
42 | }, [])
43 |
44 | useEffect(() => {
45 | if (!socket) {
46 | console.error('분봉 설정 관련 error')
47 | location.reload()
48 | return
49 | }
50 | socket.onmessage = function (e) {
51 | const enc = new TextDecoder('utf-8')
52 | const arr = new Uint8Array(e.data)
53 | const str_d = enc.decode(arr)
54 | const d = JSON.parse(str_d)
55 | if (d.type == 'ticker') {
56 | const code = d.code.split('-')[1]
57 | if (code === market) {
58 | setRealtimeCandleData(prevData => updateData(prevData, d, period))
59 | }
60 | // 소켓으로 정보가 전달되면 store에 저장
61 | priceStoreRef.current.setPrice(d, code)
62 | }
63 | }
64 | const fetchData = async () => {
65 | const fetched: CandleData[] | null = await getCandleDataArray(
66 | period,
67 | market,
68 | 200
69 | )
70 | if (fetched === null) {
71 | console.error('코인 쿼리 실패, 404에러')
72 | return
73 | }
74 | setRealtimeCandleData(fetched)
75 | }
76 | if (!isInitialMount.current) fetchData() //첫 마운트면
77 | else isInitialMount.current = false
78 | }, [market, period, priceInfo])
79 |
80 | return [realtimeCandleData, setRealtimeCandleData, realtimePriceInfo] //socket을 state해서 같이 뺀다. 변화감지 (끊길때) -> ui표시..
81 | }
82 |
83 | function connectWS(priceInfo: CoinPriceObj) {
84 | if (socket !== undefined) {
85 | socket.close()
86 | }
87 |
88 | socket = new WebSocket('wss://api.upbit.com/websocket/v1')
89 | socket.binaryType = 'arraybuffer'
90 |
91 | socket.onopen = function () {
92 | const markets = Object.keys(priceInfo)
93 | .map(code => `"KRW-${code}"`)
94 | .join(',')
95 | filterRequest(`[{"ticket":"test"},{"type":"ticker","codes":[${markets}]}]`)
96 | }
97 | socket.onclose = function () {
98 | socket = undefined
99 | }
100 | }
101 |
102 | // 웹소켓 연결 해제
103 | function closeWS() {
104 | if (socket !== undefined) {
105 | socket.close()
106 | socket = undefined
107 | }
108 | }
109 |
110 | // 웹소켓 요청
111 | function filterRequest(filter: string) {
112 | if (socket == undefined) {
113 | return
114 | }
115 | socket.send(filter)
116 | }
117 |
118 | function updateData(
119 | prevData: CandleData[],
120 | newTickData: CandleData,
121 | candlePeriod: ChartPeriod
122 | ): CandleData[] {
123 | newTickData.candle_date_time_kst = transDate(
124 | newTickData.trade_timestamp,
125 | candlePeriod
126 | )
127 | if (prevData[0].candle_date_time_kst === newTickData.candle_date_time_kst) {
128 | prevData[0].low_price = Math.min(
129 | prevData[0].low_price,
130 | newTickData.trade_price
131 | )
132 | prevData[0].high_price = Math.max(
133 | prevData[0].high_price,
134 | newTickData.trade_price
135 | )
136 | prevData[0].trade_price = newTickData.trade_price
137 | prevData[0].timestamp = newTickData.trade_timestamp
138 | return [...prevData]
139 | }
140 |
141 | const toInsert = newTickData
142 | toInsert.opening_price = toInsert.trade_price
143 | toInsert.low_price = toInsert.trade_price
144 | toInsert.high_price = toInsert.trade_price
145 | toInsert.timestamp = toInsert.trade_timestamp
146 | return [toInsert, ...prevData]
147 | }
148 |
149 | // 새로운 RealTimePrice상태를 반환
150 | function getNewRealTimePrice(
151 | prevData: CoinPriceObj,
152 | storedPrice: PriceStore
153 | ): CoinPriceObj {
154 | const newRealTimePrice = { ...prevData }
155 | Object.keys(storedPrice).forEach(code => {
156 | const newCoinPrice = { ...newRealTimePrice[code], ...storedPrice[code] }
157 | newRealTimePrice[code] = newCoinPrice
158 | })
159 | return newRealTimePrice
160 | }
161 |
162 | interface PriceStore {
163 | [key: string]: Partial
164 | }
165 |
166 | // 소켓으로 전달받는 가격정보를 임시로 저장할 store
167 | function priceStoreGenerator() {
168 | let priceStore: PriceStore = {}
169 | return {
170 | setPrice: (newTickerData: SocketTickerData, code: string) => {
171 | priceStore[code] = transToCoinPrice(newTickerData)
172 | return
173 | },
174 | // 저장했던 가격정보를 반환하며 저장소를 비운다.
175 | getPrice: () => {
176 | const storedPrice: PriceStore = { ...priceStore }
177 | priceStore = {}
178 | return storedPrice
179 | }
180 | }
181 | }
182 |
183 | // 소켓으로 전달받은 데이터를 필요한 가격정보만 추출하여 store에 저장
184 | function transToCoinPrice(newTickerData: SocketTickerData): Partial {
185 | return {
186 | price: newTickerData.trade_price,
187 | signed_change_price: newTickerData.signed_change_price,
188 | signed_change_rate: newTickerData.signed_change_rate,
189 | acc_trade_price_24h: newTickerData.acc_trade_price_24h
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/client/hooks/useRefElementSize.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CHART_X_AXIS_MARGIN,
3 | CHART_Y_AXIS_MARGIN
4 | } from '@/constants/ChartConstants'
5 | import { useState, useEffect, RefObject } from 'react'
6 |
7 | export interface RefElementSize {
8 | width: number
9 | height: number
10 | }
11 |
12 | /**
13 | * DOM 태그의 ref의 width, height를 상태로 return하는 커스텀 훅
14 | * @param ref width와 height를 얻길 원하는 html 태그의 ref
15 | * @returns width와 height의 변화하는 상태값
16 | */
17 | export function useRefElementSize(ref: RefObject) {
18 | const [refElementSize, setRefElementSize] = useState({
19 | width: CHART_Y_AXIS_MARGIN,
20 | height: CHART_X_AXIS_MARGIN
21 | })
22 | useEffect(() => {
23 | function handleResize() {
24 | if (ref.current === null) {
25 | console.error('useWindow 훅의 매개변수에 이상있음')
26 | return
27 | }
28 | setRefElementSize({
29 | width: ref.current.clientWidth,
30 | height: ref.current.clientHeight
31 | })
32 | }
33 | window.addEventListener('resize', handleResize)
34 | handleResize()
35 | return () => window.removeEventListener('resize', handleResize)
36 | }, [ref, ref?.current?.clientWidth, ref?.current?.clientHeight])
37 | return refElementSize
38 | }
39 |
--------------------------------------------------------------------------------
/client/hooks/useURL.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router'
2 | import { useEffect, useState } from 'react'
3 |
4 | export function useURL(startMarket: string): string {
5 | const router = useRouter()
6 | const [market, setMarket] = useState(startMarket)
7 | useEffect(() => {
8 | const market = Array.isArray(router.query.market)
9 | ? router.query.market[0].toUpperCase()
10 | : router.query.market?.toUpperCase()
11 | if (!market) return
12 | setMarket(market)
13 | }, [router.query.market])
14 |
15 | return market
16 | }
17 |
--------------------------------------------------------------------------------
/client/hooks/useUserAgent.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 |
3 | export default function useDeviceDetect() {
4 | const [isMobileDevice, setMobile] = useState(false)
5 |
6 | useEffect(() => {
7 | const userAgent =
8 | typeof window.navigator === 'undefined' ? '' : navigator.userAgent
9 | const mobile = Boolean(
10 | userAgent.match(
11 | /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
12 | )
13 | )
14 | setMobile(mobile)
15 | }, [])
16 |
17 | return { isMobile: isMobileDevice }
18 | }
19 |
--------------------------------------------------------------------------------
/client/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | images: {
5 | remotePatterns: [
6 | {
7 | protocol: 'https',
8 | hostname: 's2.coinmarketcap.com',
9 | pathname: '/static/img/**'
10 | }
11 | ]
12 | },
13 | swcMinify: true,
14 | compiler: {
15 | emotion: true
16 | }
17 | }
18 |
19 | module.exports = nextConfig
20 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format": "npx prettier --write *"
11 | },
12 | "dependencies": {
13 | "@emotion/server": "^11.10.0",
14 | "@mui/icons-material": "^5.10.9",
15 | "@mui/material": "^5.10.13",
16 | "@next/font": "^13.0.3",
17 | "@types/d3": "^7.4.0",
18 | "@types/lodash": "^4.14.191",
19 | "@types/node": "18.11.9",
20 | "@types/react": "18.0.25",
21 | "@types/react-dom": "18.0.8",
22 | "d3": "^7.6.1",
23 | "eslint-config-next": "13.0.2",
24 | "lodash": "^4.17.21",
25 | "next": "13.0.2",
26 | "react": "18.2.0",
27 | "react-dom": "18.2.0"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.20.2",
31 | "@emotion/react": "^11.10.5",
32 | "@emotion/styled": "^11.10.5",
33 | "@typescript-eslint/eslint-plugin": "^5.42.1",
34 | "@typescript-eslint/parser": "^5.42.1",
35 | "babel-loader": "^8.3.0",
36 | "eslint": "^8.27.0",
37 | "eslint-config-prettier": "^8.5.0",
38 | "eslint-config-standard-with-typescript": "^23.0.0",
39 | "eslint-import-resolver-typescript": "^3.5.2",
40 | "eslint-plugin-import": "^2.26.0",
41 | "eslint-plugin-n": "^15.5.1",
42 | "eslint-plugin-prettier": "^4.2.1",
43 | "eslint-plugin-promise": "^6.1.1",
44 | "eslint-plugin-react": "^7.31.10",
45 | "prettier": "^2.7.1",
46 | "typescript": "^4.8.4"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/client/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import LinkButton from '@/components/LinkButton'
2 | import { styled, useMediaQuery, useTheme } from '@mui/material'
3 | import Image from 'next/image'
4 | import dogeImage from './doge.svg'
5 |
6 | const StyledNotFound = styled('div')`
7 | width: 100%;
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: center;
11 | align-items: center;
12 | h1 {
13 | font-size: 3em;
14 | font-weight: bold;
15 | }
16 |
17 | p {
18 | font-size: 1.5em;
19 | color: #666;
20 | }
21 | `
22 |
23 | export default function My404Page() {
24 | const theme = useTheme()
25 | const isMobile = useMediaQuery(theme.breakpoints.down('tablet'))
26 | return (
27 |
28 | 404 - 존재하지 않는 페이지
29 |
35 |
36 | 버튼을 클릭하여 메인 화면으로 돌아가거나, 검색창에서 원하는 코인을
37 | 검색하세요.
38 |
39 |
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/client/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import App, { AppContext, AppProps } from 'next/app'
3 | import { ThemeProvider } from '@mui/material/styles'
4 | import CssBaseline from '@mui/material/CssBaseline'
5 | import { Global, css, CacheProvider, EmotionCache } from '@emotion/react'
6 | import theme from '../style/theme'
7 | import '../public/fonts/style.css'
8 | import createEmotionCache from '../style/createEmotionCache'
9 | import GNB from '@/components/GNB'
10 | import { Container, styled } from '@mui/material'
11 | import { MarketCapInfo } from '@/types/CoinDataTypes'
12 | import { getMarketCapInfo } from '@/utils/metaDataManages'
13 | import { createContext } from 'react'
14 |
15 | // Client-side cache, shared for the whole session of the user in the browser.
16 | const clientSideEmotionCache = createEmotionCache()
17 |
18 | interface MyAppProps extends AppProps {
19 | emotionCache?: EmotionCache
20 | data: MarketCapInfo[]
21 | }
22 |
23 | export const MyAppContext = createContext([])
24 |
25 | export default function MyApp(props: MyAppProps) {
26 | const { Component, emotionCache = clientSideEmotionCache, pageProps } = props
27 | const data = props.data
28 | return (
29 |
30 |
31 | CryptoGraph
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | MyApp.getInitialProps = async (context: AppContext) => {
60 | const ctx = await App.getInitialProps(context)
61 | const fetchedData: MarketCapInfo[] | null = await getMarketCapInfo()
62 | return { ...ctx, data: fetchedData === null ? [] : fetchedData } // pageProps와 합쳐주는 과정
63 | }
64 |
65 | const GlobalStyle = css`
66 | html,
67 | body,
68 | div#__next {
69 | height: 100%;
70 | font-family: 'LINESeedKR-Rg';
71 | * {
72 | ::-webkit-scrollbar {
73 | width: 4px;
74 | position: relative;
75 | }
76 | ::-webkit-scrollbar-track {
77 | display: none;
78 | }
79 | ::-webkit-scrollbar-thumb {
80 | background-color: rgb(199, 199, 199);
81 | }
82 | }
83 | }
84 | `
85 |
86 | const ContainerHeightLimiter = styled('div')`
87 | display: flex;
88 |
89 | width: 100%;
90 | height: 100%;
91 | overflow-y: auto;
92 | ${props => props.theme.breakpoints.down('tablet')} {
93 | padding-top: calc(64px);
94 | }
95 | ${props => props.theme.breakpoints.up('tablet')} {
96 | padding-top: calc(96px);
97 | //매직 넘버 상수화 필요
98 | }
99 | `
100 |
--------------------------------------------------------------------------------
/client/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, {
2 | Html,
3 | Head,
4 | Main,
5 | NextScript,
6 | DocumentContext,
7 | DocumentInitialProps
8 | } from 'next/document'
9 | import createEmotionServer from '@emotion/server/create-instance'
10 | import theme from '../style/theme'
11 | import createEmotionCache from '../style/createEmotionCache'
12 | import { EmotionCache } from '@emotion/react'
13 | import { AppType } from 'next/app'
14 | import { ComponentType, ReactNode } from 'react'
15 |
16 | interface DocumentProps extends DocumentInitialProps {
17 | emotionStyleTags: ReactNode[]
18 | }
19 | export default function MyDocument(props: DocumentProps) {
20 | return (
21 |
22 |
23 | {/* PWA primary color */}
24 |
25 |
26 | {props.emotionStyleTags}
27 |
28 |
29 |
30 |
31 |
35 |
39 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
56 | // `getInitialProps` belongs to `_document` (instead of `_app`),
57 | // it's compatible with static-site generation (SSG).
58 | MyDocument.getInitialProps = async (
59 | ctx: DocumentContext
60 | ): Promise => {
61 | // Resolution order
62 | //
63 | // On the server:
64 | // 1. app.getInitialProps
65 | // 2. page.getInitialProps
66 | // 3. document.getInitialProps
67 | // 4. app.render
68 | // 5. page.render
69 | // 6. document.render
70 | //
71 | // On the server with error:
72 | // 1. document.getInitialProps
73 | // 2. app.render
74 | // 3. page.render
75 | // 4. document.render
76 | //
77 | // On the client
78 | // 1. app.getInitialProps
79 | // 2. page.getInitialProps
80 | // 3. app.render
81 | // 4. page.render
82 |
83 | const originalRenderPage = ctx.renderPage
84 |
85 | // You can consider sharing the same Emotion cache between all the SSR requests to speed up performance.
86 | // However, be aware that it can have global side effects.
87 | const cache = createEmotionCache()
88 | const { extractCriticalToChunks } = createEmotionServer(cache)
89 |
90 | ctx.renderPage = () =>
91 | originalRenderPage({
92 | enhanceApp: (
93 | App: AppType | ComponentType<{ emotionCache: EmotionCache }>
94 | ) =>
95 | function EnhanceApp(props) {
96 | return
97 | }
98 | })
99 | const initialProps = await Document.getInitialProps(ctx)
100 | // This is important. It prevents Emotion to render invalid HTML.
101 | // See https://github.com/mui/material-ui/issues/26561#issuecomment-855286153
102 | const emotionStyles = extractCriticalToChunks(initialProps.html)
103 | const emotionStyleTags = emotionStyles.styles.map(style => (
104 |
110 | ))
111 |
112 | return {
113 | ...initialProps,
114 | emotionStyleTags
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/client/pages/detail/[market].tsx:
--------------------------------------------------------------------------------
1 | import { styled } from '@mui/material/styles'
2 | import { Box, useMediaQuery, useTheme } from '@mui/material'
3 | import TabContainer from '@/components/TabContainer'
4 | import ChartHeader from '@/components/ChartHeader'
5 | import {
6 | DEFAULT_CANDLE_CHART_OPTION,
7 | DEFAULT_CANDLE_PERIOD
8 | } from '@/constants/ChartConstants'
9 | import { CandleChartOption, CandleData } from '@/types/ChartTypes'
10 | import { getCandleDataArray } from '@/utils/upbitManager'
11 | import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
12 | import { CandleChart } from '@/components/Candlechart'
13 | import { useRealTimeUpbitData } from 'hooks/useRealTimeUpbitData'
14 | import { useEffect, useState } from 'react'
15 | import CoinDetailedInfo from '@/components/CoinDetailedInfo'
16 | import RealTimeCoinPrice from '@/components/RealTimeCoinPrice'
17 | import LinkButton from '@/components/LinkButton'
18 | import { getPriceInfo } from '@/utils/apiManager'
19 | import { CoinPriceObj } from '@/types/CoinPriceTypes'
20 | import SwipeableTemporaryDrawer from '@/components/SwiperableDrawer'
21 | import TabBox from '@/components/TabBox'
22 | export default function Detail({
23 | market,
24 | candleData,
25 | priceInfo
26 | }: InferGetServerSidePropsType) {
27 | const theme = useTheme()
28 | const [candleChartOption, setCandleChartOption] = useState(
29 | {
30 | ...DEFAULT_CANDLE_CHART_OPTION,
31 | marketType: market
32 | }
33 | )
34 | const isMobile = useMediaQuery(theme.breakpoints.down('tablet'))
35 | const [isDrawerOpened, setIsDrawerOpened] = useState(false)
36 | const [selectedTab, setSelectedTab] = useState(0)
37 | useEffect(() => {
38 | setCandleChartOption(prev => {
39 | return { ...prev, marketType: market }
40 | })
41 | }, [market])
42 |
43 | const [realtimeCandleData, setRealtimeCandleData, realtimePriceInfo] =
44 | useRealTimeUpbitData(candleChartOption, candleData, priceInfo)
45 | return (
46 |
47 |
48 |
53 |
60 |
65 |
66 |
67 |
68 | {isMobile ? (
69 |
70 |
75 |
79 |
80 |
81 |
88 |
89 |
90 |
91 |
95 |
96 |
97 |
98 | ) : (
99 | <>
100 |
101 |
102 |
103 | >
104 | )}
105 |
106 |
107 | )
108 | }
109 |
110 | interface CandleChartPageProps {
111 | market: string
112 | candleData: CandleData[]
113 | priceInfo: CoinPriceObj
114 | } //페이지 자체의 props interface
115 | export const getServerSideProps: GetServerSideProps<
116 | CandleChartPageProps
117 | > = async context => {
118 | if (context.params === undefined) {
119 | return {
120 | notFound: true
121 | }
122 | }
123 |
124 | const market = Array.isArray(context.params.market)
125 | ? context.params.market[0].toUpperCase()
126 | : context.params.market?.toUpperCase()
127 |
128 | if (!market) {
129 | return {
130 | notFound: true
131 | }
132 | }
133 |
134 | const fetchedCandleData: CandleData[] | null = await getCandleDataArray(
135 | DEFAULT_CANDLE_PERIOD,
136 | market,
137 | 200
138 | )
139 | if (fetchedCandleData === null) {
140 | return {
141 | notFound: true
142 | }
143 | }
144 |
145 | const priceInfo: CoinPriceObj = await getPriceInfo()
146 | if (priceInfo === null) {
147 | return {
148 | notFound: true
149 | }
150 | }
151 | return {
152 | props: {
153 | market: market,
154 | candleData: fetchedCandleData,
155 | priceInfo: priceInfo
156 | } // will be passed to the page component as props
157 | }
158 | }
159 |
160 | const HomeContainer = styled(Box)`
161 | display: flex;
162 | width: 100%;
163 | height: 100%;
164 | max-width: 1920px;
165 | ${props => props.theme.breakpoints.down('tablet')} {
166 | align-items: center;
167 | flex-direction: column;
168 | }
169 | ${props => props.theme.breakpoints.up('tablet')} {
170 | max-height: 1080px;
171 | min-height: 500px;
172 | }
173 | `
174 | //왼쪽 메인차트
175 | const ChartAreaContainer = styled('div')`
176 | display: flex;
177 | box-sizing: content-box;
178 | width: 100%;
179 | height: 100%;
180 | min-width: 350px;
181 | flex-direction: column;
182 | `
183 |
184 | //오른쪽 정보 표시 사이드바
185 | const InfoContainer = styled(Box)`
186 | display: flex;
187 | flex-direction: column;
188 | align-items: center;
189 | width: 400px;
190 | min-width: 400px;
191 | height: 100%;
192 | margin-left: 8px;
193 | ${props => props.theme.breakpoints.down('tablet')} {
194 | height: auto;
195 | margin: 0;
196 | width: 100%; //매직넘버 제거 및 반응형 관련 작업 필요(모바일에서는 100%)
197 | min-width: 0px;
198 | }
199 | `
200 |
--------------------------------------------------------------------------------
/client/pages/doge.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Svg Vector Icons : http://www.onlinewebfonts.com/icon
6 |
7 |
--------------------------------------------------------------------------------
/client/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useContext } from 'react'
2 | import Box from '@mui/material/Box'
3 | import { styled, useTheme } from '@mui/material/styles'
4 | import CoinSelectController from '@/components/CoinSelectController'
5 | import TreeChart from '@/components/Treechart'
6 | import { RunningChart } from '@/components/Runningchart'
7 | import { ChartType } from '@/types/ChartTypes'
8 | import ChartSelectController from '@/components/ChartSelectController'
9 | import SortSelectController from '@/components/SortSelectController'
10 |
11 | import { useMediaQuery } from '@mui/material'
12 | import SwipeableTemporaryDrawer from '@/components/SwiperableDrawer'
13 | import TabContainer from '@/components/TabContainer'
14 | import CoinDetailedInfo from '@/components/CoinDetailedInfo'
15 | import { useRealTimeCoinListData } from '@/hooks/useRealTimeCoinListData'
16 | import { MyAppContext } from './_app'
17 | import MuiModal from '@/components/Modal'
18 | import LinkButton from '@/components/LinkButton'
19 | import TabBox from '@/components/TabBox'
20 | import { NoSelectedCoinAlertView } from '@/components/NoSelectedCoinAlertView'
21 |
22 | export default function Home() {
23 | const data = useContext(MyAppContext)
24 | const [selectedChart, setSelectedChart] = useState('TreeChart')
25 | const [selectedMarketList, setSelectedMarketList] = useState(
26 | data.map(coin => coin.name)
27 | ) //선택된 market 컨트롤
28 | const [selectedMarket, setSelectedMarket] = useState('btc')
29 | const [selectedSort, setSelectedSort] = useState('change rate')
30 | const [selectedTab, setSelectedTab] = useState(0)
31 | const [isDrawerOpened, setIsDrawerOpened] = useState(false)
32 | const [isModalOpened, setIsModalOpened] = useState(false)
33 | const coinData = useRealTimeCoinListData(data)
34 | const theme = useTheme()
35 | const isMobile = useMediaQuery(theme.breakpoints.down('tablet'))
36 | useEffect(() => {
37 | if (selectedChart === 'RunningChart') {
38 | setSelectedSort('descending')
39 | } else {
40 | setSelectedSort('change rate')
41 | }
42 | }, [selectedChart])
43 |
44 | const chartNodeHandler = (market: string) => {
45 | isMobile
46 | ? (() => {
47 | setIsDrawerOpened(true)
48 | setSelectedMarket(market)
49 | setSelectedTab(2) //코인 상세 정보가 3번째 탭에 위치에 있기 때문에 발생하는 매직 넘버
50 | })()
51 | : (() => {
52 | setIsModalOpened(true)
53 | setSelectedMarket(market)
54 | })()
55 | //모달, drawer, 탭컨테이너의 상태를 모두 page에서 관리해야한다.
56 | //전역상태관리 있었으면 좋았을지도?
57 | }
58 | return (
59 |
60 | {isMobile ? (
61 |
62 |
67 |
71 |
72 |
73 |
77 |
82 |
83 |
84 |
85 |
88 |
89 |
90 |
91 |
96 |
97 |
98 |
99 |
100 | ) : (
101 |
102 |
106 |
111 |
112 |
115 |
116 |
120 |
121 |
125 |
126 |
127 | )}
128 |
129 | {selectedMarketList.length !== 0 ? (
130 | <>
131 | {selectedChart === 'RunningChart' ? (
132 |
140 | ) : (
141 |
148 | )}
149 | >
150 | ) : (
151 |
152 | )}
153 |
154 |
155 | )
156 | }
157 |
158 | const HomeContainer = styled('div')`
159 | display: flex;
160 | width: 100%;
161 | max-width: 1920px;
162 | height: 100%;
163 | align-items: center;
164 | ${props => props.theme.breakpoints.down('tablet')} {
165 | flex-direction: column-reverse;
166 | }
167 | `
168 | const SideBarContainer = styled(Box)`
169 | display: flex;
170 | flex-direction: column;
171 | box-sizing: border-box;
172 | background-color: #ffffff;
173 | align-items: center;
174 | min-width: 330px;
175 | max-width: 330px;
176 | height: 100%;
177 | margin-right: 8px;
178 | margin-bottom: 8px;
179 | margin-top: 8px;
180 | ${props => props.theme.breakpoints.down('tablet')} {
181 | width: 100%; //매직넘버 제거 및 반응형 관련 작업 필요(모바일에서는 100%)
182 | height: 100px;
183 | }
184 | `
185 | const ChartContainer = styled(Box)`
186 | display: flex;
187 | background: #ffffff;
188 | box-sizing: content-box; //얘가 차트 크기를 고정해준다. 이유는 아직 모르겠다..
189 | min-width: 300px;
190 | width: 100%;
191 | height: 100%;
192 | flex-direction: column;
193 | `
194 | const LinkButtonStyle = { position: 'absolute', bottom: 0 }
195 |
--------------------------------------------------------------------------------
/client/public/btc.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/client/public/doge.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Svg Vector Icons : http://www.onlinewebfonts.com/icon
6 |
7 |
--------------------------------------------------------------------------------
/client/public/doge_thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/Web35-CryptoGraph/6b4eb05d55774dc51aab4844bfd4b60209f5cac5/client/public/doge_thumbnail.png
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/Web35-CryptoGraph/6b4eb05d55774dc51aab4844bfd4b60209f5cac5/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/fonts/LINESeedKR-Bd.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/Web35-CryptoGraph/6b4eb05d55774dc51aab4844bfd4b60209f5cac5/client/public/fonts/LINESeedKR-Bd.woff
--------------------------------------------------------------------------------
/client/public/fonts/LINESeedKR-Rg.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/Web35-CryptoGraph/6b4eb05d55774dc51aab4844bfd4b60209f5cac5/client/public/fonts/LINESeedKR-Rg.woff
--------------------------------------------------------------------------------
/client/public/fonts/LINESeedKR-Th.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/boostcampwm-2022/Web35-CryptoGraph/6b4eb05d55774dc51aab4844bfd4b60209f5cac5/client/public/fonts/LINESeedKR-Th.woff
--------------------------------------------------------------------------------
/client/public/fonts/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'LINESeedKR-Rg';
3 | src: url('LINESeedKR-Rg.woff') format('opentype');
4 | font-weight: normal;
5 | font-style: normal;
6 | font-display: swap;
7 | }
8 |
--------------------------------------------------------------------------------
/client/public/menu-open.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/public/openBtn-mobile.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/public/openBtn.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/public/userInfo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/client/style/colorScale.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable prettier/prettier */
2 | export const redColorScale = [
3 | '#ffb3b3',
4 | '#ff9999',
5 | '#ff8080',
6 | '#ff4d4d',
7 | '#ff3333',
8 | '#ff1919',
9 | '#ff0000'
10 | ]
11 | export const blueColorScale = [
12 | '#93c3df',
13 | '#77ccff',
14 | '#55aaff',
15 | '#3388ff',
16 | '#0066ff',
17 | '#0044ff',
18 | '#0000ff'
19 | ]
20 |
--------------------------------------------------------------------------------
/client/style/createEmotionCache.ts:
--------------------------------------------------------------------------------
1 | import createCache from '@emotion/cache'
2 |
3 | const isBrowser = typeof document !== 'undefined'
4 |
5 | // On the client side, Create a meta tag at the top of the and set it as insertionPoint.
6 | // This assures that MUI styles are loaded first.
7 | // It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
8 | export default function createEmotionCache() {
9 | let insertionPoint
10 |
11 | if (isBrowser) {
12 | const emotionInsertionPoint = document.querySelector(
13 | 'meta[name="emotion-insertion-point"]'
14 | )
15 | insertionPoint = emotionInsertionPoint ?? undefined
16 | }
17 |
18 | return createCache({ key: 'mui-style', insertionPoint })
19 | }
20 |
--------------------------------------------------------------------------------
/client/style/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material/styles'
2 | import { red } from '@mui/material/colors'
3 |
4 | // Create a theme instance.
5 | const theme = createTheme({
6 | breakpoints: {
7 | values: {
8 | mobile: 0,
9 | tablet: 750,
10 | laptop: 1024,
11 | desktop: 1280,
12 | max: 1920
13 | }
14 | },
15 | palette: {
16 | primary: {
17 | main: '#6750a4'
18 | },
19 | secondary: {
20 | main: '#19857b'
21 | },
22 | error: {
23 | main: red.A400
24 | },
25 | background: {
26 | default: '#eee6e6'
27 | },
28 | custom: {
29 | red: '#D24F45',
30 | blue: '#1D61C4'
31 | }
32 | },
33 | typography: {
34 | fontFamily: 'LINESeedKR-Rg',
35 | button: {
36 | textTransform: 'none'
37 | }
38 | }
39 | })
40 |
41 | export default theme
42 |
--------------------------------------------------------------------------------
/client/theme.d.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Theme as OriginalTheme,
3 | ThemeOptions as OriginalThemeOption
4 | } from '@mui/material/styles'
5 |
6 | declare module '@mui/material/styles' {
7 | interface Palette {
8 | custom: CustomPaletteColor
9 | }
10 | interface PaletteOptions {
11 | custom: CustomPaletteColorOptions
12 | }
13 | interface CustomPaletteColorOptions {
14 | red?: string
15 | blue?: string
16 | }
17 | interface CustomPaletteColor {
18 | red?: string
19 | blue?: string
20 | }
21 | interface BreakpointOverrides {
22 | xs: false // removes the `xs` breakpoint
23 | sm: false
24 | md: false
25 | lg: false
26 | xl: false
27 | mobile: true
28 | tablet: true // adds the `tablet` breakpoint
29 | laptop: true
30 | desktop: true
31 | max: true
32 | }
33 | export type Theme = OriginalTheme & {
34 | breakpoints: {
35 | values: {
36 | mobile: number
37 | tablet: number
38 | laptop: number
39 | desktop: number
40 | max: number
41 | }
42 | }
43 | palette: {
44 | primary: {
45 | main: string
46 | }
47 | secondary: {
48 | main: string
49 | }
50 | error: {
51 | main: string
52 | }
53 | custom: {
54 | red: string
55 | blue: string
56 | }
57 | }
58 | typography: {
59 | fontFamily: string
60 | button: {
61 | textTransform: string
62 | }
63 | }
64 | }
65 | // allow configuration using `createTheme`
66 | export type ThemeOptions = OriginalThemeOption & {
67 | breakpoints?: {
68 | values?: {
69 | mobile?: number
70 | tablet?: number
71 | laptop?: number
72 | desktop?: number
73 | max?: number
74 | }
75 | }
76 | palette?: {
77 | primary?: {
78 | main?: string
79 | }
80 | secondary?: {
81 | main?: string
82 | }
83 | error?: {
84 | main?: string
85 | }
86 | custom?: {
87 | red?: string
88 | blue?: string
89 | }
90 | }
91 | typography?: {
92 | fontFamily?: string
93 | button?: {
94 | textTransform?: string
95 | }
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/pages/*": ["pages/*"],
20 | "@/styles/*": ["style/*"],
21 | "@/constants/*": ["constants/*"],
22 | "@/types/*": ["types/*"],
23 | "@/utils/*": ["utils/*"],
24 | "@/hooks/*": ["hooks/*"],
25 | "@/components/*": ["components/*"]
26 | }
27 | },
28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
29 | "exclude": ["node_modules"]
30 | }
31 |
--------------------------------------------------------------------------------
/client/types/ChartTypes.ts:
--------------------------------------------------------------------------------
1 | export interface CandleData {
2 | code: string
3 | market: string
4 | trade_date: string
5 | trade_time: string
6 | trade_date_kst: string
7 | opening_price: number
8 | high_price: number
9 | low_price: number
10 | trade_price: number
11 | prev_closing_price: number
12 | change: string
13 | change_rate: number
14 | change_price: number
15 | trade_volume: number
16 | candle_date_time_kst: string
17 | candle_date_time_utc: string
18 | timestamp: number
19 | candle_acc_trade_price: number
20 | trade_timestamp: number
21 | }
22 | export const ChartTypeArr = ['TreeChart', 'RunningChart'] as const
23 | export type ChartType = typeof ChartTypeArr[number]
24 | export type ChartPeriodItered = {
25 | [K in ChartPeriod]: T
26 | }
27 | export type ChartPeriod =
28 | | 'minutes/1'
29 | | 'minutes/3'
30 | | 'minutes/5'
31 | | 'minutes/60'
32 | | 'minutes/240'
33 | | 'days'
34 | | 'weeks'
35 | export const DatePeriod: ChartPeriodItered = {
36 | 'minutes/1': 60,
37 | 'minutes/3': 180,
38 | 'minutes/5': 300,
39 | 'minutes/60': 3600,
40 | 'minutes/240': 14400,
41 | days: 86400,
42 | weeks: 604800
43 | }
44 |
45 | export const ChartPeriodList = Object.keys(DatePeriod)
46 |
47 | export interface CandleChartOption {
48 | marketType: string
49 | candlePeriod: ChartPeriod
50 | isVolumeVisible: boolean
51 | isMovingAverageVisible: boolean
52 | }
53 |
54 | export interface CandleChartRenderOption {
55 | renderStartDataIndex: number
56 | renderCandleCount: number
57 | minCandleWidth: number
58 | maxCandleWidth: number
59 | candleWidth: number
60 | maxRenderStartDataIndex: number
61 | maxDataLength: number
62 | }
63 |
64 | //treeChart
65 | export interface TreeMapData {
66 | acc_trade_price: number
67 | acc_trade_price_24h: number
68 | acc_trade_volume: number
69 | acc_trade_volume_24h: number
70 | change: string
71 | change_price: number
72 | change_rate: number
73 | high_price: number
74 | highest_52_week_date: string
75 | highest_52_week_price: number
76 | low_price: number
77 | lowest_52_week_date: string
78 | lowest_52_week_price: number
79 | market: string
80 | opening_price: number
81 | prev_closing_price: number
82 | signed_change_price: number
83 | signed_change_rate: number
84 | timestamp: number
85 | trade_date: string
86 | trade_date_kst: string
87 | trade_price: number
88 | trade_time: string
89 | trade_time_kst: string
90 | trade_timestamp: number
91 | trade_volume: number
92 | }
93 |
94 | export interface CoinRateType {
95 | [key: string]: CoinRateContentType
96 | }
97 |
98 | export interface CoinRateContentType {
99 | name: string
100 | ticker: string
101 | parent: string
102 | acc_trade_price_24h: number
103 | market_cap: number
104 | cmc_rank?: number
105 | value: number
106 | }
107 |
108 | export interface PointerData {
109 | positionX: number
110 | positionY: number
111 | data: CandleData | null
112 | }
113 |
114 | export interface MainChartPointerData {
115 | offsetX: number
116 | offsetY: number
117 | data: CoinRateContentType | null
118 | }
119 |
--------------------------------------------------------------------------------
/client/types/CoinDataTypes.ts:
--------------------------------------------------------------------------------
1 | export interface CoinMetaData {
2 | id: number
3 | symbol: string
4 | name: string
5 | name_kr: string
6 | slug: string
7 | market_cap_dominance: number
8 | market_cap: number
9 | market_cap_kr: string
10 | max_supply: number
11 | circulating_supply: number
12 | total_supply: number
13 | cmc_rank: number
14 | time: string
15 | website: string
16 | logo: string
17 | description: number
18 | volume_24h: string
19 | }
20 |
21 | export interface MarketCapInfo {
22 | name: string
23 | name_es: string
24 | cmc_rank: string
25 | name_kr: string
26 | logo: string
27 | market_cap: number
28 | acc_trade_price_24h: number
29 | signed_change_rate: number
30 | }
31 |
--------------------------------------------------------------------------------
/client/types/CoinPriceTypes.ts:
--------------------------------------------------------------------------------
1 | export interface CoinPrice {
2 | logo: string
3 | name_kr: string
4 | name: string
5 | price: number
6 | signed_change_price: number
7 | signed_change_rate: number
8 | acc_trade_price_24h: number
9 | }
10 |
11 | export interface CoinPriceObj {
12 | [key: string]: CoinPrice
13 | }
14 |
15 | export interface SocketTickerData {
16 | type: string
17 | code: string
18 | opening_price: number
19 | high_price: number
20 | low_price: number
21 | trade_price: number
22 | prev_closing_price: number
23 | signed_change_price: number
24 | signed_change_rate: number
25 | trade_volume: number
26 | acc_trade_volume: number
27 | acc_trade_volume_24h: number
28 | acc_trade_price: number
29 | acc_trade_price_24h: number
30 | trade_timestamp: number
31 | acc_ask_volume: number
32 | acc_bid_volume: number
33 | highest_52_week_price: number
34 | highest_52_week_date: string
35 | lowest_52_week_price: number
36 | lowest_52_week_date: string
37 | timestamp: number
38 | }
39 |
--------------------------------------------------------------------------------
/client/utils/apiManager.ts:
--------------------------------------------------------------------------------
1 | export const getPriceInfo = async () => {
2 | const res = await fetch(
3 | `${process.env.NEXT_PUBLIC_SERVER_URL}/market-price-info`
4 | )
5 | if (res.status !== 200) {
6 | return null
7 | }
8 | const priceInfo = await res.json()
9 | return priceInfo
10 | }
11 |
--------------------------------------------------------------------------------
/client/utils/chartManager.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CANDLE_COLOR_RED,
3 | CANDLE_COLOR_BLUE,
4 | CANDLE_CHART_POINTER_LINE_COLOR,
5 | CHART_FONT_SIZE,
6 | CHART_Y_AXIS_MARGIN,
7 | DEFAULT_CANDLE_COUNT,
8 | DEFAULT_RENDER_START_INDEX,
9 | CHART_X_AXIS_MARGIN,
10 | CHART_AXIS_RECT_WIDTH,
11 | CHART_AXIS_RECT_HEIGHT,
12 | DEFAULT_MAX_CANDLE_COUNT,
13 | DEFAULT_MAX_RENDER_START_INDEX
14 | } from '@/constants/ChartConstants'
15 | import { DatePeriod } from '@/types/ChartTypes'
16 | import { RefElementSize } from 'hooks/useRefElementSize'
17 | import {
18 | CandleChartRenderOption,
19 | CandleData,
20 | PointerData,
21 | ChartPeriod,
22 | MainChartPointerData
23 | } from '@/types/ChartTypes'
24 | import * as d3 from 'd3'
25 | import { makeDate } from './dateManager'
26 | import { blueColorScale, redColorScale } from '@/styles/colorScale'
27 | import { CoinRateContentType } from '@/types/ChartTypes'
28 | import { Dispatch, SetStateAction } from 'react'
29 | import { transDate } from '@/utils/dateManager'
30 |
31 | export function getVolumeHeightScale(
32 | data: CandleData[],
33 | CHART_AREA_Y_SIZE: number
34 | ) {
35 | const [min, max] = [
36 | d3.min(data, d => d.candle_acc_trade_price),
37 | d3.max(data, d => d.candle_acc_trade_price)
38 | ]
39 | if (!min || !max) {
40 | console.error(data, data.length)
41 | console.error('데이터에 문제가 있다. 서버에서 잘못 쏨')
42 | return undefined
43 | }
44 | return d3.scaleLinear().domain([min, max]).range([CHART_AREA_Y_SIZE, 30])
45 | }
46 |
47 | export function getYAxisScale(data: CandleData[], CHART_AREA_Y_SIZE: number) {
48 | const [min, max] = [
49 | d3.min(data, d => d.low_price),
50 | d3.max(data, d => d.high_price)
51 | ]
52 | if (!min || !max) {
53 | console.error(data, data.length)
54 | console.error('데이터에 문제가 있다. 서버에서 잘못 쏨')
55 | return undefined
56 | }
57 | const diff = max - min
58 | return d3
59 | .scaleLinear()
60 | .domain([min - 0.03 * diff, max + 0.03 * diff])
61 | .range([CHART_AREA_Y_SIZE, 0])
62 | }
63 |
64 | // scale함수 updateChart에서만 호출
65 | export function getXAxisScale(
66 | option: CandleChartRenderOption,
67 | data: CandleData[],
68 | chartAreaXsize: number,
69 | candlePeriod: ChartPeriod
70 | ) {
71 | return d3
72 | .scaleTime()
73 | .domain([
74 | makeDate(
75 | data[option.renderStartDataIndex].timestamp -
76 | DatePeriod[candlePeriod] * (option.renderCandleCount + 1) * 1000,
77 | candlePeriod
78 | ),
79 | makeDate(data[option.renderStartDataIndex].timestamp, candlePeriod)
80 | ])
81 | .range([
82 | chartAreaXsize - (option.renderCandleCount + 1) * option.candleWidth,
83 | chartAreaXsize
84 | ])
85 | }
86 |
87 | export function updateCurrentPrice(
88 | yAxisScale: d3.ScaleLinear,
89 | data: CandleData[],
90 | renderOpt: CandleChartRenderOption,
91 | chartAreaXsize: number,
92 | chartAreaYSize: number
93 | ) {
94 | const $currentPrice = d3.select('svg#current-price')
95 | const yCoord = yAxisScale(data[0].trade_price)
96 | const strokeColor =
97 | data[0].opening_price < data[0].trade_price
98 | ? CANDLE_COLOR_RED
99 | : CANDLE_COLOR_BLUE
100 | $currentPrice
101 | .select('line')
102 | .attr('x1', chartAreaXsize)
103 | .attr('x2', 0)
104 | .attr('y1', yCoord)
105 | .attr('y2', yCoord)
106 | .attr('stroke', strokeColor)
107 | .attr('stroke-width', 2)
108 | .attr('stroke-dasharray', '10,10')
109 | $currentPrice
110 | .select('rect')
111 | .attr('fill', strokeColor)
112 | .attr('width', CHART_AXIS_RECT_WIDTH)
113 | .attr('height', CHART_AXIS_RECT_HEIGHT)
114 | .attr('x', chartAreaXsize)
115 | .attr(
116 | 'y',
117 | Math.min(
118 | Math.max(0, yCoord - CHART_AXIS_RECT_HEIGHT / 2),
119 | chartAreaYSize - CHART_AXIS_RECT_HEIGHT
120 | )
121 | )
122 | $currentPrice
123 | .select('text')
124 | .attr('fill', 'white')
125 | .attr('font-size', CHART_FONT_SIZE)
126 | .attr(
127 | 'transform',
128 | getTextTransform(yCoord, 1, chartAreaXsize, chartAreaYSize)
129 | )
130 | .attr('font-weight', '600')
131 | .attr('text-anchor', 'middle')
132 | .attr('dominant-baseline', 'middle')
133 | .text(data[0].trade_price.toLocaleString())
134 | }
135 |
136 | // svg#mouse-pointer-UI자식요소에 격자구선 선 join
137 | export function updatePointerUI(
138 | pointerInfo: PointerData,
139 | renderOpt: CandleChartRenderOption,
140 | data: CandleData[],
141 | refElementSize: RefElementSize,
142 | period: ChartPeriod
143 | ) {
144 | const [chartAreaXsize, chartAreaYsize] = [
145 | refElementSize.width - CHART_Y_AXIS_MARGIN,
146 | refElementSize.height - CHART_X_AXIS_MARGIN
147 | ]
148 | const { priceText, color } = getPriceInfo(pointerInfo)
149 | const yAxisScale = getYAxisScale(
150 | data.slice(
151 | renderOpt.renderStartDataIndex,
152 | renderOpt.renderStartDataIndex + renderOpt.renderCandleCount
153 | ),
154 | chartAreaYsize
155 | )
156 | const xAxisScale = getXAxisScale(renderOpt, data, chartAreaXsize, period)
157 | if (!yAxisScale || !xAxisScale) {
158 | return
159 | }
160 | d3.select('text#price-info')
161 | .attr('fill', color ? color : 'black')
162 | .attr('font-size', CHART_FONT_SIZE)
163 | .text(priceText)
164 | d3.select('svg#mouse-pointer-UI')
165 | .selectAll('g')
166 | .data(transPointerInfoToArray(pointerInfo))
167 | .join(
168 | function (enter) {
169 | const $g = enter.append('g')
170 | $g.append('path')
171 | .attr('d', (d, i) =>
172 | getPathDAttr(d, i, chartAreaXsize, chartAreaYsize)
173 | )
174 | .attr('stroke', CANDLE_CHART_POINTER_LINE_COLOR)
175 | $g.append('rect')
176 | .attr('width', CHART_AXIS_RECT_WIDTH)
177 | .attr('height', CHART_AXIS_RECT_HEIGHT)
178 | .attr('fill', CANDLE_CHART_POINTER_LINE_COLOR)
179 | .attr('transform', (d, i) =>
180 | getRectTransform(d, i, chartAreaXsize, chartAreaYsize)
181 | )
182 |
183 | $g.append('text')
184 | .attr('fill', 'black')
185 | .attr('font-size', CHART_FONT_SIZE)
186 | .attr('transform', (d, i) =>
187 | getTextTransform(d, i, chartAreaXsize, chartAreaYsize)
188 | )
189 | .attr('font-weight', '600')
190 | .text((d, i) => {
191 | if (i === 0) {
192 | return getTimeText(pointerInfo.data)
193 | }
194 | return Math.round(yAxisScale.invert(d)).toLocaleString()
195 | })
196 | .attr('text-anchor', 'middle')
197 | .attr('dominant-baseline', (d, i) => (i === 0 ? 'hanging' : 'middle'))
198 | return $g
199 | },
200 | function (update) {
201 | update
202 | .select('path')
203 | .attr('d', (d, i) =>
204 | getPathDAttr(d, i, chartAreaXsize, chartAreaYsize)
205 | )
206 | update
207 | .select('rect')
208 | .attr('width', CHART_AXIS_RECT_WIDTH)
209 | .attr('height', CHART_AXIS_RECT_HEIGHT)
210 | .attr('transform', (d, i) =>
211 | getRectTransform(d, i, chartAreaXsize, chartAreaYsize)
212 | )
213 | update
214 | .select('text')
215 | .attr('transform', (d, i) =>
216 | getTextTransform(d, i, chartAreaXsize, chartAreaYsize)
217 | )
218 | .text((d, i) => {
219 | if (i === 0) {
220 | const unitData = pointerInfo.data
221 | if (!unitData || !unitData.candle_date_time_kst) {
222 | return getTimeTextByPosX(
223 | pointerInfo.positionX,
224 | xAxisScale,
225 | period
226 | )
227 | }
228 | return getTimeText(pointerInfo.data)
229 | }
230 | return Math.round(yAxisScale.invert(d)).toLocaleString()
231 | })
232 | return update
233 | },
234 | function (exit) {
235 | return exit.remove()
236 | }
237 | )
238 | }
239 |
240 | // text위치 정보 반환
241 | function getTextTransform(
242 | pointerXY: number,
243 | i: number,
244 | chartAreaXSize: number,
245 | chartAreaYSize: number
246 | ) {
247 | if (i === 0) {
248 | let textX = pointerXY - CHART_AXIS_RECT_WIDTH / 2
249 | textX = Math.max(Math.min(textX, chartAreaXSize - CHART_AXIS_RECT_WIDTH), 0)
250 | return `translate(${textX + CHART_AXIS_RECT_WIDTH / 2},${
251 | chartAreaYSize + 3
252 | })`
253 | }
254 | let textY = pointerXY - CHART_AXIS_RECT_HEIGHT / 2
255 | textY = Math.max(Math.min(chartAreaYSize - CHART_AXIS_RECT_HEIGHT, textY), 0)
256 | return `translate(${chartAreaXSize + CHART_AXIS_RECT_WIDTH / 2},${
257 | textY + CHART_AXIS_RECT_HEIGHT / 2
258 | })`
259 | }
260 |
261 | // rect위치정보 반환
262 | function getRectTransform(
263 | pointerXY: number,
264 | i: number,
265 | chartAreaXSize: number,
266 | chartAreaYSize: number
267 | ) {
268 | if (i === 0) {
269 | let rectX = pointerXY - CHART_AXIS_RECT_WIDTH / 2
270 | rectX = Math.max(Math.min(rectX, chartAreaXSize - CHART_AXIS_RECT_WIDTH), 0)
271 | return `translate(${rectX},${chartAreaYSize})`
272 | }
273 | let rectY = pointerXY - CHART_AXIS_RECT_HEIGHT / 2
274 | rectY = Math.max(Math.min(chartAreaYSize - CHART_AXIS_RECT_HEIGHT, rectY), 0)
275 | return `translate(${chartAreaXSize},${rectY})`
276 | }
277 |
278 | // 시간정보 텍스트 반환
279 | function getTimeText(unitData: CandleData | null) {
280 | if (!unitData || !unitData.candle_date_time_kst) {
281 | return ''
282 | }
283 | const timeString = unitData.candle_date_time_kst
284 | return `${timeString.substring(5, 10)} ${timeString.substring(11, 16)}`
285 | }
286 |
287 | function getTimeTextByPosX(
288 | posX: number,
289 | xAxisScale: d3.ScaleTime,
290 | period: ChartPeriod
291 | ) {
292 | const dateString = transDate(xAxisScale.invert(posX).getTime(), period)
293 | return `${dateString.substring(5, 10)} ${dateString.substring(11, 16)}`
294 | }
295 |
296 | // 마우스 포인터가 가리키는 위치의 분봉데이터를 찾아 렌더링될 가격정보를 반환
297 | function getPriceInfo(pointerInfo: PointerData) {
298 | if (pointerInfo.positionX < 0) {
299 | return { priceText: '' }
300 | }
301 | const candleUnitData = pointerInfo.data
302 | if (
303 | !candleUnitData ||
304 | !candleUnitData.high_price ||
305 | !candleUnitData.low_price ||
306 | !candleUnitData.opening_price ||
307 | !candleUnitData.trade_price
308 | ) {
309 | return { priceText: '' }
310 | }
311 | return {
312 | priceText: [
313 | `고가: ${candleUnitData.high_price}`,
314 | `저가: ${candleUnitData.low_price}`,
315 | `시가: ${candleUnitData.opening_price}`,
316 | `종가: ${candleUnitData.trade_price}`
317 | ].join(' '),
318 | color:
319 | candleUnitData.opening_price < candleUnitData.trade_price
320 | ? CANDLE_COLOR_RED
321 | : CANDLE_COLOR_BLUE
322 | }
323 | }
324 |
325 | // 격자를 생성할 path요소의 내용 index가 0이라면 세로선 1이라면 가로선
326 | function getPathDAttr(
327 | d: number,
328 | i: number,
329 | chartAreaXsize: number,
330 | chartAreaYsize: number
331 | ) {
332 | return i === 0
333 | ? `M${d} 0 L${d} ${chartAreaYsize}`
334 | : `M0 ${d} L${chartAreaXsize} ${d}`
335 | }
336 |
337 | function transPointerInfoToArray(pointerInfo: PointerData) {
338 | return [pointerInfo.positionX, pointerInfo.positionY].filter(el => el !== -1)
339 | }
340 |
341 | // 마우스 이벤트 핸들러 포인터의 위치를 파악하고 pointerPosition을 갱신한다.
342 | export function handleMouseEvent(
343 | event: MouseEvent,
344 | pointerPositionSetter: Dispatch>,
345 | chartAreaXsize: number,
346 | chartAreaYsize: number
347 | ) {
348 | const $rect = d3.select(event.target as SVGRectElement)
349 | const data = $rect.data().length > 0 ? $rect.data()[0] : null
350 | if (
351 | event.offsetX > 0 &&
352 | event.offsetY > 0 &&
353 | event.offsetX < chartAreaXsize &&
354 | event.offsetY < chartAreaYsize
355 | ) {
356 | pointerPositionSetter({
357 | positionX: event.offsetX,
358 | positionY: event.offsetY,
359 | data: data as CandleData
360 | })
361 | return
362 | }
363 | pointerPositionSetter({ positionX: -1, positionY: -1, data: null })
364 | }
365 |
366 | export function checkNeedFetch(
367 | candleData: CandleData[],
368 | option: CandleChartRenderOption
369 | ) {
370 | return (
371 | candleData.length <
372 | option.renderStartDataIndex + option.renderCandleCount * 2
373 | )
374 | }
375 |
376 | export function getInitRenderOption(width: number): CandleChartRenderOption {
377 | const candleWidth = Math.ceil(width / DEFAULT_CANDLE_COUNT)
378 | return {
379 | candleWidth,
380 | minCandleWidth: Math.max(5, Math.ceil(width / 200)),
381 | maxCandleWidth: Math.max(5, Math.ceil(width / 10)),
382 | renderStartDataIndex: DEFAULT_RENDER_START_INDEX,
383 | renderCandleCount: DEFAULT_CANDLE_COUNT,
384 | maxDataLength: DEFAULT_MAX_CANDLE_COUNT,
385 | maxRenderStartDataIndex: DEFAULT_MAX_RENDER_START_INDEX
386 | }
387 | }
388 |
389 | export function getRenderOptionByWindow(
390 | width: number,
391 | prev: CandleChartRenderOption
392 | ): CandleChartRenderOption {
393 | const renderCandleCount = Math.ceil(width / prev.candleWidth)
394 | const minCandleWidth = Math.max(5, Math.ceil(width / 200))
395 | const maxCandleWidth = Math.max(5, Math.ceil(width / 10))
396 | return {
397 | ...prev,
398 | renderCandleCount,
399 | minCandleWidth,
400 | maxCandleWidth
401 | }
402 | }
403 |
404 | export const colorQuantizeScale = (max: number, value: number) => {
405 | return value > 0
406 | ? d3.scaleQuantize().domain([0, max]).range(redColorScale)(value)
407 | : d3.scaleQuantize().domain([0, max]).range(blueColorScale)(
408 | Math.abs(value)
409 | )
410 | }
411 |
412 | export const convertUnit = (unit: number) => {
413 | if (unit >= 1000000000000) {
414 | return (unit / 1000000000000).toFixed(2) + '조'
415 | }
416 | return (unit / 100000000).toFixed(0) + '억'
417 | }
418 | export function MainChartHandleMouseEvent(
419 | event: MouseEvent,
420 | pointerInfoSetter: Dispatch>,
421 | data: CoinRateContentType,
422 | width: number,
423 | height: number
424 | ) {
425 | if (event.type === 'mousemove') {
426 | pointerInfoSetter({
427 | offsetX:
428 | (width * 2) / 3 > event.offsetX + 50
429 | ? event.offsetX + 50
430 | : event.offsetX - 200,
431 | offsetY:
432 | height - 50 > event.clientY ? event.clientY - 100 : event.clientY - 270,
433 | data: data
434 | })
435 | } else {
436 | pointerInfoSetter({
437 | offsetX: -1,
438 | offsetY: -1,
439 | data: null
440 | })
441 | }
442 | return
443 | }
444 |
--------------------------------------------------------------------------------
/client/utils/dateManager.ts:
--------------------------------------------------------------------------------
1 | import { ChartPeriod, DatePeriod } from '@/types/ChartTypes'
2 |
3 | export function transDate(timestamp: number, period: ChartPeriod): string {
4 | const date = makeDate(timestamp, period)
5 | date.setHours(date.getHours() + 9)
6 | return date.toISOString().substring(0, 19)
7 | }
8 |
9 | export function makeDate(timestamp: number, period: ChartPeriod): Date {
10 | const date = new Date(timestamp - (timestamp % (DatePeriod[period] * 1000)))
11 | if (period === 'weeks') {
12 | const originalDate = new Date(timestamp)
13 | if (originalDate.getUTCDay() < 4) {
14 | date.setHours(105)
15 | } else {
16 | date.setHours(-63)
17 | }
18 | }
19 | return date
20 | }
21 |
--------------------------------------------------------------------------------
/client/utils/inputBarManager.ts:
--------------------------------------------------------------------------------
1 | import { MarketCapInfo } from '@/types/CoinDataTypes'
2 |
3 | export function validateInputName(
4 | coinNames: MarketCapInfo[],
5 | inputName: string
6 | ): boolean {
7 | const autoCompleteNamesArray = [
8 | ...coinNames.map(coin => coin.name),
9 | ...coinNames.map(coin => coin.name_kr)
10 | ]
11 | if (autoCompleteNamesArray.includes(inputName)) return true
12 | return false
13 | }
14 |
15 | export function matchNameKRwithENG( //영어이니셜과 한글이름 아무거나 들어와도 모두 영어 이니셜로 필터링
16 | coinNames: MarketCapInfo[],
17 | inputName: string
18 | ): string {
19 | const coinNameObject: MarketCapInfo = coinNames.filter(
20 | coin => coin.name === inputName || coin.name_kr === inputName
21 | )[0]
22 | return coinNameObject.name
23 | }
24 |
--------------------------------------------------------------------------------
/client/utils/metaDataManages.ts:
--------------------------------------------------------------------------------
1 | import { CoinMetaData, MarketCapInfo } from '@/types/CoinDataTypes'
2 |
3 | export async function getCoinMetaData(
4 | coinCode: string
5 | ): Promise {
6 | const response = await fetch(
7 | `${process.env.NEXT_PUBLIC_SERVER_URL}/coin-info/${coinCode}`,
8 | {
9 | method: 'GET'
10 | }
11 | )
12 | if (response.status !== 200) {
13 | return null
14 | }
15 | const data: CoinMetaData = await response.json()
16 | return data
17 | }
18 |
19 | export async function getMarketCapInfo(): Promise {
20 | const res = await fetch(
21 | `${process.env.NEXT_PUBLIC_SERVER_URL}/market-cap-info`
22 | )
23 | if (res.status !== 200) {
24 | return null
25 | }
26 | const data: MarketCapInfo[] = await res.json()
27 | return data
28 | }
29 |
--------------------------------------------------------------------------------
/client/utils/upbitManager.ts:
--------------------------------------------------------------------------------
1 | import { CandleData, ChartPeriod, TreeMapData } from '@/types/ChartTypes'
2 | import { DEFAULT_CANDLE_COUNT } from '@/constants/ChartConstants'
3 | export async function getCandleDataArray(
4 | period: ChartPeriod,
5 | market: string,
6 | count = DEFAULT_CANDLE_COUNT,
7 | lastTime?: string
8 | ): Promise {
9 | let res
10 | if (!lastTime) {
11 | res = await fetch(
12 | `https://api.upbit.com/v1/candles/${period}?market=KRW-${market}&count=${count}`,
13 | {
14 | method: 'GET',
15 | headers: { accept: 'application/json' }
16 | }
17 | )
18 | } else {
19 | res = await fetch(
20 | `https://api.upbit.com/v1/candles/${period}?market=KRW-${market}&to=${
21 | lastTime + 'Z'
22 | }&count=${count}`,
23 | {
24 | method: 'GET',
25 | headers: { accept: 'application/json' }
26 | }
27 | )
28 | }
29 | if (res.status === 404) {
30 | return null
31 | }
32 | const data: CandleData[] = await res.json()
33 | return data
34 | }
35 |
36 | export async function getTreeMapDataArray(
37 | market: string
38 | ): Promise {
39 | const res = await fetch(
40 | //market -> markets
41 | `https://api.upbit.com/v1/ticker?markets=${market}&count=1`,
42 | {
43 | method: 'GET',
44 | headers: { accept: 'application/json' }
45 | }
46 | )
47 | const data: TreeMapData[] = await res.json()
48 | return data
49 | }
50 |
--------------------------------------------------------------------------------