From f6adf0a3e0a02eb4dc5a552f986c71a093a68750 Mon Sep 17 00:00:00 2001 From: mindol1004 Date: Thu, 10 Feb 2022 13:07:45 +0900 Subject: [PATCH] commit --- LICENSE | 21 + README.md | 455 + package.json | 71 + public/blueLogo.png | Bin 0 -> 14562 bytes public/favicon.png | Bin 0 -> 571 bytes public/index.html | 20 + public/robots.txt | 3 + public/whiteLogo.png | Bin 0 -> 11276 bytes src/Api/api.js | 60 + src/App.js | 15 + src/Components/Global/Footer.js | 132 + src/Components/Global/Header.js | 64 + src/Components/Global/Loading.js | 34 + src/Components/Main/ChartDataConsole.js | 119 + src/Components/Main/CoinInfoHeader.js | 213 + src/Components/Main/CoinList.js | 210 + src/Components/Main/CoinListItem.js | 206 + src/Components/Main/MainChart-d3fc.js | 78 + src/Components/Main/MainChart-old.js | 180 + src/Components/Main/MainChart.js | 285 + src/Components/Main/OrderInfo.js | 110 + src/Components/Main/OrderInfoAskBid.js | 255 + src/Components/Main/OrderInfoTradeList.js | 21 + src/Components/Main/Orderbook.js | 124 + src/Components/Main/OrderbookCoinInfo.js | 64 + src/Components/Main/OrderbookItem.js | 169 + src/Components/Main/TradeList.js | 122 + src/Components/Main/TradeListItem.js | 119 + src/Container/withLatestCoinData.js | 33 + src/Container/withLoadingData.js | 33 + src/Container/withMarketNames.js | 52 + src/Container/withOHLCData.js | 18 + src/Container/withOrderbookData.js | 59 + src/Container/withSelectedCoinName.js | 28 + src/Container/withSelectedCoinPrice.js | 76 + src/Container/withSelectedOption.js | 33 + src/Container/withSize.js | 29 + src/Container/withThemeData.js | 9 + src/Container/withTradeListData.js | 18 + src/Lib/asyncUtil.js | 290 + src/Lib/utils.js | 529 + src/Pages/Main.js | 121 + src/Reducer/coinReducer.js | 547 + src/Reducer/index.js | 15 + src/Reducer/loadingReducer.js | 33 + src/Router/MainRouter.js | 14 + src/index.css | 40 + src/index.js | 51 + src/serviceWorker.js | 141 + src/setupTests.js | 5 + src/styles/GlobalStyle.js | 18 + src/styles/fonts/NotoSansKR-Black.otf | Bin 0 -> 386588 bytes src/styles/fonts/NotoSansKR-Black.woff | Bin 0 -> 246204 bytes src/styles/fonts/NotoSansKR-Black.woff2 | Bin 0 -> 163136 bytes src/styles/fonts/NotoSansKR-Bold.otf | Bin 0 -> 378644 bytes src/styles/fonts/NotoSansKR-Bold.woff | Bin 0 -> 252264 bytes src/styles/fonts/NotoSansKR-Bold.woff2 | Bin 0 -> 170292 bytes src/styles/fonts/NotoSansKR-DemiLight.otf | Bin 0 -> 376416 bytes src/styles/fonts/NotoSansKR-DemiLight.woff | Bin 0 -> 247988 bytes src/styles/fonts/NotoSansKR-DemiLight.woff2 | Bin 0 -> 169916 bytes src/styles/fonts/NotoSansKR-Light.otf | Bin 0 -> 379316 bytes src/styles/fonts/NotoSansKR-Light.woff | Bin 0 -> 244948 bytes src/styles/fonts/NotoSansKR-Light.woff2 | Bin 0 -> 167060 bytes src/styles/fonts/NotoSansKR-Medium.otf | Bin 0 -> 373156 bytes src/styles/fonts/NotoSansKR-Medium.woff | Bin 0 -> 248928 bytes src/styles/fonts/NotoSansKR-Medium.woff2 | Bin 0 -> 170156 bytes src/styles/fonts/NotoSansKR-Regular.otf | Bin 0 -> 375252 bytes src/styles/fonts/NotoSansKR-Regular.woff | Bin 0 -> 249180 bytes src/styles/fonts/NotoSansKR-Regular.woff2 | Bin 0 -> 170444 bytes src/styles/fonts/NotoSansKR-Thin.otf | Bin 0 -> 381964 bytes src/styles/fonts/NotoSansKR-Thin.woff | Bin 0 -> 227516 bytes src/styles/fonts/NotoSansKR-Thin.woff2 | Bin 0 -> 154988 bytes src/styles/theme.js | 31 + yarn.lock | 13753 ++++++++++++++++++ 74 files changed, 19126 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 public/blueLogo.png create mode 100644 public/favicon.png create mode 100644 public/index.html create mode 100644 public/robots.txt create mode 100644 public/whiteLogo.png create mode 100644 src/Api/api.js create mode 100644 src/App.js create mode 100644 src/Components/Global/Footer.js create mode 100644 src/Components/Global/Header.js create mode 100644 src/Components/Global/Loading.js create mode 100644 src/Components/Main/ChartDataConsole.js create mode 100644 src/Components/Main/CoinInfoHeader.js create mode 100644 src/Components/Main/CoinList.js create mode 100644 src/Components/Main/CoinListItem.js create mode 100644 src/Components/Main/MainChart-d3fc.js create mode 100644 src/Components/Main/MainChart-old.js create mode 100644 src/Components/Main/MainChart.js create mode 100644 src/Components/Main/OrderInfo.js create mode 100644 src/Components/Main/OrderInfoAskBid.js create mode 100644 src/Components/Main/OrderInfoTradeList.js create mode 100644 src/Components/Main/Orderbook.js create mode 100644 src/Components/Main/OrderbookCoinInfo.js create mode 100644 src/Components/Main/OrderbookItem.js create mode 100644 src/Components/Main/TradeList.js create mode 100644 src/Components/Main/TradeListItem.js create mode 100644 src/Container/withLatestCoinData.js create mode 100644 src/Container/withLoadingData.js create mode 100644 src/Container/withMarketNames.js create mode 100644 src/Container/withOHLCData.js create mode 100644 src/Container/withOrderbookData.js create mode 100644 src/Container/withSelectedCoinName.js create mode 100644 src/Container/withSelectedCoinPrice.js create mode 100644 src/Container/withSelectedOption.js create mode 100644 src/Container/withSize.js create mode 100644 src/Container/withThemeData.js create mode 100644 src/Container/withTradeListData.js create mode 100644 src/Lib/asyncUtil.js create mode 100644 src/Lib/utils.js create mode 100644 src/Pages/Main.js create mode 100644 src/Reducer/coinReducer.js create mode 100644 src/Reducer/index.js create mode 100644 src/Reducer/loadingReducer.js create mode 100644 src/Router/MainRouter.js create mode 100644 src/index.css create mode 100644 src/index.js create mode 100644 src/serviceWorker.js create mode 100644 src/setupTests.js create mode 100644 src/styles/GlobalStyle.js create mode 100644 src/styles/fonts/NotoSansKR-Black.otf create mode 100644 src/styles/fonts/NotoSansKR-Black.woff create mode 100644 src/styles/fonts/NotoSansKR-Black.woff2 create mode 100644 src/styles/fonts/NotoSansKR-Bold.otf create mode 100644 src/styles/fonts/NotoSansKR-Bold.woff create mode 100644 src/styles/fonts/NotoSansKR-Bold.woff2 create mode 100644 src/styles/fonts/NotoSansKR-DemiLight.otf create mode 100644 src/styles/fonts/NotoSansKR-DemiLight.woff create mode 100644 src/styles/fonts/NotoSansKR-DemiLight.woff2 create mode 100644 src/styles/fonts/NotoSansKR-Light.otf create mode 100644 src/styles/fonts/NotoSansKR-Light.woff create mode 100644 src/styles/fonts/NotoSansKR-Light.woff2 create mode 100644 src/styles/fonts/NotoSansKR-Medium.otf create mode 100644 src/styles/fonts/NotoSansKR-Medium.woff create mode 100644 src/styles/fonts/NotoSansKR-Medium.woff2 create mode 100644 src/styles/fonts/NotoSansKR-Regular.otf create mode 100644 src/styles/fonts/NotoSansKR-Regular.woff create mode 100644 src/styles/fonts/NotoSansKR-Regular.woff2 create mode 100644 src/styles/fonts/NotoSansKR-Thin.otf create mode 100644 src/styles/fonts/NotoSansKR-Thin.woff create mode 100644 src/styles/fonts/NotoSansKR-Thin.woff2 create mode 100644 src/styles/theme.js create mode 100644 yarn.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ac4303 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Seongkyun Yu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba45c30 --- /dev/null +++ b/README.md @@ -0,0 +1,455 @@ +# Downbit 프로젝트 ![ViewCount](https://views.whatilearened.today/views/github/Seongkyun-Yu/upbit-clone.svg) + +| [![downbit_image](https://user-images.githubusercontent.com/15887982/99421488-c6cf0080-2941-11eb-9c5f-624593075430.gif)](https://youtu.be/zsuSvv9IfM8) | +| :----------------------------------------------------------------------------------------------------------------------------------------------------: | +| _이미지 클릭시 YouTube로 연결됩니다_ | + +
+ +2020년 9월 1일부터 11월 10일 동안 매일 2시간씩 진행한 업비트 클론 프로젝트 입니다.
+ +~~[downbit.ml](https://downbit.ml)에서 배포된 프로젝트 내역을 확인하실 수 있습니다.~~
+두나무의 요청으로 배포를 중단했습니다.
+ +
+ +## Development motivation + +Upbit의 실제 거래 데이터를 통해
+ +많은 데이터 수신시 프론트 엔드의 뷰를 최적화 하는 방법을 학습하고자
+ +이번 프로젝트를 시작하였습니다.
+ +
+ +## Skill + +![HTML5](https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white) +![CSS3](https://img.shields.io/badge/-CSS3-1572B6?style=flat-square&logo=css3) +![JavaScript](https://img.shields.io/badge/-JavaScript-black?style=flat-square&logo=javascript)
+![React](https://img.shields.io/badge/-React-black?style=flat-square&logo=react) +![Redux](https://img.shields.io/badge/Redux-7F43C5?style=flat&logo=redux&logoColor=white) +![Styled-Components](https://img.shields.io/badge/-Styled%20Component-pink?style=flat-square&logo=styled-components)
+![Amazon AWS](https://img.shields.io/badge/Amazon%20AWS-232F3E?style=flat-square&logo=amazon-aws) + +
+ +## Requirements + +- Library +
+ 접기/펼치기 버튼 +
+ React v.16
+ axios: 0.20.0
+ d3: 5.15.1
+ react-redux: 7.2.1
+ redux-saga v.1.1.3
+ redux-thunk v.2.3.0
+ react-router-dom v.5.2.0
+ axios v.0.19.2
+ websocket: 1.0.32
+ react-fast-compare: 3.2.0
+ react-financial-charts: 1.0.0-alpha.16
+ decimal.js: 10.2.1
+ hangul-js: 0.2.6
+ lodash: 4.17.20
+ moment-timezone: 0.5.31
+ styled-components: 5.2.0
+ styled-normalize: 8.0.7
+ styled-reset": 4.3.0
+ @fortawesome/free-brands-svg-icons: 5.15.1
+ @fortawesome/free-solid-svg-icons: 5.15.1
+ @fortawesome/react-fontawesome: 0.1.12
+
+
+ +
+ +## Getting Started + +$ git clone https://github.com/Seongkyun-Yu/upbit-clone.git
+$ yarn install
+\$ yarn start
+ +
+ +## Main Feature (프로젝트의 모든 기능을 혼자 개발했습니다) + +- 실시간 가격, 거래량 등의 데이터 수신 및 차트 랜더링 +- 실시간 호가창, 거래내역 랜더링 +- 코인 초성, 심볼 검색 +- 매수 총액에 따른 구매수량 자동 조절, 가격 변경에 따른 구매 총액 자동 변경 +- 호가창 클릭시 자동 가격 입력 +- 반응형 + +
+ +## 프로젝트 구조 + +```bash +├── node_modules +├── public +│ ├── blueLogo.png +│ ├── whiteLogo.png +│ ├── favicon.png +│ └── index.html +├── build +├── src +│ ├── Api +│ │ └── api.js +│ ├── Components +│ │ ├── Global +│ │ │ ├── Header.js +│ │ │ ├── Footer.js +│ │ │ └── Loading.js +│ │ └── Main +│ │ ├── ChartDataConsole.js +│ │ ├── CoinInfoHeader.js +│ │ ├── CoinList.js +│ │ ├── CoinListItem.js +│ │ ├── MainChart.js +│ │ ├── Orderbook.js +│ │ ├── OrderbookCoinInfo.js +│ │ ├── OrderbookItem.js +│ │ ├── OrderInfo.js +│ │ ├── OrderInfoAskBid.js +│ │ ├── OrderInfoTradeList.js +│ │ ├── TradeList.js +│ │ └── TradeListItem.js +│ ├── Pages +│ │ └── Main.js +│ ├── Container <-- HOC +│ │ ├── withLatestCoinData.js +│ │ ├── withLoadingData.js +│ │ ├── withMarketNames.js +│ │ ├── withOHLCData.js +│ │ └── ...etc +│ ├── Lib +│ │ ├── asyncUtil.js <-- redux-saga, thunk factory pattern +│ │ └── utils.js <-- etc utils +│ ├── Reducer +│ │ ├── index.js +│ │ ├── coinReducer.js +│ │ └── loadingReducer.js +│ ├── Router +│ │ └── MainRouter.js +│ ├── styles +│ │ ├── fonts +│ │ ├── GlobalStyle.js +│ │ └── theme.js +│ ├── App.js +│ └── index.js +├── README.md +├── LICENSE +├── package.json +├── yarn.lock +└── .gitignore +``` + +
+ +## 프로젝트 관련 생각들 + + +- [buffer를 활용하여 상태 갱신 줄이기](https://velog.io/@seongkyun/React-%EC%B5%9C%EC%A0%81%ED%99%94-buffer%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EC%83%81%ED%83%9C-%EA%B0%B1%EC%8B%A0-%EC%A4%84%EC%9D%B4%EA%B8%B0) +- [throttle로 이벤트 캐치 줄이기](https://velog.io/@seongkyun/React-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%98%EC%9D%91%ED%98%95%EA%B3%BC-display-none) +
+ +## Technical Issue: Optimization + +- 1초에 최대 150개의 데이터가 전송되어 상태를 변경시킴 + + + - Push 방식의 WebSocket을 Redux-Saga를 이용하여 Pull 방식으로 변경 + - Redux-Saga의 eventChannel을 이용하여 버퍼 생성 + - 0.5초에 한 번 버퍼를 확인하여 중복된 데이터 제거 후 변경내역을 상태에 한번에 업데이트 + + - ```javascript + import { call, put, select, flush, delay } from "redux-saga/effects"; + import { buffers, eventChannel } from "redux-saga"; + + // 소켓 만들기 + const createSocket = () => { + const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1"); + client.binaryType = "arraybuffer"; + + return client; + }; + + // 소켓 연결용 + const connectSocekt = (socket, connectType, action, buffer) => { + return eventChannel((emit) => { + socket.onopen = () => { + socket.send( + JSON.stringify([ + { ticket: "downbit-clone" }, + { type: connectType, codes: action.payload }, + ]) + ); + }; + + socket.onmessage = (evt) => { + const enc = new TextDecoder("utf-8"); + const arr = new Uint8Array(evt.data); + const data = JSON.parse(enc.decode(arr)); + + emit(data); + }; + + socket.onerror = (evt) => { + emit(evt); + }; + + const unsubscribe = () => { + socket.close(); + }; + + return unsubscribe; + }, buffer || buffers.none()); + }; + + // 웹소켓 연결용 사가 + const createConnectSocketSaga = (type, connectType, dataMaker) => { + const SUCCESS = `${type}_SUCCESS`; + const ERROR = `${type}_ERROR`; + + return function* (action = {}) { + const client = yield call(createSocket); + const clientChannel = yield call( + connectSocekt, + client, + connectType, + action, + buffers.expanding(500) + ); + + while (true) { + try { + const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기 + const state = yield select(); + + if (datas.length) { + const sortedObj = {}; + datas.forEach((data) => { + if (sortedObj[data.code]) { + // 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김 + sortedObj[data.code] = + sortedObj[data.code].timestamp > data.timestamp + ? sortedObj[data.code] + : data; + } else { + sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음 + } + }); + + const sortedData = Object.keys(sortedObj).map( + (data) => sortedObj[data] + ); + + yield put({ + type: SUCCESS, + payload: dataMaker(sortedData, state), + }); + } + yield delay(500); // 500ms 동안 대기 + } catch (e) { + yield put({ type: ERROR, payload: e }); + } + } + }; + }; + ``` + +- 반응형으로 제작시 보이지 않는 컴포넌트를 랜더링 처리 + + + + - display: none으로 처리해도 DOM에는 사라지지 않기 때문에 상태 변경시 랜더링 시도함 + + - width 값을 측정하여 조건이 맞을 경우에만 컴포넌트를 랜더링 하게 함 + - throttle 사용으로 과도한 width값 측정 방지 + - ```javascript + import React, { useCallback, useEffect, useState } from "react"; + import { throttle } from "lodash"; + + const withSize = () => (OriginalComponent) => (props) => { + const [widthSize, setWidthSize] = useState(window.innerWidth); + const [heightSize, setHeightSize] = useState(window.innerHeight); + + const handleSize = useCallback(() => { + setWidthSize(window.innerWidth); + setHeightSize(window.innerHeight); + }, []); + + useEffect(() => { + window.addEventListener("resize", throttle(handleSize, 200)); + return () => { + window.removeEventListener("resize", handleSize); + }; + }, [handleSize]); + + return ( + + ); + }; + export default withSize; + ``` + +- 초기 차트 데이터를 얼마나 가져와야 하는지에 대한 문제 + - 200개의 캔들을 먼저 가져오고 필요할 시 추가로 요청 후 랜더링 + +
+ +## Todo + +- [x] WebSocket 통신
+- [x] 기본 Reducer 제작
+- [x] Thunk Factory Pattern 제작
+- [x] Saga Factory Pattern 제작
+- [x] 캔들 차트 드로잉
+- [x] 호가 차트 드로잉
+- [x] 주문 창 구현
diff --git a/package.json b/package.json new file mode 100644 index 0000000..fdcf350 --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "upbit-clone", + "version": "0.1.0", + "private": true, + "dependencies": { + "@babel/polyfill": "^7.12.1", + "@fortawesome/fontawesome-svg-core": "^1.2.32", + "@fortawesome/free-brands-svg-icons": "^5.15.1", + "@fortawesome/free-solid-svg-icons": "^5.15.1", + "@fortawesome/react-fontawesome": "^0.1.12", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "axios": "^0.20.0", + "core-js": "^3.8.3", + "d3": "^5.15.1", + "d3-selection": "^1.1.0", + "d3-transition": "^1.3.2", + "d3fc": "^15.0.16", + "decimal.js": "^10.2.1", + "hangul-js": "^0.2.6", + "install": "^0.13.0", + "lodash": "^4.17.20", + "moment-timezone": "^0.5.31", + "npm": "^6.14.9", + "raf": "^3.4.1", + "react": "^16.13.1", + "react-app-polyfill": "^2.0.0", + "react-dom": "^16.13.1", + "react-fast-compare": "^3.2.0", + "react-financial-charts": "1.0.0-alpha.16", + "react-loading": "^2.0.3", + "react-redux": "^7.2.1", + "react-router-dom": "^5.2.0", + "react-scripts": "3.2.0", + "redux": "^4.0.5", + "redux-devtools-extension": "^2.13.8", + "redux-saga": "^1.1.3", + "redux-thunk": "^2.3.0", + "regenerator-runtime": "^0.13.7", + "styled-components": "^5.2.0", + "styled-normalize": "^8.0.7", + "styled-reset": "^4.3.0", + "text-encoding": "^0.7.0", + "websocket": "^1.0.32" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + ">0.2%", + "ie 9", + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version", + "not dead" + ] + } +} diff --git a/public/blueLogo.png b/public/blueLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..8c83dd9c31c3dfdcb06cd0e468c16a0cd7a62957 GIT binary patch literal 14562 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 G3i^Kl+Kbu% literal 0 HcmV?d00001 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..d6c3e97ce388c96dcc4843e2cc7660fe12ffb008 GIT binary patch literal 571 zcmV-B0>u4^P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0nkZAK~!i%?UqYU z!!QtrxeALedO(E47FCs4As*scl|ogu|;n33g79B1OhbsG8Cw~6hs zz9dfUjm=hjy@xvJb!zq z83kdr$f}J$KEG&2L0B!aYU8c#9hy-PR*M8!$L!Z>2`fYb7z09SR)th!-0AKump|W& zMXx_#U0Fz^SqV~s@#*;`4f>C8gFHMwp@QtN5~Qs0^!A=cXvvj6yXetSYgiRh%GmOA zwMh2|H#8_OtPpWG&fN=)(!jih@-J^>w>qhS52mO)R7F_8$cIY^jd|BaiN_S z9x7HnJkF>o8TBgC(%8-@Y(?xbWT{wHF3DXodPQN=gyHMJwE5T(Q>(a)Dkt^-23%D2qC=F|xwJR*rWpn{Y zV+n5CVJ;qx0kFyZwjR;l z%*L>EXly;AyP1svkcRUBRRoRb%d0cd*gw(OKhfAf(bzYw_8+)CKc~HX!+`(*002ov JPDHLkV1h!H{Kx + + + + + + + + + Downbit + + + +
+ + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/whiteLogo.png b/public/whiteLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..e7afbac97264a28cdd2e864abb15ad0dbf750aea GIT binary patch literal 11276 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 + axios.get("https://api.upbit.com/v1/market/all?isDetails=false"), + getInitCanldes: (coins) => + axios.get(`https://api.upbit.com/v1/ticker?markets=${coins}`), + getInitOrderbooks: (coins) => + axios.get(`https://api.upbit.com/v1/orderbook?markets=${coins}`), + getOneCoinCandles: ({ coin, timeType, timeCount }) => { + if (timeType === "minutes") + return axios + .get( + `https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&count=200` + ) + .then((res) => { + return { + ...res, + data: res.data.sort((a, b) => a.timestamp - b.timestamp), + }; + }); + else + return axios + .get( + `https://api.upbit.com/v1/candles/${timeType}?market=${coin}&count=200` + ) + .then((res) => { + return { + ...res, + data: res.data.sort((a, b) => a.timestamp - b.timestamp), + }; + }); + }, + getAdditionalCoinCandles: ({ coin, timeType, timeCount, datetime }) => { + if (timeType === "minutes") + return axios + .get( + `https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&to=${datetime}&count=200` + ) + .then((res) => { + return { + ...res, + data: res.data.sort((a, b) => a.timestamp - b.timestamp), + }; + }); + else + return axios + .get( + `https://api.upbit.com/v1/candles/${timeType}?market=${coin}&to=${datetime}&count=200` + ) + .then((res) => { + return { + ...res, + data: res.data.sort((a, b) => a.timestamp - b.timestamp), + }; + }); + }, + getOneCoinTradeLists: (coin) => + axios.get(`https://api.upbit.com/v1/trades/ticks?market=${coin}&count=50`), +}; diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..9a8c9fc --- /dev/null +++ b/src/App.js @@ -0,0 +1,15 @@ +import React, { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { startInit } from "./Reducer/coinReducer"; +import MainRouter from "./Router/MainRouter"; + +function App() { + const dispatch = useDispatch(); + useEffect(() => { + dispatch(startInit()); + }, [dispatch]); + + return ; +} + +export default App; diff --git a/src/Components/Global/Footer.js b/src/Components/Global/Footer.js new file mode 100644 index 0000000..14c2535 --- /dev/null +++ b/src/Components/Global/Footer.js @@ -0,0 +1,132 @@ +import React from "react"; +import styled from "styled-components"; +import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; +import { faGithub } from "@fortawesome/free-brands-svg-icons"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const St = { + Footer: styled.footer` + display: block; + width: 100%; + height: 100%; + /* height: 120px; */ + background-color: white; + padding: 20px 0; + @media ${({ theme }) => theme.tablet} { + display: none; + } + `, + Container: styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 100%; + max-width: 1360px; + margin: 0 auto; + padding: 0 20px; + + @media ${({ theme }) => theme.tablet} { + display: block; + max-width: 950px; + } + `, + MainLink: styled.a` + display: block; + background-image: ${({ logo }) => `url(${logo})`}; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + color: transparent; + width: 130px; + height: 60px; + `, + Description: styled.p` + font-weight: 600; + font-size: 0.9rem; + color: gray; + height: 85px; + margin-top: 10px; + /* margin-left: 250px; */ + `, + DescSpan: styled.span` + display: block; + height: 30px; + `, + ContactContainer: styled.address` + display: flex; + flex-direction: column; + /* margin-left: 250px; */ + `, + LinkTitle: styled.span` + height: 25px; + font-size: 0.9rem; + font-weight: 600; + color: gray; + `, + LinkTag: styled.a` + display: flex; + align-items: center; + height: 30px; + color: black; + text-decoration: none; + `, + LinkSpan: styled.span` + display: block; + margin-left: ${({ marginLeft }) => marginLeft || "8px"}; + font-weight: 600; + font-size: 0.9rem; + height: 20px; + /* line-height: 1.5rem; */ + color: gray; + `, +}; + +const Footer = () => { + return ( + + + + + Upbit Clone Project - Downbit + Created by Seongkyun Yu + + Copyright © 2020 DOWNBIT INC. ALL RIGHTS RESERVED. + + + + Contact Me +
    +
  • + + + github.com/Seongkyun-Yu/upbit-clone + +
  • +
  • + + + ysungkyun@gmail.com + +
  • +
+
+
+
+ ); +}; + +export default Footer; diff --git a/src/Components/Global/Header.js b/src/Components/Global/Header.js new file mode 100644 index 0000000..27a4338 --- /dev/null +++ b/src/Components/Global/Header.js @@ -0,0 +1,64 @@ +import React from "react"; +import styled from "styled-components"; + +const St = { + Header: styled.header` + position: sticky; + top: 0; + z-index: 100; + width: 100%; + height: 60px; + background-color: rgb(9, 54, 135); + `, + Container: styled.div` + display: flex; + align-items: center; + width: 100%; + height: 100%; + max-width: 1360px; + margin: 0 auto; + + @media ${({ theme, isRootURL }) => (!isRootURL ? theme.tablet : true)} { + max-width: 950px; + } + + @media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} { + max-width: 100%; + } + `, + SiteHeading: styled.h1` + padding: 0 20px; + width: 150px; + height: 100%; + `, + MainLink: styled.a` + display: block; + background-image: ${({ logo }) => `url(${logo})`}; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + color: transparent; + width: 100%; + height: 100%; + `, +}; + +const Header = ({ isRootURL }) => { + return ( + + + + + 업비트 + + + + + ); +}; + +export default Header; diff --git a/src/Components/Global/Loading.js b/src/Components/Global/Loading.js new file mode 100644 index 0000000..5b6eac6 --- /dev/null +++ b/src/Components/Global/Loading.js @@ -0,0 +1,34 @@ +import React from "react"; +import ReactLoading from "react-loading"; +import styled, { css } from "styled-components"; + +const St = { + Container: styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + ${({ isCenter }) => + !isCenter && + css` + align-items: stretch; + margin-top: 200px; + `} + `, +}; + +const Loading = ({ center = true }) => { + return ( + + + + ); +}; + +export default Loading; diff --git a/src/Components/Main/ChartDataConsole.js b/src/Components/Main/ChartDataConsole.js new file mode 100644 index 0000000..3211437 --- /dev/null +++ b/src/Components/Main/ChartDataConsole.js @@ -0,0 +1,119 @@ +import React, { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import styled from "styled-components"; +import withSelectedOption from "../../Container/withSelectedOption"; +import withThemeData from "../../Container/withThemeData"; +import { changeTimeTypeAndData } from "../../Reducer/coinReducer"; + +const St = { + Container: styled.div` + display: flex; + width: 100%; + background-color: white; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + TimeBtnContainer: styled.div` + display: flex; + align-items: center; + height: 30px; + `, + TimeBtn: styled.button` + /* width: 50px; */ + height: 20px; + width: 38px; + margin-left: 5px; + font-size: 0.8rem; + background-color: white; + + border: ${({ theme, isSelected }) => + isSelected ? `2px solid black` : `1px solid ${theme.lightGray2}`}; + outline: none; + cursor: pointer; + `, +}; + +const ChartDataConsole = ({ theme, selectedTimeCount, selectedTimeType }) => { + const dispatch = useDispatch(); + + const changeChartTime = useCallback( + (timeCount, timeType) => () => { + dispatch(changeTimeTypeAndData({ timeCount, timeType })); + }, + [dispatch] + ); + + return ( + + 차트에 표시할 캔들의 시간 선택 + + + 1m + + + 3m + + + 5m + + + 10m + + + 15m + + + 1h + + + 4h + + + 1d + + + + ); +}; + +export default withSelectedOption()(withThemeData()(ChartDataConsole)); diff --git a/src/Components/Main/CoinInfoHeader.js b/src/Components/Main/CoinInfoHeader.js new file mode 100644 index 0000000..4cca13e --- /dev/null +++ b/src/Components/Main/CoinInfoHeader.js @@ -0,0 +1,213 @@ +import React from "react"; +import styled from "styled-components"; +import withSelectedCoinName from "../../Container/withSelectedCoinName"; +import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice"; +import withThemeData from "../../Container/withThemeData"; +import isEqual from "react-fast-compare"; + +const St = { + CoinInfoContainer: styled.section` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + background-color: white; + padding: 10px; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + CoinInfoMain: styled.div` + display: flex; + align-items: center; + min-width: 380px; + `, + CoinLogo: styled.i` + display: inline-block; + width: 35px; + height: 35px; + background-image: ${({ coinSymbol }) => + `url(https://static.upbit.com/logos/${coinSymbol}.png)`}; + background-size: cover; + margin-left: 5px; + `, + CoinNameContainer: styled.div` + padding: 0 8px 0 13px; + `, + CoinName: styled.strong` + font-size: 1.7rem; + font-weight: 800; + color: #2b2b2b; + + @media ${({ theme }) => theme.mobileS} { + font-size: 1.5rem; + } + `, + CoinMarketName: styled.span` + display: flex; + font-size: 0.9rem; + flex-direction: column; + padding-left: 5px; + margin-top: 7px; + `, + PriceInfo: styled.div` + display: flex; + flex-direction: column; + `, + Price: styled.strong` + color: ${({ priceColor }) => priceColor}; + font-size: 2rem; + font-weight: 800; + + @media ${({ theme }) => theme.mobileS} { + font-size: 1.5rem; + } + `, + PriceUnit: styled.span` + font-size: 0.9rem; + font-weight: 500; + padding-left: 5px; + `, + ChangeContainer: styled.span` + font-size: 0.8rem; + margin-top: 5px; + `, + ChangeRate: styled.strong` + font-size: 1rem; + color: ${({ priceColor }) => priceColor}; + margin: 0 10px 0 5px; + font-weight: 800; + `, + ChangePrice: styled.strong` + font-size: 1rem; + font-weight: 800; + color: ${({ priceColor }) => priceColor}; + `, + TradeInfoContainer: styled.dl` + display: flex; + justify-content: flex-end; + align-items: center; + width: 45%; + height: 100%; + margin: 0 10px 0 0; + + @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} { + display: none; + } + `, + InfoContainer: styled.div` + height: 100%; + margin-left: 15px; + @media ${({ theme, tabletNone }) => (tabletNone ? theme.tablet : true)} { + display: none; + } + @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} { + display: none; + } + `, + TradeInfo: styled.div` + display: flex; + justify-content: space-between; + height: 50%; + min-width: ${({ minWidth }) => minWidth || "none"}; + border-bottom: 1px solid ${({ borderColor }) => borderColor || "none"}; + padding: 5px 0 5px 0; + font-size: 0.8rem; + `, + TradeDT: styled.dt` + display: inline-block; + min-width: 50px; + height: 50%; + `, + TradeDD: styled.dd` + margin: 0; + display: inline-block; + height: 50%; + color: ${({ fontColor }) => fontColor || "black"}; + font-weight: ${({ fontWeight }) => fontWeight || 500}; + `, +}; + +const CoinInfoHeader = ({ + theme, + coinNameKor, + coinSymbol, + coinNameAndMarketEng, + highestPrice24Hour, + lowestPrice24Hour, + changeRate24Hour, + changePrice24Hour, + tradePrice24Hour, + volume24Hour, + price, +}) => { + const priceColor = changeRate24Hour > 0 ? theme.priceUp : theme.priceDown; + return ( + + 코인 가격 및 기타 정보 + + + + {coinNameKor} + {coinNameAndMarketEng} + + + + {price.toLocaleString()} + KRW + + + 전일대비 + + {changeRate24Hour}% + + + {changePrice24Hour.toLocaleString()} + + + + + + + + 고가 + + {highestPrice24Hour ? highestPrice24Hour.toLocaleString() : 0} + + + + 저가 + + {lowestPrice24Hour ? lowestPrice24Hour.toLocaleString() : 0} + + + + + + 거래량(24h) + {`${volume24Hour.toLocaleString()} ${coinSymbol}`} + + + + 거래대금(24h) + + + {tradePrice24Hour ? tradePrice24Hour.toLocaleString() : 0} KRW + + + + + + ); +}; + +export default withSelectedCoinName()( + withSelectedCoinPrice()(withThemeData()(React.memo(CoinInfoHeader))) +); diff --git a/src/Components/Main/CoinList.js b/src/Components/Main/CoinList.js new file mode 100644 index 0000000..b42359e --- /dev/null +++ b/src/Components/Main/CoinList.js @@ -0,0 +1,210 @@ +import React from "react"; +import styled from "styled-components"; +import { searchCoin } from "../../Reducer/coinReducer"; +import { useDispatch } from "react-redux"; + +import CoinListItem from "./CoinListItem"; +import Loading from "../Global/Loading"; + +import withThemeData from "../../Container/withThemeData"; +import withSelectedOption from "../../Container/withSelectedOption"; +import withMarketNames from "../../Container/withMarketNames"; +import withLatestCoinData from "../../Container/withLatestCoinData"; +import withLoadingData from "../../Container/withLoadingData"; + +const St = { + CoinListContainer: styled.article` + display: none; + position: -webkit-sticky; /* 사파리 */ + position: sticky; + top: 70px; + height: 100%; + width: 100%; + background-color: white; + overflow: hidden; + + @media ${({ theme }) => theme.desktop} { + display: block; + max-width: 400px; + height: ${({ heightSize }) => `${heightSize}px`}; + margin-left: 10px; + } + + @media ${({ theme, isRootURL }) => (!isRootURL ? theme.mobileM : true)} { + display: none; + } + + @media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} { + display: block; + margin-top: 0; + margin-bottom: 0; + height: ${({ heightSize }) => + `${heightSize + 80}px`}; // 모바일 풀 화면을 위해 다시 80px 더해줌 + } + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + CoinSearchContainer: styled.div` + display: flex; + width: 100%; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + `, + CoinSearchInput: styled.input` + width: 100%; + border: none; + padding: 5px; + padding-left: 12px; + &::placeholder { + font-size: 0.7rem; + color: gray; + font-weight: 700; + } + `, + CoinSearchBtn: styled.button` + width: 30px; + height: 30px; + background: url("https://cdn.upbit.com/images/bg.e801517.png") -83px 2px no-repeat; + background-color: white; + padding: 10px; + padding-right: 20px; + padding-left: 20px; + border: none; + `, + CoinSortContainer: styled.ul` + display: flex; + justify-content: space-between; + align-items: center; + background-color: white; + width: 100%; + height: 30px; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + font-size: 0.9rem; + font-weight: 800; + color: #666666; + `, + CoinSortList: styled.li` + width: ${({ width }) => width || "20%"}; + text-align: ${({ textAlign }) => textAlign || "right"}; + margin-right: ${({ marginRight }) => marginRight || 0}; + font-size: 0.78rem; + `, + CoinUl: styled.ul` + height: ${({ heightSize }) => `${heightSize + 70}px`}; + min-height: 800px; + background-color: white; + overflow-y: scroll; + scrollbar-color: ${({ theme }) => theme.middleGray}; + scrollbar-width: thin; + scrollbar-base-color: ${({ theme }) => theme.middleGray}; + &::-webkit-scrollbar { + width: 5px; + background-color: white; + border-radius: 5rem; + } + &::-webkit-scrollbar-thumb { + background-color: ${({ theme }) => theme.middleGray}; + border-radius: 5rem; + } + + @media ${({ theme }) => theme.desktop} { + display: block; + max-width: 400px; + height: ${({ heightSize }) => `${heightSize}px`}; + } + `, +}; + +const CoinList = ({ + theme, + marketNames, + sortedMarketNames, + latestCoinData, + selectedMarket, + searchCoinInput, + isMarketNamesLoading, + isInitCandleLoading, + heightSize, + isRootURL, +}) => { + const dispatch = useDispatch(); + + return ( + + 코인 리스트 + + dispatch(searchCoin(e.target.value))} + value={searchCoinInput} + placeholder={"코인명/심볼검색"} + /> + + + + + 한글명 + 현재가 + 상승률 + + 거래대금 + + + + {isMarketNamesLoading || isInitCandleLoading ? ( + + ) : ( + sortedMarketNames.map((marketName) => { + const splitedName = marketName.split("-"); + const enCoinName = splitedName[1] + "/" + splitedName[0]; + const changePrice24Hour = + latestCoinData[marketName].changePrice24Hour; + const changeRate24Hour = + latestCoinData[marketName].changeRate24Hour; + const tradePrice24Hour = + latestCoinData[marketName].tradePrice24Hour; + const price = latestCoinData[marketName].price; + // const isTraded = latestCoinData[marketName].isTraded; + + const fontColor = + +changePrice24Hour > 0 + ? theme.strongRed + : +changePrice24Hour < 0 + ? theme.strongBlue + : "black"; + return ( + + ); + }) + )} + + + ); +}; + +export default withLatestCoinData()( + withMarketNames()( + withSelectedOption()( + withLoadingData()(withThemeData()(React.memo(CoinList))) + ) + ) +); diff --git a/src/Components/Main/CoinListItem.js b/src/Components/Main/CoinListItem.js new file mode 100644 index 0000000..6543a54 --- /dev/null +++ b/src/Components/Main/CoinListItem.js @@ -0,0 +1,206 @@ +import React, { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useHistory } from "react-router-dom"; +import styled, { css } from "styled-components"; +import { startChangeMarketAndData } from "../../Reducer/coinReducer"; + +import isEqual from "react-fast-compare"; + +const St = { + CoinLi: styled.li` + width: 100%; + height: 45px; + + border-bottom: 1px solid ${({ borderBottomColor }) => borderBottomColor}; + &:last-child { + border-bottom: none; + } + background-color: ${({ bgColor }) => bgColor}; + `, + CoinBtn: styled.button` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 100%; + background-color: transparent; + border: none; + outline: none; + cursor: pointer; + text-align: left; + `, + CoinLogo: styled.i` + display: inline-block; + width: 20px; + height: 20px; + background-image: ${({ coinNameEn }) => + coinNameEn !== "ADX" + ? `url(https://static.upbit.com/logos/${coinNameEn}.png)` + : "../styles/img/ADX.png"}; + background-size: cover; + margin-left: 5px; + margin-right: 15px; + `, + CoinNameContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + width: 20%; + min-width: 55px; + height: 45px; + `, + CoinName: styled.strong` + display: block; + font-size: 12px; + font-weight: 800; + @media ${({ theme }) => theme.tablet} { + font-weight: 500; + } + `, + CoinNameEn: styled.span` + display: block; + font-size: 12px; + `, + Price: styled.strong` + display: block; + width: 20%; + min-width: 55px; + height: 100%; + text-align: right; + line-height: 2.5rem; + font-size: 12px; + font-weight: 800; + color: ${({ fontColor }) => fontColor}; + + border: 1px solid transparent; + ${({ isTraded }) => + isTraded && + (isTraded === "ASK" + ? css` + animation: disappearBlue 0.6s; + ` + : css` + animation: disappearRed 0.6s; + `)}; + @keyframes disappearBlue { + 0% { + border-color: ${({ theme }) => theme.strongBlue}; + } + 100% { + border-color: ${({ theme }) => theme.strongBlue}; + } + } + @keyframes disappearRed { + 0% { + border-color: ${({ theme }) => theme.strongRed}; + } + 100% { + border-color: ${({ theme }) => theme.strongRed}; + } + } + + @media ${({ theme }) => theme.tablet} { + font-weight: 500; + } + `, + ChangRateContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + width: 20%; + min-width: 55px; + height: 100%; + text-align: right; + `, + ChangeRate: styled.span` + display: block; + font-size: 12px; + color: ${({ fontColor }) => fontColor}; + /* font-weight: 800; */ + `, + ChangePrice: styled.span` + display: block; + font-size: 12px; + color: ${({ fontColor }) => fontColor}; + /* font-weight: 800; */ + `, + TradePrice: styled.span` + display: flex; + flex-direction: column; + justify-content: center; + font-size: 12px; + width: 25%; + height: 100%; + text-align: right; + /* font-weight: 800; */ + `, +}; + +const CoinListItem = ({ + theme, + selectedMarket, + marketName, + coinName, + enCoinName, + fontColor, + price, + changeRate24Hour, + changePrice24Hour, + tradePrice24Hour, +}) => { + const dispatch = useDispatch(); + const history = useHistory(); + + const tradeListData = useSelector( + (state) => state.Coin.tradeList.data[marketName] + ); + + const nowTimestamp = +new Date(); + + const isTraded = + tradeListData && + tradeListData.length > 2 && + nowTimestamp - tradeListData[0].timestamp < 500 && + tradeListData[0].trade_price !== tradeListData[1].trade_price + ? tradeListData[0].ask_bid + : false; + + const changeMarket = useCallback(() => { + dispatch(startChangeMarketAndData(marketName)); + history.push("/trade"); + }, [dispatch, marketName, history]); + + return ( + + + + + {coinName} + {enCoinName} + + + {price.toLocaleString()} + + + + {changeRate24Hour} + + + {changePrice24Hour.toLocaleString()} + + + + {tradePrice24Hour.toLocaleString() + " 백만"} + + + + ); +}; + +export default React.memo(CoinListItem, isEqual); diff --git a/src/Components/Main/MainChart-d3fc.js b/src/Components/Main/MainChart-d3fc.js new file mode 100644 index 0000000..64bf2e6 --- /dev/null +++ b/src/Components/Main/MainChart-d3fc.js @@ -0,0 +1,78 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import * as fc from "d3fc"; +import { useSelector } from "react-redux"; + +const MainChart = () => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + // const selectedCandles = useSelector( + // (state) => state.Coin.candle.data[selectedMarket].candles + // ); + // console.log(test[0].date); + + const selectedCandles = fc.randomFinancial()(200); + let updated = false; + + if (selectedCandles.length > 2) { + let updated = true; + + // console.log("여기야", selectedCandles); + const xExtent = fc.extentDate().accessors([(d) => d.date]); + const yExtent = fc.extentLinear().accessors([(d) => d.high, (d) => d.low]); + + const gridlines = fc.annotationSvgGridline(); + + const candlestick = fc.seriesSvgCandlestick().decorate((sel) => { + sel.enter().style("fill", (_, i) => (_.open > _.close ? "blue" : "red")); + sel + .enter() + .style("stroke", (_, i) => (_.open > _.close ? "blue" : "red")); + }); + + const multi = fc.seriesSvgMulti().series([gridlines, candlestick]); + // 줌용 설정 + const x = d3.scaleTime(); + const y = d3.scaleLinear(); + + const x2 = d3.scaleTime(); + const y2 = d3.scaleLinear(); + + const zoom = d3.zoom().on("zoom", () => { + // update the scale used by the chart to use the updated domain + + x.domain(d3.event.transform.rescaleX(x2).domain()); + y.domain(d3.event.transform.rescaleY(y2).domain()); + d3.select(".chartContainer").datum(selectedCandles).call(chart); + }); + const chart = fc + .chartCartesian(x, y) + .xDomain(xExtent(selectedCandles)) + .yDomain(yExtent(selectedCandles)) + .svgPlotArea(multi) + .decorate((sel) => { + sel + .enter() + .select(".plot-area") + .on("measure.range", () => { + x2.range([0, d3.event.detail.width]); + y2.range([d3.event.detail.height, 0]); + }) + .call(zoom); + }); + + x2.domain(chart.xDomain()); + y2.domain(chart.yDomain()); + + d3.select(".chartContainer").datum(selectedCandles).call(chart); + } + + useEffect(() => {}, [selectedCandles]); + + return ( +
+ {/* */} +
+ ); +}; + +export default MainChart; diff --git a/src/Components/Main/MainChart-old.js b/src/Components/Main/MainChart-old.js new file mode 100644 index 0000000..c4bbd3a --- /dev/null +++ b/src/Components/Main/MainChart-old.js @@ -0,0 +1,180 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import * as fc from "d3fc"; +import { useSelector } from "react-redux"; + +const MainChart = () => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const selectedCandles = useSelector( + (state) => state.Coin.candle.data[selectedMarket].candles + ); + const dateFormat = d3.timeParse("%Y-%m-%d %H:%M"); + + const svgRef = useRef(); + + useEffect(() => { + const margin = { top: 15, right: 65, bottom: 205, left: 50 }; + const width = 1000 - margin.left - margin.right; + const height = 625 - margin.top - margin.bottom; + + d3.select("#chartContainer").remove(); + const svg = d3 + .select("#root") + .append("svg") + .attr("id", "chartContainer") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + const dates = selectedCandles.map((candle) => dateFormat(candle.datetime)); + + const xScale = d3 + .scaleLinear() + .domain([-1, dates.length]) + .range([0, width]); + + const xDateScale = d3 + .scaleQuantize() + .domain([0, dates.length]) + .range(dates.length ? dates : ["20-01-01 00:00"]); + + const xBand = d3 + .scaleBand() + .domain(d3.range(-1, dates.length)) + .range([0, width]) + .padding(0.3); + + const xAxis = d3.axisBottom().scale(xScale); + + svg + .append("rect") + .attr("id", "rect") + .attr("width", width) + .attr("height", height) + .style("fill", "none") + .style("pointer-events", "all") + .attr("clip-path", "url(#clip)"); + + let gX = svg + .append("g") + .attr("class", "axis x-axis") //Assign "axis" class + .attr("transform", "translate(0," + height + ")") + .call(xAxis); + + gX.selectAll(".tick text"); + + const ymin = d3.min(selectedCandles.map((candle) => candle.low)); + const ymax = d3.max(selectedCandles.map((candle) => candle.high)); + const yScale = d3 + .scaleLinear() + .domain([ymin, ymax]) + .range([height, 0]) + .nice(); + const yAxis = d3.axisLeft().scale(yScale); + + const gY = svg.append("g").attr("class", "axis y-axis").call(yAxis); + + const chartBody = svg + .append("g") + .attr("class", "chartBody") + .attr("clip-path", "url(#clip)"); + + // 캔들 몸통 + const candles = chartBody.selectAll(".candle").data(selectedCandles); + candles + .enter() + .append("rect") + .attr("x", (_, i) => xScale(i) - xBand.bandwidth()) + .attr("class", "candle") + .attr("y", (candle) => yScale(Math.max(candle.open, candle.close))) + .attr("width", xBand.bandwidth()) + .attr("height", (candle) => { + return candle.open === candle.close + ? 1 + : yScale(Math.min(candle.open, candle.close)) - + yScale(Math.max(candle.open, candle.close)); + }) + .attr("fill", (candle) => { + return candle.open === candle.close + ? "silver" + : candle.open > candle.close + ? "red" + : "green"; + }); + // candles.exit().remove(); + + // 윗꼬리 아랫꼬리 + const stems = chartBody.selectAll("g.line").data(selectedCandles); + + stems + .enter() + .append("line") + .attr("class", "stem") + .attr("x1", (_, i) => xScale(i) - xBand.bandwidth() / 2) + .attr("x2", (_, i) => xScale(i) - xBand.bandwidth() / 2) + .attr("y1", (candle) => yScale(candle.high)) + .attr("y2", (candle) => yScale(candle.low)) + .attr("stroke", (candle) => { + return candle.open === candle.close + ? "white" + : candle.open > candle.close + ? "red" + : "green"; + }); + // stems.exit().remove(); + + svg + .append("defs") + .append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + // d3.getEvent = () => require("d3-selection").event; + // const zoomed = () => { + // const t = currentEvent.transform; + // const xScaleZ = t.rescaleX(xScale); + + // const hideTicksWithoutLabel = () => { + // d3.selectAll(".xAxis .tick text").each((d) => { + // if (this.innerHTML === "") { + // this.parentNode.style.display = "none"; + // } + // }); + // }; + + // gX.call(d3.axisBottom(xScaleZ)); + + // candles + // .attr("x", (d, i) => xScaleZ(i) - (xBand.bandwidth() * t.k) / 2) + // .attr("width", xBand.bandwidth() * t.k); + // stems.attr( + // "x1", + // (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5 + // ); + // stems.attr( + // "x2", + // (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5 + // ); + + // hideTicksWithoutLabel(); + + // // gX.selectAll(".tick text").call(wrap, xBand.bandwidth()); + // }; + + const extent = [ + [0, 0], + [width, height], + ]; + + let resizeTimer; + + chartBody.exit().remove(); + }, [selectedCandles]); + + return ; +}; + +export default MainChart; diff --git a/src/Components/Main/MainChart.js b/src/Components/Main/MainChart.js new file mode 100644 index 0000000..eb145e1 --- /dev/null +++ b/src/Components/Main/MainChart.js @@ -0,0 +1,285 @@ +import React from "react"; +import styled from "styled-components"; +import { useDispatch } from "react-redux"; +import { startAddMoreCandleData } from "../../Reducer/coinReducer"; + +import { format } from "d3-format"; +import { timeFormat } from "d3-time-format"; +import { + elderRay, + ema, + discontinuousTimeScaleProviderBuilder, + Chart, + ChartCanvas, + CurrentCoordinate, + BarSeries, + CandlestickSeries, + ElderRaySeries, + LineSeries, + MovingAverageTooltip, + OHLCTooltip, + SingleValueTooltip, + mouseBasedZoomAnchor, + XAxis, + YAxis, + CrossHairCursor, + EdgeIndicator, + MouseCoordinateX, + MouseCoordinateY, + ZoomButtons, + withDeviceRatio, + withSize, +} from "react-financial-charts"; + +import Loading from "../Global/Loading"; + +import withOHLCData from "../../Container/withOHLCData"; +import withThemeData from "../../Container/withThemeData"; +import withSelectedOption from "../../Container/withSelectedOption"; +import withLoadingData from "../../Container/withLoadingData"; +import isEqual from "react-fast-compare"; + +const barChartExtents = (data) => { + return data.volume; +}; + +const candleChartExtents = (data) => { + return [data.high, data.low]; +}; + +const yEdgeIndicator = (data) => { + return data.close; +}; + +const volumeSeries = (data) => { + return data.volume; +}; + +const St = { + ChartContainer: styled.section` + width: 100%; + height: 500px; + background-color: white; + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, +}; + +const margin = { left: 10, right: 80, top: 20, bottom: 20 }; +const minHeight = 350; + +const MainChart = ({ + data: initialData, + height, + width, + ratio, + selectedTimeType, + theme, + isCandleLoading, +}) => { + if (height > 500) height = 500; + const dispatch = useDispatch(); + + const dateTimeFormat = + selectedTimeType === "days" || selectedTimeType === "weeks" + ? "%y-%m-%d" + : "%y-%m-%d %H:%M"; + const timeDisplayFormat = timeFormat(dateTimeFormat); + const pricesDisplayFormat = format(""); + + const openCloseColor = (data) => { + return data.close > data.open ? theme.priceUp : theme.priceDown; + }; + + const volumeColor = (data) => { + return data.close > data.open ? theme.priceUpTrans : theme.priceDownTrans; + }; + + const xScaleProvider = discontinuousTimeScaleProviderBuilder().inputDateAccessor( + (d) => d.date + ); + + const ema12 = ema() + .id(1) + .options({ windowSize: 12 }) + .merge((d, c) => { + d.ema12 = c; + }) + .accessor((d) => d.ema12); + + const ema26 = ema() + .id(2) + .options({ windowSize: 26 }) + .merge((d, c) => { + d.ema26 = c; + }) + .accessor((d) => d.ema26); + + const elder = elderRay(); + + const calculatedData = elder(ema26(ema12(initialData))); + + const { data, xScale, xAccessor, displayXAccessor } = xScaleProvider( + calculatedData + ); + + // 확대 축소 초기 범위를 정하는 xExtendts설정, max와 min이 변하면 새로운 데이터 추가시 줌 설정이 풀린다 + const max = xAccessor(data[Math.min(199, data.length - 1)]); + const min = xAccessor( + data.length < 50 ? 0 : data[Math.min(50, Math.floor(data.length / 2))] + ); + const xExtents = [min, max + 5]; + + const gridHeight = height - margin.top - margin.bottom; + + const elderRayHeight = 100; + const elderRayOrigin = (_, h) => [0, h - elderRayHeight]; + const barChartHeight = gridHeight / 4; + const barChartOrigin = (_, h) => [0, h - barChartHeight - elderRayHeight]; + const chartHeight = gridHeight - elderRayHeight; + + return ( + + 캔들 차트 + {isCandleLoading ? ( + + ) : ( + { + dispatch(startAddMoreCandleData()); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `${pricesDisplayFormat(d.bullPower)}, ${pricesDisplayFormat( + d.bearPower + )}` + } + origin={[8, 16]} + /> + + + + )} + + ); +}; + +export default withOHLCData()( + withSize({ + style: { + width: "100%", + height: "500", + minHeight, + }, + })( + withDeviceRatio()( + withSelectedOption()( + withLoadingData()(withThemeData()(React.memo(MainChart, isEqual))) + ) + ) + ) +); diff --git a/src/Components/Main/OrderInfo.js b/src/Components/Main/OrderInfo.js new file mode 100644 index 0000000..c8d6e9c --- /dev/null +++ b/src/Components/Main/OrderInfo.js @@ -0,0 +1,110 @@ +import React from "react"; +import { useDispatch } from "react-redux"; +import styled from "styled-components"; +import OrderInfoAskBid from "./OrderInfoAskBid"; + +import withSelectedOption from "../../Container/withSelectedOption"; +import withThemeData from "../../Container/withThemeData"; +import withSelectedCoinName from "../../Container/withSelectedCoinName"; + +import { changeAskBidOrder } from "../../Reducer/coinReducer"; +import isEqual from "react-fast-compare"; + +const St = { + Container: styled.section` + width: 100%; + height: 50%; + background-color: white; + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + OrderTypeContainer: styled.ul` + display: flex; + height: 40px; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + + @media ${({ theme }) => theme.mobileS} { + font-size: 0.8rem; + } + `, + OrderTypeLi: styled.li` + width: 33.3333%; + height: 100%; + `, + OrderTypeBtn: styled.button` + width: 100%; + height: 100%; + background-color: white; + border: none; + border-bottom: 3px solid ${({ borderBottom }) => borderBottom || "white"}; + outline: 0; + font-weight: 900; + color: ${({ fontColor }) => fontColor || "black"}; + cursor: pointer; + `, +}; + +const OrderInfo = ({ + theme, + selectedAskBidOrder, + coinSymbol, + orderPrice, + orderAmount, + orderTotalPrice, +}) => { + const dispatch = useDispatch(); + return ( + + 주문 정보 + + + dispatch(changeAskBidOrder("bid"))} + > + 매수 + + + + dispatch(changeAskBidOrder("ask"))} + > + 매도 + + + + dispatch(changeAskBidOrder("tradeList"))} + > + 거래내역 + + + + + + ); +}; + +export default withSelectedCoinName()( + withSelectedOption()(withThemeData()(React.memo(OrderInfo, isEqual))) +); diff --git a/src/Components/Main/OrderInfoAskBid.js b/src/Components/Main/OrderInfoAskBid.js new file mode 100644 index 0000000..76df812 --- /dev/null +++ b/src/Components/Main/OrderInfoAskBid.js @@ -0,0 +1,255 @@ +import React, { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import styled from "styled-components"; +import { + changeAmountAndTotalPrice, + changePriceAndTotalPrice, + changeTotalPriceAndAmount, +} from "../../Reducer/coinReducer"; +import OrderInfoTradeList from "./OrderInfoTradeList"; + +const St = { + Container: styled.section` + width: 100%; + height: 50%; + background-color: white; + `, + OrderTypeContainer: styled.div` + display: flex; + height: 40px; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.lightGray2}; + + @media ${({ theme }) => theme.mobileS} { + font-size: 0.8rem; + } + `, + OrderType: styled.button` + width: 33.33%; + height: 100%; + background-color: white; + border: none; + border-bottom: 3px solid + ${({ borderBottom }) => borderBottom || "tranceparent"}; + outline: 0; + font-weight: 900; + color: ${({ fontColor }) => fontColor || "black"}; + `, + OrderInfoContainer: styled.div` + width: 100%; + padding: 15px; + padding-top: 0; + + @media ${({ theme }) => theme.mobileS} { + padding: 5px; + } + `, + OrderInfoDetailContainer: styled.div` + display: flex; + align-items: center; + width: 100%; + height: 38px; + margin-top: 15px; + + @media ${({ theme }) => theme.mobileS} { + font-size: 0.6rem; + margint-right: 10px; + } + `, + OrderInfoDetailTitle: styled.span` + display: block; + width: 20%; + min-width: 52px; + max-width: 100px; + font-size: 0.8rem; + font-weight: 600; + color: #666; + margin-left: 5px; + margin-right: 5px; + `, + OrderInfoInputContainer: styled.div` + display: flex; + width: 100%; + height: 100%; + `, + OrderInfoInput: styled.input` + width: ${({ width }) => width || "100%"}; + height: 100%; + margin: 0; + padding: 5px; + padding-right: 15px; + border: 1px solid ${({ theme }) => theme.lightGray2}; + text-align: right; + font-size: 0.95rem; + font-weight: ${({ fontWeight }) => fontWeight}; + @media ${({ theme }) => theme.mobileS} { + font-size: 0.6rem; + } + `, + Button: styled.button` + width: ${({ width }) => width || "50px"}; + min-width: ${({ minWidth }) => minWidth}; + height: ${({ height }) => height || "38px"}; + margin-right: ${({ marginRight }) => marginRight}; + background-color: ${({ bgColor }) => bgColor || "tranceparent"}; + border: none; + border-top: 1px solid ${({ borderColor }) => borderColor || "tranceparent"}; + border-right: 1px solid + ${({ borderColor }) => borderColor || "tranceparent"}; + border-bottom: 1px solid + ${({ borderColor }) => borderColor || "tranceparent"}; + outline: none; + color: ${({ fontColor }) => fontColor || "black"}; + font-size: ${({ fontSize }) => fontSize}; + font-weight: 900; + `, + PossibleAmount: styled.span` + display: block; + width: 100%; + text-align: right; + font-size: 1.2rem; + font-weight: 600; + @media ${({ theme }) => theme.mobileS} { + font-size: 1rem; + } + `, + Unit: styled.span` + margin-left: 5px; + font-size: 0.8rem; + font-weight: 500; + `, + OrderBtnContainer: styled.div` + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 50px; + + @media ${({ theme }) => theme.mobileS} { + font-size: 0.8rem; + } + `, +}; + +const OrderInfoAskBid = ({ + theme, + selectedAskBidOrder, + coinSymbol, + orderPrice, + orderAmount, + orderTotalPrice, +}) => { + const dispatch = useDispatch(); + const changePrice = useCallback( + (e) => + dispatch( + changePriceAndTotalPrice( + parseInt(e.target.value.replace(/[^0-9-.]/g, "")) + ) + ), + [dispatch] + ); + const changeAmount = useCallback( + (e) => { + dispatch( + changeAmountAndTotalPrice(e.target.value.replace(/[^0-9-.]/g, "")) + ); + }, + [dispatch] + ); + const changeTotalPrice = useCallback( + (e) => + dispatch( + changeTotalPriceAndAmount( + parseInt(e.target.value.replace(/[^0-9-.]/g, "")) + ) + ), + [dispatch] + ); + + return ( + + {selectedAskBidOrder !== "tradeList" ? ( + <> + + 주문가능 + + 0 + + {selectedAskBidOrder === "bid" ? "KRW" : coinSymbol} + + + + + + {selectedAskBidOrder === "bid" ? "매수가격" : "매도가격"} + + + + + + + + + - + + + + + 주문수량 + + + + 주문총액 + + + + ) : ( + + )} + + + 회원가입 + + + 로그인 + + + + ); +}; + +export default React.memo(OrderInfoAskBid); diff --git a/src/Components/Main/OrderInfoTradeList.js b/src/Components/Main/OrderInfoTradeList.js new file mode 100644 index 0000000..9bc690d --- /dev/null +++ b/src/Components/Main/OrderInfoTradeList.js @@ -0,0 +1,21 @@ +import React from "react"; +import styled from "styled-components"; + +const St = { + Container: styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 212px; + background-color: white; + font-size: 0.8rem; + color: #666; + `, +}; + +const OrderInfoTradeList = ({ theme }) => { + return 로그인 후 사용 가능합니다.; +}; + +export default React.memo(OrderInfoTradeList); diff --git a/src/Components/Main/Orderbook.js b/src/Components/Main/Orderbook.js new file mode 100644 index 0000000..ee37de8 --- /dev/null +++ b/src/Components/Main/Orderbook.js @@ -0,0 +1,124 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import styled from "styled-components"; + +import OrderbookItem from "./OrderbookItem"; +import Loading from "../Global/Loading"; + +import withThemeData from "../../Container/withThemeData"; +import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice"; +import withOrderbookData from "../../Container/withOrderbookData"; +import withSelectedOption from "../../Container/withSelectedOption"; +import withLoadingData from "../../Container/withLoadingData"; + +import isEqual from "react-fast-compare"; + +const St = { + Container: styled.section` + width: 46%; + max-height: 722px; + height: 100%; + background-color: white; + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + OrderUl: styled.ul` + width: 100%; + height: 722px; + overflow-y: scroll; + scrollbar-color: ${({ theme }) => theme.middleGray}; + scrollbar-width: thin; + scrollbar-base-color: transparent; + &::-webkit-scrollbar { + width: 5px; + background-color: transparent; + border-radius: 5rem; + } + &::-webkit-scrollbar-thumb { + background-color: ${({ theme }) => theme.middleGray}; + border-radius: 5rem; + } + `, +}; + +const Orderbook = ({ + theme, + // totalData, + askOrderbookData, + bidOrderbookData, + maxOrderSize, + beforeDayPrice, + selectedMarket, + isOrderbookLoading, +}) => { + const lastTradePrice = useSelector( + (state) => + state.Coin.tradeList.data[selectedMarket] && + state.Coin.tradeList.data[selectedMarket][0].trade_price + ); + return ( + + 호가창 + + {isOrderbookLoading ? ( + + ) : ( + askOrderbookData.map((orderbook, i) => { + return ( + + ); + }) + )} + {isOrderbookLoading || + bidOrderbookData.map((orderbook, i) => { + return ( + + ); + })} + + + ); +}; + +export default withOrderbookData()( + withSelectedCoinPrice()( + withSelectedOption()( + withLoadingData()(withThemeData()(React.memo(Orderbook, isEqual))) + ) + ) +); diff --git a/src/Components/Main/OrderbookCoinInfo.js b/src/Components/Main/OrderbookCoinInfo.js new file mode 100644 index 0000000..185ec7a --- /dev/null +++ b/src/Components/Main/OrderbookCoinInfo.js @@ -0,0 +1,64 @@ +import React from "react"; +import styled from "styled-components"; + +const St = { + CandleInfoContainer: styled.div` + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 33.3333%; + border: 1px solid gray; + margin-top: -1px; + margin-left: -1px; + `, + + InfoContainer: styled.div` + display: flex; + width: 90%; + font-size: 0.8rem; + padding: 5px; + `, + + InfoTxt: styled.span` + display: block; + width: 50%; + `, + + InfoValue: styled.span` + display: block; + width: 50%; + text-align: right; + `, +}; + +const OrderbookCoinInfo = ({ + volume24, + tradePrice24, + highestPrice52Week, + highestDate52Week, + lowestPrice52Week, + lowestDate52Week, +}) => { + return ( + + + 거래량 + {volume24} + + + 거래대금 + {`${tradePrice24}백만`} + + + 52주 최고 + {`${highestPrice52Week} (${highestDate52Week})`} + + + 52주 최저 + {`${lowestPrice52Week} (${lowestDate52Week})`} + + + ); +}; + +export default React.memo(OrderbookCoinInfo); diff --git a/src/Components/Main/OrderbookItem.js b/src/Components/Main/OrderbookItem.js new file mode 100644 index 0000000..6092069 --- /dev/null +++ b/src/Components/Main/OrderbookItem.js @@ -0,0 +1,169 @@ +import React, { useEffect, useRef } from "react"; +import { useDispatch } from "react-redux"; +import { changePriceAndTotalPrice } from "../../Reducer/coinReducer"; +import styled, { css } from "styled-components"; + +import isEqual from "react-fast-compare"; + +const St = { + OrderLi: styled.li` + display: flex; + width: 100%; + height: 45px; + &:nth-last-child() { + border-bottom: none; + } + font-size: 0.8rem; + @media ${({ theme }) => theme.mobileS} { + font-size: 0.7rem; + } + `, + + Btn: styled.button` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: transparent; + border: none; + outline: none; + padding: 0; + margin: 0; + cursor: pointer; + `, + + OrderAmount: styled.div` + display: flex; + justify-content: flex-start; + align-items: center; + position: relative; + width: 50%; + height: 45px; + border: 1px solid ${({ borderColor }) => borderColor}; + padding-left: 5px; + padding-right: 10px; + margin-top: -1px; + margin-left: -1px; + text-align: right; + `, + + OrderAmountSize: styled.div` + position: absolute; + width: ${({ witdhSize }) => witdhSize}; + left: 0; + height: 70%; + background-color: ${({ bgColor }) => bgColor}; + `, + + OrderPriceContainer: styled.div` + display: flex; + justify-content: center; + align-items: center; + position: relative; + width: 50%; + height: 45px; + border: 1px solid ${({ borderColor }) => borderColor}; + margin-top: -1px; + margin-left: -1px; + text-align: right; + color: ${({ fontColor }) => fontColor}; + background-color: ${({ bgColor }) => bgColor}; + + ${({ outline }) => + outline && + css` + border: 2px solid black; + border-right: 3px solid black; + &::after { + content: ""; + display: block; + position: absolute; + left: -5px; + width: 0px; + height: 0px; + border-right: 10px solid transparent; + border-bottom: 10px solid black; + transform: rotate(225deg); + -ms-transform: rotate(225deg); + -webkit-transform: rotate(225deg); + -moz-transform: rotate(225deg); + -o-transform: rotate(225deg); + } + `} + + @media ${({ theme }) => theme.mobileM} { + flex-direction: column; + } + `, + + OrderPrice: styled.strong` + font-weight: 800; + `, + + OrderPrcieRatio: styled.span` + padding-left: 13px; + `, +}; + +const OrderbookItem = ({ + theme, + price, + size, + maxOrderSize, + type, + changeRate24Hour, + index, + outline, +}) => { + const dispatch = useDispatch(); + const scrollRef = useRef(); + + useEffect(() => { + if (index === 7 && type === "ask") { + const parentNode = scrollRef.current.parentNode; + const parentAbsoluteTop = window.pageYOffset + parentNode.offsetTop; + const absoluteTop = window.pageYOffset + scrollRef.current.offsetTop; + const relativeTop = absoluteTop - parentAbsoluteTop; + scrollRef.current.parentNode.scrollTop = relativeTop; + } + }, []); + + return ( + + { + document.activeElement.blur(); + dispatch(changePriceAndTotalPrice(price)); + }} + > + 0 + ? theme.priceUp + : +changeRate24Hour < 0 + ? theme.priceDown + : "black" + } + borderColor={theme.lightGray} + bgColor={type === "ask" ? theme.skyBlue1 : theme.lightPink1} + // outline={lastTradePrice === price} + outline={outline} + > + {price.toLocaleString()} + {`${changeRate24Hour}%`} + + + {size} + + + + + ); +}; + +export default React.memo(OrderbookItem, isEqual); diff --git a/src/Components/Main/TradeList.js b/src/Components/Main/TradeList.js new file mode 100644 index 0000000..f752df5 --- /dev/null +++ b/src/Components/Main/TradeList.js @@ -0,0 +1,122 @@ +import React from "react"; +import styled from "styled-components"; +import Decimal from "decimal.js"; +import moment from "moment-timezone"; + +import TradeListItem from "./TradeListItem"; +import Loading from "../Global/Loading"; + +import withTradeListData from "../../Container/withTradeListData"; +import withThemeData from "../../Container/withThemeData"; +import withLoadingData from "../../Container/withLoadingData"; + +const St = { + Container: styled.article` + width: 100%; + height: 100%; + background-color: white; + margin-top: 10px; + @media ${({ theme }) => theme.mobileM} { + margin-top: 0; + } + `, + HiddenH3: styled.h3` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + TradeListUL: styled.ul` + overflow-y: scroll; + scrollbar-color: ${(props) => props.scrollColor}; + scrollbar-width: thin; + scrollbar-base-color: ${(props) => props.scrollColor}; + &::-webkit-scrollbar { + width: 5px; + background-color: white; + border-radius: 5rem; + } + &::-webkit-scrollbar-thumb { + background-color: ${(props) => props.scrollColor}; + border-radius: 5rem; + } + height: 320px; + `, + TradeListTitle: styled.ul` + display: flex; + justify-content: space-around; + align-items: center; + height: 25px; + background-color: ${({ theme }) => theme.lightGray1}; + font-size: 0.9rem; + + @media ${({ theme }) => theme.mobileS} { + font-size: 0.6rem; + } + `, + TitleListItem: styled.li` + width: 20%; + + min-width: 58px; + text-align: ${({ textAlign }) => textAlign || "center"}; + @media ${({ theme, mobileSNone }) => (mobileSNone ? theme.mobileS : true)} { + display: none; + } + + @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} { + display: none; + } + + @media ${({ theme, mobileSNone }) => mobileSNone || theme.mobileS} { + width: 50%; + } + `, +}; + +const TradeList = ({ theme, selectedTradeListData, isTradeListLoading }) => { + return ( + + 실시간 체결내역 + + + 체결시간 + + 체결가격 + 체결량 + + 체결금액 + + + + {isTradeListLoading || !selectedTradeListData ? ( + + ) : ( + selectedTradeListData.map((tradeList, i) => { + const tradeAmount = new Decimal(tradeList.trade_volume) + ""; + return ( + + ); + }) + )} + + + ); +}; + +export default withTradeListData()( + withLoadingData()(withThemeData()(React.memo(TradeList))) +); diff --git a/src/Components/Main/TradeListItem.js b/src/Components/Main/TradeListItem.js new file mode 100644 index 0000000..15c8778 --- /dev/null +++ b/src/Components/Main/TradeListItem.js @@ -0,0 +1,119 @@ +import React from "react"; +import styled from "styled-components"; +import isEqual from "react-fast-compare"; + +const St = { + TradeListLi: styled.li` + display: flex; + justify-content: space-around; + align-items: center; + width: 100%; + height: 25px; + font-size: 0.9em; + background-color: ${({ bgColor }) => bgColor || "white"}; + `, + + Datetime: styled.div` + width: 20%; + text-align: center; + @media ${({ theme }) => theme.mobileS} { + display: none; + } + `, + Date: styled.span` + text-align: center; + `, + + Time: styled.span` + text-align: center; + font-size: 0.8rem; + margin-left: 5px; + `, + + TradePrice: styled.span` + display: block; + width: 20%; + text-align: center; + color: ${({ fontColor }) => fontColor}; + font-weight: 600; + + @media ${({ theme }) => theme.mobileS} { + width: 50%; + font-size: 0.7rem; + } + @media ${({ theme }) => theme.mobileM} { + /* width: 50%; */ + font-size: 0.8rem; + } + `, + + TradeAmount: styled.span` + display: block; + width: 20%; + text-align: center; + color: ${({ fontColor }) => fontColor}; + + @media ${({ theme }) => theme.mobileS} { + width: 50%; + font-size: 0.7rem; + } + + @media ${({ theme }) => theme.mobileM} { + /* width: 50%; */ + font-size: 0.8rem; + } + `, + + TradeKRW: styled.span` + display: block; + width: 20%; + text-align: right; + + @media ${({ theme }) => theme.mobileS} { + display: none; + } + + @media ${({ theme }) => theme.mobileM} { + display: none; + } + `, +}; + +const TradeListItem = ({ + theme, + index, + date, + time, + tradePrice, + changePrice, + tradeAmount, + askBid, +}) => { + return ( + + + {date} + {time} + + 0 ? theme.priceUp : theme.priceDown} + > + {tradePrice.toLocaleString()} + + + {tradeAmount.toFixed(5)} + + + {Math.floor(tradePrice * tradeAmount).toLocaleString()} + + + ); +}; + +export default React.memo(TradeListItem, isEqual); diff --git a/src/Container/withLatestCoinData.js b/src/Container/withLatestCoinData.js new file mode 100644 index 0000000..7811156 --- /dev/null +++ b/src/Container/withLatestCoinData.js @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withLatestCoinData = () => (OriginalComponent) => (props) => { + const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터 + + const latestCoinData = {}; + + if (Object.keys(coinListDatas).length > 2) { + Object.keys(coinListDatas).forEach((marketName) => { + latestCoinData[marketName] = {}; + latestCoinData[marketName].price = + coinListDatas[marketName].candles[ + coinListDatas[marketName].candles.length - 1 + ].close; + + latestCoinData[marketName].changeRate24Hour = ( + Math.round(coinListDatas[marketName].changeRate24Hour * 10000) / 100 + ).toFixed(2); + + latestCoinData[marketName].changePrice24Hour = + coinListDatas[marketName].changePrice24Hour; + + latestCoinData[marketName].tradePrice24Hour = Math.floor( + coinListDatas[marketName].tradePrice24Hour / 1000000 + ); + }); + } + + return ; +}; + +export default withLatestCoinData; diff --git a/src/Container/withLoadingData.js b/src/Container/withLoadingData.js new file mode 100644 index 0000000..3c03f30 --- /dev/null +++ b/src/Container/withLoadingData.js @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withLoadingData = () => (OriginalComponent) => (props) => { + const isCandleLoading = useSelector( + (state) => state.Loading["coin/GET_ONE_COIN_CANDLES"] + ); + const isOrderbookLoading = useSelector( + (state) => state.Loading["coin/GET_INIT_ORDERBOOKS"] + ); + const isTradeListLoading = useSelector( + (state) => state.Loading["coin/GET_ONE_COIN_TRADELISTS"] + ); + const isInitCandleLoading = useSelector( + (state) => state.Loading["coin/GET_INIT_CANDLES"] + ); + const isMarketNamesLoading = useSelector( + (state) => state.Loading["coin/GET_MARKET_NAMES"] + ); + + return ( + + ); +}; + +export default withLoadingData; diff --git a/src/Container/withMarketNames.js b/src/Container/withMarketNames.js new file mode 100644 index 0000000..9e2178c --- /dev/null +++ b/src/Container/withMarketNames.js @@ -0,0 +1,52 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import * as Hangul from "hangul-js"; +import { choHangul } from "../Lib/utils"; + +const withMarketNames = () => (OriginalComponent) => (props) => { + const marketNames = useSelector((state) => state.Coin.marketNames.data); // 코인 마켓 이름들(객체) + let marketNamesArr = Object.keys(marketNames); // 코인 마켓 이름 배열화 + + const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터 + const coinSearchInputData = useSelector((state) => state.Coin.searchCoin); // 검색한 코인 이름 + + // 데이터 받는 데 성공하면 필터링 및 정렬한다 + if (Object.keys(coinListDatas).length > 1) { + // 검색 기준 필터링 + marketNamesArr = marketNamesArr.filter( + (coin) => + // 영어 검색 + marketNames[coin].english + .toLowerCase() + .includes(coinSearchInputData.toLowerCase()) || + // 코인 심볼 검색 + coin + .split("-")[1] + .toLowerCase() + .includes(coinSearchInputData.toLowerCase()) || + // 한글 검색 + Hangul.disassembleToString(marketNames[coin].korean).includes( + Hangul.disassembleToString(coinSearchInputData) + ) || + // 초성 검색 + choHangul(marketNames[coin].korean).includes(coinSearchInputData) + ); + + // 정렬 + marketNamesArr = marketNamesArr.sort((coin1, coin2) => { + return ( + +coinListDatas[coin2].tradePrice24Hour - + +coinListDatas[coin1].tradePrice24Hour + ); + }); + } + return ( + + ); +}; + +export default withMarketNames; diff --git a/src/Container/withOHLCData.js b/src/Container/withOHLCData.js new file mode 100644 index 0000000..a4f8d35 --- /dev/null +++ b/src/Container/withOHLCData.js @@ -0,0 +1,18 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withOHLCData = () => (OriginalComponent) => () => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); // 선택된 코인/마켓 + const selectedCandles = useSelector( + (state) => state.Coin.candle.data[selectedMarket].candles + ); // 선택된 코인/마켓 캔들 정보 + + // return selectedCandles.length ? ( + // + // ) : ( + //
Chart Loading
+ // ); + return ; +}; + +export default withOHLCData; diff --git a/src/Container/withOrderbookData.js b/src/Container/withOrderbookData.js new file mode 100644 index 0000000..55c415d --- /dev/null +++ b/src/Container/withOrderbookData.js @@ -0,0 +1,59 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withOrderbookData = () => (OriginalComponent) => (props) => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const orderbook = useSelector( + (state) => state.Coin.orderbook.data[selectedMarket] + ); + + let totalData; + let bidOrderbookData; + let askOrderbookData; + let orderbookData; + let maxOrderSize = 0; + + if (orderbook) { + totalData = { + totalBidSize: orderbook.total_bid_size, + totalAskSize: orderbook.total_ask_size, + }; + + bidOrderbookData = []; + askOrderbookData = []; + + // let maxOrderSize = 0; + // 호가 데이터 분리 정렬 + orderbook.orderbook_units.forEach((orderbook, i) => { + const bidSize = orderbook.bid_size.toFixed(3); + const askSize = orderbook.ask_size.toFixed(3); + + bidOrderbookData.push({ + bidPrice: orderbook.bid_price, + bidSize: orderbook.bid_size.toFixed(3), + }); + askOrderbookData.push({ + askPrice: orderbook.ask_price, + askSize: orderbook.ask_size.toFixed(3), + }); + maxOrderSize = Math.max(maxOrderSize, bidSize, askSize); + }); + + orderbookData = [...askOrderbookData, ...bidOrderbookData]; + // 매도 호가창은 가격 내림차순으로 정렬해줌 (매수는 원래 가격 내림차순임) + askOrderbookData.sort((book1, book2) => +book2.askPrice - +book1.askPrice); + } + + return ( + + ); +}; + +export default withOrderbookData; diff --git a/src/Container/withSelectedCoinName.js b/src/Container/withSelectedCoinName.js new file mode 100644 index 0000000..7ec5f04 --- /dev/null +++ b/src/Container/withSelectedCoinName.js @@ -0,0 +1,28 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withSelectedCoinName = () => (OriginalComponent) => (props) => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const coinNameKor = useSelector( + (state) => state.Coin.marketNames.data[selectedMarket].korean + ); + const coinNameEng = useSelector( + (state) => state.Coin.marketNames.data[selectedMarket].english + ); + + const splitedName = selectedMarket.split("-"); + const coinSymbol = splitedName[1]; + const coinNameAndMarketEng = splitedName[1] + "/" + splitedName[0]; + + return ( + + ); +}; + +export default withSelectedCoinName; diff --git a/src/Container/withSelectedCoinPrice.js b/src/Container/withSelectedCoinPrice.js new file mode 100644 index 0000000..267d66d --- /dev/null +++ b/src/Container/withSelectedCoinPrice.js @@ -0,0 +1,76 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withSelectedCoinPrice = () => (OriginalComponent) => (props) => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const selectedCoinData = useSelector( + (state) => state.Coin.candle.data[selectedMarket] + ); + + // 24시간 고가 저가 + const highestPrice24Hour = useSelector( + (state) => state.Coin.candle.data[selectedMarket]["highestPrice24Hour"] + ); + const lowestPrice24Hour = useSelector( + (state) => state.Coin.candle.data[selectedMarket]["lowestPrice24Hour"] + ); + + // 52주 고가 저가 + const highestPrice52Week = useSelector( + (state) => state.Coin.candle.data[selectedMarket].highestPrice52Week + ); + const highestDate52Week = useSelector( + (state) => state.Coin.candle.data[selectedMarket].highestDate52Week + ); + const lowestPrice52Week = useSelector( + (state) => state.Coin.candle.data[selectedMarket].lowestPrice52Week + ); + const lowestDate52Week = useSelector( + (state) => state.Coin.candle.data[selectedMarket].lowestDate52Week + ); + + // 24시간 거래대금, 거래량 + const tradePrice24Hour = Math.floor(selectedCoinData.tradePrice24Hour); + const volume24Hour = Math.floor(selectedCoinData.volume24Hour); + + // 24시간 가격 변화율, 변화량 + const changeRate24Hour = + Math.round(selectedCoinData.changeRate24Hour * 10000) / 100; + const changePrice24Hour = selectedCoinData.changePrice24Hour + ? selectedCoinData.changePrice24Hour + : 0; + + // 전일, 당일 가격 + const selecteCoinCadnles = useSelector( + (state) => state.Coin.candle.data[selectedMarket].candles + ); + const lastCandleIndex = selecteCoinCadnles.length - 1; + + const beforeDayPrice = selecteCoinCadnles.length + ? selecteCoinCadnles[lastCandleIndex].close - changePrice24Hour + : 0; + + const price = selecteCoinCadnles.length + ? selecteCoinCadnles[selecteCoinCadnles.length - 1].close + : 0; + + return ( + + ); +}; + +export default withSelectedCoinPrice; diff --git a/src/Container/withSelectedOption.js b/src/Container/withSelectedOption.js new file mode 100644 index 0000000..f15b18f --- /dev/null +++ b/src/Container/withSelectedOption.js @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withSelectedOption = () => (OriginalComponent) => (props) => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const selectedTimeType = useSelector((state) => state.Coin.selectedTimeType); + const selectedTimeCount = useSelector( + (state) => state.Coin.selectedTimeCount + ); + const selectedAskBidOrder = useSelector( + (state) => state.Coin.selectedAskBidOrder + ); + const searchCoin = useSelector((state) => state.Coin.searchCoin); + const orderPrice = useSelector((state) => state.Coin.orderPrice); + const orderAmount = useSelector((state) => state.Coin.orderAmount); + const orderTotalPrice = useSelector((state) => state.Coin.orderTotalPrice); + + return ( + + ); +}; + +export default withSelectedOption; diff --git a/src/Container/withSize.js b/src/Container/withSize.js new file mode 100644 index 0000000..d607e80 --- /dev/null +++ b/src/Container/withSize.js @@ -0,0 +1,29 @@ +import { throttle } from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; + +const withSize = () => (OriginalComponent) => (props) => { + const [widthSize, setWidthSize] = useState(window.innerWidth); + const [heightSize, setHeightSize] = useState(window.innerHeight); + + const handleSize = useCallback(() => { + setWidthSize(window.innerWidth); + setHeightSize(window.innerHeight); + }, []); + + useEffect(() => { + window.addEventListener("resize", throttle(handleSize, 200)); + return () => { + window.removeEventListener("resize", handleSize); + }; + }, [handleSize]); + + return ( + + ); +}; + +export default withSize; diff --git a/src/Container/withThemeData.js b/src/Container/withThemeData.js new file mode 100644 index 0000000..9916943 --- /dev/null +++ b/src/Container/withThemeData.js @@ -0,0 +1,9 @@ +import React, { useContext } from "react"; +import { ThemeContext } from "styled-components"; + +const withThemeData = () => (OriginalComponent) => (props) => { + const theme = useContext(ThemeContext); // 테마 정보 + return ; +}; + +export default withThemeData; diff --git a/src/Container/withTradeListData.js b/src/Container/withTradeListData.js new file mode 100644 index 0000000..20ca56f --- /dev/null +++ b/src/Container/withTradeListData.js @@ -0,0 +1,18 @@ +import React from "react"; +import { useSelector } from "react-redux"; + +const withTradeListData = () => (OriginalComponent) => (props) => { + const selectedMarket = useSelector((state) => state.Coin.selectedMarket); + const selectedTradeListData = useSelector( + (state) => state.Coin.tradeList.data[selectedMarket] + ); + + return ( + + ); +}; + +export default withTradeListData; diff --git a/src/Lib/asyncUtil.js b/src/Lib/asyncUtil.js new file mode 100644 index 0000000..bade725 --- /dev/null +++ b/src/Lib/asyncUtil.js @@ -0,0 +1,290 @@ +import { w3cwebsocket as W3CWebSocket } from "websocket"; +import { call, put, select, flush, delay } from "redux-saga/effects"; +import { startLoading, finishLoading } from "../Reducer/loadingReducer"; +import { throttle } from "lodash"; +import { buffers, eventChannel, END } from "redux-saga"; +import encoding from "text-encoding"; + +// 캔들용 사가 +const createRequestSaga = (type, api, dataMaker) => { + const SUCCESS = `${type}_SUCCESS`; + const ERROR = `${type}_ERROR`; + + return function* (action = {}) { + yield put(startLoading(type)); + try { + const res = yield call(api, action.payload); + const state = yield select(); + + yield put({ type: SUCCESS, payload: dataMaker(res.data, state) }); + yield put(finishLoading(type)); + } catch (e) { + yield put({ type: ERROR, payload: e }); + yield put(finishLoading(type)); + throw e; + } + }; +}; + +// 선택 옵션 변경용 사가 +const createChangeOptionSaga = (type) => { + const SUCCESS = `${type}_SUCCESS`; + + return function* (action = {}) { + yield put({ type: SUCCESS, payload: action.payload }); + }; +}; + +// 웹소켓 연결용 Thunk +const createConnectSocketThunk = (type, connectType, dataMaker) => { + const SUCCESS = `${type}_SUCCESS`; + const ERROR = `${type}_ERROR`; + + return (action = {}) => (dispatch, getState) => { + const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1"); + client.binaryType = "arraybuffer"; + + client.onopen = () => { + client.send( + JSON.stringify([ + { ticket: "downbit-clone" }, + { type: connectType, codes: action.payload }, + ]) + ); + }; + + client.onmessage = (evt) => { + const enc = new encoding.TextDecoder("utf-8"); + const arr = new Uint8Array(evt.data); + const data = JSON.parse(enc.decode(arr)); + const state = getState(); + + dispatch({ type: SUCCESS, payload: dataMaker(data, state) }); + }; + + client.onerror = (e) => { + dispatch({ type: ERROR, payload: e }); + }; + }; +}; + +// 웹소켓 연결용 Thunk +const createConnectSocketThrottleThunk = (type, connectType, dataMaker) => { + const SUCCESS = `${type}_SUCCESS`; + const ERROR = `${type}_ERROR`; + const throttleDispatch = throttle((dispatch, state, data) => { + dispatch({ type: SUCCESS, payload: dataMaker(data, state) }); + }, 500); + + return (action = {}) => (dispatch, getState) => { + const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1"); + client.binaryType = "arraybuffer"; + + client.onopen = () => { + client.send( + JSON.stringify([ + { ticket: "downbit-clone" }, + { type: connectType, codes: action.payload }, + ]) + ); + }; + + client.onmessage = (evt) => { + const enc = new encoding.TextDecoder("utf-8"); + const arr = new Uint8Array(evt.data); + const data = JSON.parse(enc.decode(arr)); + const state = getState(); + + // dispatch({ type: SUCCESS, payload: dataMaker(data, state) }); + throttleDispatch(dispatch, state, data); + }; + + client.onerror = (e) => { + dispatch({ type: ERROR, payload: e }); + }; + }; +}; + +// 소켓 만들기 +const createSocket = () => { + const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1"); + client.binaryType = "arraybuffer"; + + return client; +}; + +// 소켓 연결용 +const connectSocekt = (socket, connectType, action, buffer) => { + return eventChannel((emit) => { + socket.onopen = () => { + socket.send( + JSON.stringify([ + { ticket: "downbit-clone" }, + { type: connectType, codes: action.payload }, + ]) + ); + }; + + socket.onmessage = (evt) => { + const enc = new encoding.TextDecoder("utf-8"); + // const arr = new Uint8Array(evt.data); + const data = JSON.parse(enc.decode(evt.data)); + + emit(data); + }; + + socket.onerror = (evt) => { + emit(evt); + emit(END); + }; + + const unsubscribe = () => { + socket.close(); + }; + + return unsubscribe; + }, buffer || buffers.none()); +}; + +// 웹소켓 연결용 사가 +const createConnectSocketSaga = (type, connectType, dataMaker) => { + const SUCCESS = `${type}_SUCCESS`; + const ERROR = `${type}_ERROR`; + + return function* (action = {}) { + const client = yield call(createSocket); + const clientChannel = yield call( + connectSocekt, + client, + connectType, + action, + buffers.expanding(500) + ); + + try { + while (true) { + const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기 + const state = yield select(); + + if (datas.length) { + const sortedObj = {}; + datas.forEach((data) => { + if (sortedObj[data.code]) { + // 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김 + sortedObj[data.code] = + sortedObj[data.code].timestamp > data.timestamp + ? sortedObj[data.code] + : data; + } else { + sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음 + } + }); + + const sortedData = Object.keys(sortedObj).map( + (data) => sortedObj[data] + ); + + yield put({ type: SUCCESS, payload: dataMaker(sortedData, state) }); + } + yield delay(500); // 500ms 동안 대기 + } + } catch (e) { + yield put({ type: ERROR, payload: e }); + } finally { + clientChannel.close(); // emit(END) 접근시 소켓 닫기 + } + }; +}; + +const reducerUtils = { + success: (state, payload, key) => { + return { + ...state, + [key]: { + data: payload, + error: false, + }, + }; + }, + error: (state, error, key) => ({ + ...state, + [key]: { + ...state[key], + error: error, + }, + }), +}; + +const requestActions = (type, key) => { + const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`]; + return (state, action) => { + switch (action.type) { + case SUCCESS: + return reducerUtils.success(state, action.payload, key); + case ERROR: + return reducerUtils.error(state, action.payload, key); + default: + return state; + } + }; +}; + +const requestInitActions = (type, key) => { + const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`]; + return (state, action) => { + switch (action.type) { + case SUCCESS: + return { + ...state, + candleDay: { + data: action.payload, + error: false, + }, + [key]: { + data: action.payload, + error: false, + }, + }; + case ERROR: + return { + ...state, + candleDay: { + ...state.candleDay, + error: action.payload, + }, + [key]: { + ...state[key], + error: action.payload, + }, + }; + default: + return state; + } + }; +}; + +const changeOptionActions = (type, key) => { + const SUCCESS = `${type}_SUCCESS`; + return (state, action) => { + switch (action.type) { + case SUCCESS: + return { + ...state, + [key]: action.payload, + }; + default: + return state; + } + }; +}; + +export { + createRequestSaga, + createConnectSocketThunk, + createConnectSocketThrottleThunk, + createConnectSocketSaga, + createChangeOptionSaga, + requestActions, + requestInitActions, + changeOptionActions, +}; diff --git a/src/Lib/utils.js b/src/Lib/utils.js new file mode 100644 index 0000000..f19fd54 --- /dev/null +++ b/src/Lib/utils.js @@ -0,0 +1,529 @@ +import moment from "moment-timezone"; +import * as d3 from "d3"; + +const dateFormat = d3.timeParse("%Y-%m-%d %H:%M"); + +const timestampToDatetime = (timeType, timeCount, timestamp) => { + switch (timeType) { + case "minute": + case "minutes": + return ( + moment(timestamp) + .minute( + Math.floor(moment(timestamp).minute() / timeCount) * timeCount + ) + .second(0) + // .tz("Asia/Seoul") + .format("YYYY-MM-DD HH:mm") + ); + case "hour": + case "hours": + return ( + moment(timestamp) + .hour(Math.floor(moment(timestamp).hour() / timeCount) * timeCount) + .minute(0) + .second(0) + // .tz("Asia/Seoul") + .format("YYYY-MM-DD HH:mm") + ); + case "day": + case "days": + return moment(timestamp) + .hour(9) + .minute(0) + .second(0) + .format("YYYY-MM-DD HH:mm"); + case "week": + case "weeks": + return moment(timestamp) + .hour(0) + .minute(0) + .second(0) + .format("YYYY-MM-DD HH:mm"); + default: + return undefined; + } +}; + +const candleDataUtils = { + init: (candles, state) => { + const selectedTimeType = state.Coin.selectedTimeType; + const selectedTimeCount = state.Coin.selectedTimeCount; + + const data = {}; + candles.forEach((candle) => { + data[candle.market] = {}; + data[candle.market]["candles"] = []; + data[candle.market]["candles"].push({ + date: dateFormat( + timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ) + ), + datetime: timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ), + timestamp: candle.timestamp, + open: candle.opening_price, + high: candle.high_price, + low: candle.low_price, + close: candle.trade_price, + volume: candle.acc_trade_volume, + tradePrice: candle.acc_trade_price, + }); + data[candle.market]["tradePrice24Hour"] = candle.acc_trade_price_24h; + data[candle.market]["volume24Hour"] = candle.acc_trade_volume_24h; + data[candle.market]["changeRate24Hour"] = candle.signed_change_rate; + data[candle.market]["changePrice24Hour"] = candle.signed_change_price; + data[candle.market]["highestPrice24Hour"] = candle.high_price; + data[candle.market]["lowestPrice24Hour"] = candle.low_price; + data[candle.market]["highestPrice52Week"] = candle.highest_52_week_price; + data[candle.market]["highestDate52Week"] = candle.highest_52_week_date; + data[candle.market]["lowestPrice52Week"] = candle.lowest_52_week_price; + data[candle.market]["lowestDate52Week"] = candle.lowest_52_week_date; + }); + + return data; + }, + + update: (candle, state) => { + const candleStateDatas = state.Coin.candle.data; + const selectedTimeType = state.Coin.selectedTimeType; + const selectedTimeCount = state.Coin.selectedTimeCount; + + const coinMarket = candle.code; + + const targetCandles = candleStateDatas[coinMarket].candles; + const lastCandle = targetCandles.slice(-1)[0]; + + const date = dateFormat( + timestampToDatetime(selectedTimeType, selectedTimeCount, candle.timestamp) + ); + const datetime = timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ); + const open = lastCandle.open; + const high = + candle.trade_price > lastCandle.high + ? candle.trade_price + : lastCandle.high; + const low = + candle.trade_price < lastCandle.low ? candle.trade_price : lastCandle.low; + const close = candle.trade_price; + + const highestPrice24Hour = candleStateDatas[coinMarket].highestPrice24Hour; + const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour; + + const needUpdate = targetCandles.find( + (candle) => candle.datetime === datetime + ); + const dateChanged = + d3.timeParse("YYYY-MM-DD")(lastCandle.date) !== + d3.timeParse("YYYY-MM-DD")(datetime); + + const newData = { ...candleStateDatas }; // 원본 데이터 보장 + if (needUpdate) { + const volume = needUpdate.volume + candle.trade_volume; + const tradePrice = needUpdate.tradePrice + candle.trade_price; + const updatedCandles = [...targetCandles]; + updatedCandles.pop(); + updatedCandles.push({ + date, + datetime, + timestamp: candle.timestamp, + open, + high, + low, + close, + volume, + tradePrice, + }); + + newData[coinMarket]["candles"] = updatedCandles; + newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h; + newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h; + newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate; + newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price; + newData[coinMarket]["highestPrice24Hour"] = + high > highestPrice24Hour ? high : highestPrice24Hour; + newData[coinMarket]["lowestPrice24Hour"] = + low < lowestPrice24Hour ? low : lowestPrice24Hour; + newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price; + newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date; + newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price; + newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date; + } else { + const volume = candle.trade_volume; + const tradePrice = candle.trade_price; + + newData[coinMarket]["candles"] = [ + ...targetCandles, + { + date, + datetime, + timestamp: candle.timestamp, + dateKst: candle.trade_date_kst, + timeKst: candle.trade_time_kst, + open: close, + high: close, + low: close, + close, + volume, + tradePrice, + }, + ]; + newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h; + newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h; + newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate; + newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price; + newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록 + ? high + : high > highestPrice24Hour + ? high + : highestPrice24Hour; + newData[coinMarket]["lowestPrice24Hour"] = dateChanged + ? low + : low < lowestPrice24Hour + ? low + : lowestPrice24Hour; + newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price; + newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date; + newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price; + newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date; + } + + return newData; + }, + updates: (candles, state) => { + const candleStateDatas = state.Coin.candle.data; + const selectedTimeType = state.Coin.selectedTimeType; + const selectedTimeCount = state.Coin.selectedTimeCount; + + const newData = { ...candleStateDatas }; // 원본 데이터 보장 + + candles.forEach((candle) => { + const coinMarket = candle.code; + + const targetCandles = candleStateDatas[coinMarket].candles; + const lastCandle = targetCandles.slice(-1)[0]; + + const date = dateFormat( + timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ) + ); + const datetime = timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ); + const open = lastCandle.open; + const high = + candle.trade_price > lastCandle.high + ? candle.trade_price + : lastCandle.high; + const low = + candle.trade_price < lastCandle.low + ? candle.trade_price + : lastCandle.low; + const close = candle.trade_price; + + const highestPrice24Hour = + candleStateDatas[coinMarket].highestPrice24Hour; + const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour; + + const needUpdate = targetCandles.find( + (candle) => candle.datetime === datetime + ); + const dateChanged = + d3.timeParse("YYYY-MM-DD")(lastCandle.date) !== + d3.timeParse("YYYY-MM-DD")(datetime); + + if (needUpdate) { + const volume = needUpdate.volume + candle.trade_volume; + const tradePrice = needUpdate.tradePrice + candle.trade_price; + const updatedCandles = [...targetCandles]; + updatedCandles.pop(); + updatedCandles.push({ + date, + datetime, + timestamp: candle.timestamp, + open, + high, + low, + close, + volume, + tradePrice, + }); + + newData[coinMarket]["candles"] = updatedCandles; + newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h; + newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h; + newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate; + newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price; + newData[coinMarket]["highestPrice24Hour"] = + high > highestPrice24Hour ? high : highestPrice24Hour; + newData[coinMarket]["lowestPrice24Hour"] = + low < lowestPrice24Hour ? low : lowestPrice24Hour; + newData[coinMarket]["highestPrice52Week"] = + candle.highest_52_week_price; + newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date; + newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price; + newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date; + } else { + const volume = candle.trade_volume; + const tradePrice = candle.trade_price; + + newData[coinMarket]["candles"] = [ + ...targetCandles, + { + date, + datetime, + timestamp: candle.timestamp, + dateKst: candle.trade_date_kst, + timeKst: candle.trade_time_kst, + open: close, + high: close, + low: close, + close, + volume, + tradePrice, + }, + ]; + newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h; + newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h; + newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate; + newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price; + newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록 + ? high + : high > highestPrice24Hour + ? high + : highestPrice24Hour; + newData[coinMarket]["lowestPrice24Hour"] = dateChanged + ? low + : low < lowestPrice24Hour + ? low + : lowestPrice24Hour; + newData[coinMarket]["highestPrice52Week"] = + candle.highest_52_week_price; + newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date; + newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price; + newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date; + } + }); + + return newData; + }, + oneCoin: (candles, state) => { + const candleStateData = state.Coin.candle.data; + const selectedTimeType = state.Coin.selectedTimeType; + const selectedTimeCount = state.Coin.selectedTimeCount; + const market = candles[0].market; + + const newCandles = candles.map((candle) => { + return { + date: dateFormat( + timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ) + ), + datetime: timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ), + timestamp: candle.timestamp, + open: candle.opening_price, + high: candle.high_price, + low: candle.low_price, + close: candle.trade_price, + volume: candle.candle_acc_trade_volume, + tradePrice: candle.candle_acc_trade_price, + }; + }); + + const newData = { + ...candleStateData, + [market]: { + ...candleStateData[market], + candles: newCandles, + }, + }; + + return newData; + }, + add: (candles, state) => { + const candleStateData = state.Coin.candle.data; + const selectedTimeType = state.Coin.selectedTimeType; + const selectedTimeCount = state.Coin.selectedTimeCount; + const market = candles[0].market; + + const newCandles = candles.reduce((acc, candle) => { + if (!candle.timestamp) return acc; + if ( + candleStateData[market].candles.find( + (stateCandle) => stateCandle.timestamp === candle.timestamp + ) + ) + return acc; + + return [ + ...acc, + { + date: dateFormat( + timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ) + ), + datetime: timestampToDatetime( + selectedTimeType, + selectedTimeCount, + candle.timestamp + ), + timestamp: candle.timestamp, + open: candle.opening_price, + high: candle.high_price, + low: candle.low_price, + close: candle.trade_price, + volume: candle.candle_acc_trade_volume, + tradePrice: candle.candle_acc_trade_price, + }, + ]; + }, []); + + const newData = { + ...candleStateData, + [market]: { + ...candleStateData[market], + candles: [...newCandles, ...candleStateData[market].candles], + }, + }; + + return newData; + }, + marketNames: (names) => { + const data = {}; + names.forEach((name) => { + if (name.market.split("-")[0] !== "KRW") return; + data[name.market] = { + korean: name.korean_name, + english: name.english_name, + }; + }); + + return data; + }, +}; + +const orderbookUtils = { + init: (orderbooks, _) => { + const data = {}; + orderbooks.forEach((orderbook) => { + data[orderbook.market] = { + ...orderbook, + code: orderbook.market, + }; + }); + + return data; + }, + update: (orderbook, state) => { + const orderbookData = state.Coin.orderbook.data; + const market = orderbook.code; + return { + ...orderbookData, + [market]: { + ...orderbook, + market, + }, + }; + }, +}; + +const tradeListUtils = { + init: (tradeLists, state) => { + const tradeListData = state.Coin.tradeList.data; + const market = tradeLists[0].market; + return { + ...tradeListData, + [market]: tradeLists, + }; + }, + update: (tradeList, state) => { + const tradeListData = state.Coin.tradeList.data; + const market = tradeList.code; + if ( + tradeListData[market] && + tradeListData[market].find( + (data) => data.sequential_id === tradeList.sequential_id + ) + ) + return tradeListData; + + // 데이터가 200개까지만 유지되게 만듦 + tradeListData[market] && + tradeListData[market].length > 200 && + tradeListData[market].pop(); + + return tradeListData[market] + ? { + ...tradeListData, + [market]: [tradeList, ...tradeListData[market]], + } + : { + ...tradeListData, + [market]: [tradeList], + }; + }, +}; + +const choHangul = (str) => { + const cho = [ + "ㄱ", + "ㄲ", + "ㄴ", + "ㄷ", + "ㄸ", + "ㄹ", + "ㅁ", + "ㅂ", + "ㅃ", + "ㅅ", + "ㅆ", + "ㅇ", + "ㅈ", + "ㅉ", + "ㅊ", + "ㅋ", + "ㅌ", + "ㅍ", + "ㅎ", + ]; + + return [...str].reduce((acc, cur) => { + const code = cur.charCodeAt(0) - 44032; + return code > -1 && code < 11172 + ? acc + cho[Math.floor(code / 588)] + : acc + cur.charAt(0); + }, ""); +}; + +export { + timestampToDatetime, + candleDataUtils, + orderbookUtils, + tradeListUtils, + choHangul, +}; diff --git a/src/Pages/Main.js b/src/Pages/Main.js new file mode 100644 index 0000000..d1be8d7 --- /dev/null +++ b/src/Pages/Main.js @@ -0,0 +1,121 @@ +import React from "react"; +import styled from "styled-components"; +import withSize from "../Container/withSize"; +import { viewSize } from "../styles/theme"; + +import Header from "../Components/Global/Header"; +import CoinInfoHeader from "../Components/Main/CoinInfoHeader"; +import ChartDataConsole from "../Components/Main/ChartDataConsole"; +import MainChart from "../Components/Main/MainChart"; +import Orderbook from "../Components/Main/Orderbook"; +import OrderInfo from "../Components/Main/OrderInfo"; +import TradeList from "../Components/Main/TradeList"; +import CoinList from "../Components/Main/CoinList"; +import Footer from "../Components/Global/Footer"; + +const St = { + MainContentContainer: styled.div` + display: flex; + justify-content: center; + max-width: 1500px; + margin: 0 auto; + margin-top: 10px; + margin-bottom: 50px; + width: 100%; + height: 100%; + + @media ${({ theme }) => theme.tablet} { + margin-top: 0; + margin-bottom: 0; + } + `, + ChartAndTradeContainer: styled.section` + display: flex; + flex-direction: column; + align-items: center; + width: 95%; + max-width: 950px; + + @media ${(props) => (props.isRootURL ? props.theme.tablet : true)} { + display: none; + } + `, + HiddenH2: styled.h2` + position: absolute; + width: 1px; + height: 1px; + clip: rect(0, 0); + clip-path: polygon(0, 0); + overflow: hidden; + text-indent: -9999px; + `, + MainChartContainer: styled.div` + width: 100%; + height: 500; + `, + TradeInfoContainer: styled.div` + display: flex; + width: 100%; + margin-top: 10px; + @media ${({ theme }) => theme.mobileM} { + margin-top: 0; + } + `, + TradeOrderContainer: styled.div` + display: flex; + flex-direction: column; + width: 55%; + min-width: 180px; + margin-left: 10px; + @media ${({ theme }) => theme.mobileM} { + margin-left: 0; + border: 2px solid ${({ theme }) => theme.lightGray1}; + /* border-top: 1px solid ${({ theme }) => theme.lightGray1}; + border-bottom: 1px solid ${({ theme }) => theme.lightGray1}; + border-left: 1px solid ${({ theme }) => theme.lightGray1}; */ + } + `, +}; + +const Main = ({ match, widthSize, heightSize }) => { + const isRootURL = match.path === "/"; + + return ( + <> +
+ + { + // 차트 및 주문 관련 뷰는 메인 페이지이면서 tablet 사이즈보다 크거나, 메인 페이지가 아닌 경우에만 그린다 + ((isRootURL && widthSize > viewSize.tablet) || !isRootURL) && ( + + 차트 및 주문 정보 창 + + + + + + + + + + + + ) + } + { + // 코인 리스트 뷰는 메인 페이지이거나, 메인 페이지가 아니면서 tablet 사이즈보다 큰 경우에만 그린다 + (isRootURL || (!isRootURL && widthSize > viewSize.tablet)) && ( + + ) + } + +