commit f6adf0a3e0a02eb4dc5a552f986c71a093a68750 Author: mindol1004 Date: Thu Feb 10 13:07:45 2022 +0900 commit 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 0000000..8c83dd9 Binary files /dev/null and b/public/blueLogo.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..d6c3e97 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..be059ba --- /dev/null +++ b/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + 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 0000000..e7afbac Binary files /dev/null and b/public/whiteLogo.png differ diff --git a/src/Api/api.js b/src/Api/api.js new file mode 100644 index 0000000..d3d971f --- /dev/null +++ b/src/Api/api.js @@ -0,0 +1,60 @@ +import axios from "axios"; + +export const coinApi = { + getMarketCodes: () => + 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)) && ( + + ) + } + +