├── .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 | ![logo primary](https://user-images.githubusercontent.com/60903175/205961174-e6bd9e88-2d0f-4b51-9eeb-d7db05af9885.svg) 7 | 8 | ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)![Express](https://img.shields.io/badge/Express.js-3982CE?style=for-the-badge&logo=Express&logoColor=white)![Next JS](https://img.shields.io/badge/Next.js-black?style=for-the-badge&logo=next.js&logoColor=white)![D3](https://img.shields.io/badge/-d3.js-brightgreen?style=for-the-badge&logo=d3.js&logoColor=white)![Emotion](https://img.shields.io/badge/-emotion-brightgreen?style=for-the-badge&logo=emotion&logoColor=white)![MUI](https://img.shields.io/badge/-MaterialUI-blue?style=for-the-badge&logo=mui&logoColor=white) 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 | 27 | 28 | 29 | 42 | 55 | 68 | 69 | 80 | 81 | 82 | 83 |

30 | 31 | ### J006 강재민 32 | 33 | 34 | 35 | ### 뱃살과 실력이 비례하고 싶은 프론트엔드 개발자 36 | 37 | [이력서 바로가기](https://www.notion.so/010dc5a42f8b4c08a2f2592682eba48c) 38 | 39 | [깃헙 바로가기 💻](http://github.com/rkdwoals159) 40 | 41 |
43 | 44 | ### J013 공윤배 45 | 46 | 47 | 48 | ### 항상 즐겁게 개발하는 개발자 49 | 50 | [이력서 바로가기](https://kongyb.notion.site/d9ecf75afb6245b9b558e3e7db6dc1dd)
51 | 52 | [깃헙 바로가기 💻](https://github.com/kongyb) 53 | 54 |

56 | 57 | ### J038 김상훈 58 | 59 | 김상훈 60 | 61 | ### 불편함을 불편해할 줄 아는 프론트엔드 개발자 62 | 63 | [이력서 바로가기](https://www.notion.so/009309ae05974be68be9ad7beded7285) 64 | 65 | [깃헙 바로가기 💻](https://github.com/baldwinIV) 66 | 67 |

70 | 71 | ### J054 김준태 72 | 73 | 김준태 74 | 75 | ### 같이의 가치를 아는 프론트엔드 개발자 76 | 77 | [이력서 바로가기](https://www.notion.so/438ec182c25847df84ef53186a387fde) 78 | 79 | [깃헙 바로가기 💻](https://github.com/sronger)
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 | 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 | 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 | /logo-only-white.svg 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 | 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 | /logo-only-white.svg 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 |
122 |
123 |
코인명
124 |
{ 127 | sortHandler('price') 128 | }} 129 | > 130 | 현재가 131 |
132 |
{ 135 | sortHandler('signed_change_rate') 136 | }} 137 | > 138 | 전일대비 139 |
140 |
{ 143 | sortHandler('acc_trade_price_24h') 144 | }} 145 | > 146 | 거래대금 147 |
148 |
149 |
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 | 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 | 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 | /logo-only-white.svg 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 |