diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index 1582929aa..bf0e15056 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -50,11 +50,7 @@ "ENABLED": true, "TX_PER_SECOND_SAMPLE_PERIOD": 150 }, - "BISQ_BLOCKS": { - "ENABLED": false, - "DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db/json" - }, - "BISQ_MARKETS": { + "BISQ": { "ENABLED": false, "DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db" } diff --git a/backend/src/api/bisq/bisq.ts b/backend/src/api/bisq/bisq.ts index cedb97faa..8d5cf83b7 100644 --- a/backend/src/api/bisq/bisq.ts +++ b/backend/src/api/bisq/bisq.ts @@ -8,7 +8,7 @@ import { StaticPool } from 'node-worker-threads-pool'; import logger from '../../logger'; class Bisq { - private static BLOCKS_JSON_FILE_PATH = config.BISQ_BLOCKS.DATA_PATH + '/all/blocks.json'; + private static BLOCKS_JSON_FILE_PATH = config.BISQ.DATA_PATH + '/json/all/blocks.json'; private latestBlockHeight = 0; private blocks: BisqBlock[] = []; private transactions: BisqTransaction[] = []; @@ -98,7 +98,7 @@ class Bisq { this.topDirectoryWatcher.close(); } let fsWait: NodeJS.Timeout | null = null; - this.topDirectoryWatcher = fs.watch(config.BISQ_BLOCKS.DATA_PATH, () => { + this.topDirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json', () => { if (fsWait) { clearTimeout(fsWait); } @@ -126,7 +126,7 @@ class Bisq { return; } let fsWait: NodeJS.Timeout | null = null; - this.subdirectoryWatcher = fs.watch(config.BISQ_BLOCKS.DATA_PATH + '/all', () => { + this.subdirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json/all', () => { if (fsWait) { clearTimeout(fsWait); } diff --git a/backend/src/api/bisq/markets-api.ts b/backend/src/api/bisq/markets-api.ts index acc8093c7..558c390a0 100644 --- a/backend/src/api/bisq/markets-api.ts +++ b/backend/src/api/bisq/markets-api.ts @@ -457,6 +457,30 @@ class BisqMarketsApi { } } + getVolumesByTime(time: number): MarketVolume[] { + const timestamp_from = new Date().getTime() / 1000 - time; + const timestamp_to = new Date().getTime() / 1000; + + const trades = this.getTradesByCriteria(undefined, timestamp_to, timestamp_from, + undefined, undefined, undefined, 'asc', Number.MAX_SAFE_INTEGER); + + const markets: any = {}; + + for (const trade of trades) { + if (!markets[trade._market]) { + markets[trade._market] = { + 'volume': 0, + 'num_trades': 0, + }; + } + + markets[trade._market]['volume'] += this.fiatCurrenciesIndexed[trade.currency] ? trade._tradeAmount : trade._tradeVolume; + markets[trade._market]['num_trades']++; + } + + return markets; + } + private getTradesSummarized(trades: TradesData[], timestamp_from: number, interval?: string): SummarizedIntervals { const intervals: any = {}; const intervals_prices: any = {}; diff --git a/backend/src/api/bisq/markets.ts b/backend/src/api/bisq/markets.ts index 1d6169366..beac44851 100644 --- a/backend/src/api/bisq/markets.ts +++ b/backend/src/api/bisq/markets.ts @@ -6,7 +6,7 @@ import logger from '../../logger'; class Bisq { private static FOLDER_WATCH_CHANGE_DETECTION_DEBOUNCE = 4000; - private static MARKET_JSON_PATH = config.BISQ_MARKETS.DATA_PATH; + private static MARKET_JSON_PATH = config.BISQ.DATA_PATH; private static MARKET_JSON_FILE_PATHS = { activeCryptoCurrency: '/active_crypto_currency_list.json', activeFiatCurrency: '/active_fiat_currency_list.json', diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 2ac88fe42..74a06a588 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -96,6 +96,14 @@ class WebsocketHandler { client['track-donation'] = parsedMessage['track-donation']; } + if (parsedMessage['track-bisq-market']) { + if (/^[a-z]{3}_[a-z]{3}$/.test(parsedMessage['track-bisq-market'])) { + client['track-bisq-market'] = parsedMessage['track-bisq-market']; + } else { + client['track-bisq-market'] = null; + } + } + if (Object.keys(response).length) { client.send(JSON.stringify(response)); } diff --git a/backend/src/config.ts b/backend/src/config.ts index d5828f922..4a2e4f588 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -52,11 +52,7 @@ interface IConfig { ENABLED: boolean; TX_PER_SECOND_SAMPLE_PERIOD: number; }; - BISQ_BLOCKS: { - ENABLED: boolean; - DATA_PATH: string; - }; - BISQ_MARKETS: { + BISQ: { ENABLED: boolean; DATA_PATH: string; }; @@ -114,11 +110,7 @@ const defaults: IConfig = { 'ENABLED': true, 'TX_PER_SECOND_SAMPLE_PERIOD': 150 }, - 'BISQ_BLOCKS': { - 'ENABLED': false, - 'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db/json' - }, - 'BISQ_MARKETS': { + 'BISQ': { 'ENABLED': false, 'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db' }, @@ -133,8 +125,7 @@ class Config implements IConfig { DATABASE: IConfig['DATABASE']; SYSLOG: IConfig['SYSLOG']; STATISTICS: IConfig['STATISTICS']; - BISQ_BLOCKS: IConfig['BISQ_BLOCKS']; - BISQ_MARKETS: IConfig['BISQ_MARKETS']; + BISQ: IConfig['BISQ']; constructor() { const configs = this.merge(configFile, defaults); @@ -146,8 +137,7 @@ class Config implements IConfig { this.DATABASE = configs.DATABASE; this.SYSLOG = configs.SYSLOG; this.STATISTICS = configs.STATISTICS; - this.BISQ_BLOCKS = configs.BISQ_BLOCKS; - this.BISQ_MARKETS = configs.BISQ_MARKETS; + this.BISQ = configs.BISQ; } merge = (...objects: object[]): IConfig => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 916f47e12..ec9c53cab 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -90,13 +90,10 @@ class Server { this.setUpHttpApiRoutes(); this.runMainUpdateLoop(); - if (config.BISQ_BLOCKS.ENABLED) { + if (config.BISQ.ENABLED) { bisq.startBisqService(); bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price)); blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq)); - } - - if (config.BISQ_MARKETS.ENABLED) { bisqMarkets.startBisqService(); } @@ -210,7 +207,7 @@ class Server { ; } - if (config.BISQ_BLOCKS.ENABLED) { + if (config.BISQ.ENABLED) { this.app .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/stats', routes.getBisqStats) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/tx/:txId', routes.getBisqTransaction) @@ -219,11 +216,6 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/:index/:length', routes.getBisqBlocks) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/address/:address', routes.getBisqAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/txs/:index/:length', routes.getBisqTransactions) - ; - } - - if (config.BISQ_MARKETS.ENABLED) { - this.app .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/currencies', routes.getBisqMarketCurrencies.bind(routes)) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/depth', routes.getBisqMarketDepth.bind(routes)) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/hloc', routes.getBisqMarketHloc.bind(routes)) @@ -232,6 +224,7 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/ticker', routes.getBisqMarketTicker.bind(routes)) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/trades', routes.getBisqMarketTrades.bind(routes)) .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes', routes.getBisqMarketVolumes.bind(routes)) + .get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes/7d', routes.getBisqMarketVolumes7d.bind(routes)) ; } diff --git a/backend/src/logger.ts b/backend/src/logger.ts index f14af0104..4e8c5ea11 100644 --- a/backend/src/logger.ts +++ b/backend/src/logger.ts @@ -73,7 +73,7 @@ class Logger { } private getNetwork(): string { - if (config.BISQ_BLOCKS.ENABLED) { + if (config.BISQ.ENABLED) { return 'bisq'; } if (config.MEMPOOL.NETWORK && config.MEMPOOL.NETWORK !== 'mainnet') { diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 66a486f94..fae78ef9b 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -144,6 +144,7 @@ export interface WebsocketResponse { 'track-tx': string; 'track-address': string; 'watch-mempool': boolean; + 'track-bisq-market': string; } export interface VbytesPerSecond { diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 399c3b8ab..7d4bd844e 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -426,6 +426,15 @@ class Routes { } } + public getBisqMarketVolumes7d(req: Request, res: Response) { + const result = bisqMarket.getVolumesByTime(604800); + if (result) { + res.json(result); + } else { + res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketVolumes7d error')); + } + } + private parseRequestParameters(requestParams: object, params: RequiredSpec): { [name: string]: any; } { const final = {}; for (const i in params) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 80da5b578..274fad20f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-frontend", - "version": "2.2.0-dev", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-frontend", - "version": "2.2.0-dev", + "version": "2.0.0", "license": "GNU Affero General Public License v3.0", "dependencies": { "@angular/animations": "~11.2.8", @@ -24,7 +24,7 @@ "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@mempool/chartist": "^0.11.4", - "@mempool/mempool.js": "^2.2.0", + "@mempool/mempool-js": "^2.2.1", "@ng-bootstrap/ng-bootstrap": "^7.0.0", "@nguniversal/express-engine": "11.2.1", "@types/qrcode": "^1.3.4", @@ -33,6 +33,7 @@ "clipboard": "^2.0.4", "domino": "^2.1.6", "express": "^4.17.1", + "lightweight-charts": "^3.3.0", "ngx-bootrap-multiselect": "^2.0.0", "ngx-infinite-scroll": "^10.0.1", "qrcode": "^1.4.4", @@ -2192,10 +2193,10 @@ "node": ">=4.6.0" } }, - "node_modules/@mempool/mempool.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@mempool/mempool.js/-/mempool.js-2.2.0.tgz", - "integrity": "sha512-emBbMmLQd/x+4DQVno9zq4nGA9rOMAinYTOzI4s5lLVBzGL8++8JpkXouH05HC5wOHA0VpRhBX7X+lOX/o0oAA==", + "node_modules/@mempool/mempool-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mempool/mempool-js/-/mempool-js-2.2.1.tgz", + "integrity": "sha512-zMoqXx+PgL59iQn4fEPgvYgxBi+kNNXVU99v0E8kxQXJkX9KzSVDPFlrHoaodXzGHGB2cZPMlK35okK7+LsYiw==", "dependencies": { "axios": "^0.21.1", "ws": "^7.4.3" @@ -7630,6 +7631,11 @@ "node": ">=0.4.0" } }, + "node_modules/fancy-canvas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz", + "integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10529,6 +10535,14 @@ "immediate": "~3.0.5" } }, + "node_modules/lightweight-charts": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.3.0.tgz", + "integrity": "sha512-W5jeBrXcHG8eHnIQ0L2CB9TLkrrsjNPlQq5SICPO8PnJ3dJ8jZkLCAwemZ7Ym7ZGCfKCz6ow1EPbyzNYxblnkw==", + "dependencies": { + "fancy-canvas": "0.2.2" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -22186,10 +22200,10 @@ "resolved": "https://registry.npmjs.org/@mempool/chartist/-/chartist-0.11.4.tgz", "integrity": "sha512-wSemsw2NIWS7/SHxjDe9upSdUETxNRebY0ByaJzcONKUzJSUzMuSNmKEdD3kr/g02H++JvsXR2znLC6tYEAbPA==" }, - "@mempool/mempool.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@mempool/mempool.js/-/mempool.js-2.2.0.tgz", - "integrity": "sha512-emBbMmLQd/x+4DQVno9zq4nGA9rOMAinYTOzI4s5lLVBzGL8++8JpkXouH05HC5wOHA0VpRhBX7X+lOX/o0oAA==", + "@mempool/mempool-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mempool/mempool-js/-/mempool-js-2.2.1.tgz", + "integrity": "sha512-zMoqXx+PgL59iQn4fEPgvYgxBi+kNNXVU99v0E8kxQXJkX9KzSVDPFlrHoaodXzGHGB2cZPMlK35okK7+LsYiw==", "requires": { "axios": "^0.21.1", "ws": "^7.4.3" @@ -26734,6 +26748,11 @@ "object-keys": "^1.0.6" } }, + "fancy-canvas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz", + "integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -28995,6 +29014,14 @@ "immediate": "~3.0.5" } }, + "lightweight-charts": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.3.0.tgz", + "integrity": "sha512-W5jeBrXcHG8eHnIQ0L2CB9TLkrrsjNPlQq5SICPO8PnJ3dJ8jZkLCAwemZ7Ym7ZGCfKCz6ow1EPbyzNYxblnkw==", + "requires": { + "fancy-canvas": "0.2.2" + } + }, "limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 064e8f98a..1b39c6435 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,6 +66,7 @@ "clipboard": "^2.0.4", "domino": "^2.1.6", "express": "^4.17.1", + "lightweight-charts": "^3.3.0", "ngx-bootrap-multiselect": "^2.0.0", "ngx-infinite-scroll": "^10.0.1", "qrcode": "^1.4.4", diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index b76526582..d9f020593 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -16,8 +16,9 @@ import { DashboardComponent } from './dashboard/dashboard.component'; import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component'; import { ApiDocsComponent } from './components/api-docs/api-docs.component'; import { TermsOfServiceComponent } from './components/terms-of-service/terms-of-service.component'; +import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; -const routes: Routes = [ +let routes: Routes = [ { path: '', component: MasterPageComponent, @@ -283,6 +284,18 @@ const routes: Routes = [ }, ]; +const browserWindow = window || {}; +// @ts-ignore +const browserWindowEnv = browserWindow.__env || {}; + +if (browserWindowEnv && browserWindowEnv.OFFICIAL_BISQ_MARKETS) { + routes = [{ + path: '', + component: BisqMasterPageComponent, + loadChildren: () => import('./bisq/bisq.module').then(m => m.BisqModule) + }]; +} + @NgModule({ imports: [RouterModule.forRoot(routes, { initialNavigation: 'enabled' diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 71d73ab97..3afa8a32c 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -21,6 +21,7 @@ import { WebsocketService } from './services/websocket.service'; import { AddressLabelsComponent } from './components/address-labels/address-labels.component'; import { MempoolBlocksComponent } from './components/mempool-blocks/mempool-blocks.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; +import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { AboutComponent } from './components/about/about.component'; import { TelevisionComponent } from './components/television/television.component'; import { StatisticsComponent } from './components/statistics/statistics.component'; @@ -44,7 +45,7 @@ import { FeesBoxComponent } from './components/fees-box/fees-box.component'; import { DashboardComponent } from './dashboard/dashboard.component'; import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faAngleDown, faAngleUp, faBolt, faChartArea, faCogs, faCubes, faDatabase, faExchangeAlt, faInfoCircle, - faLink, faList, faSearch, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faAngleDoubleUp } from '@fortawesome/free-solid-svg-icons'; + faLink, faList, faSearch, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faAngleDoubleUp, faSort, faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { ApiDocsComponent } from './components/api-docs/api-docs.component'; import { TermsOfServiceComponent } from './components/terms-of-service/terms-of-service.component'; import { StorageService } from './services/storage.service'; @@ -55,6 +56,7 @@ import { HttpCacheInterceptor } from './services/http-cache.interceptor'; AppComponent, AboutComponent, MasterPageComponent, + BisqMasterPageComponent, TelevisionComponent, BlockchainComponent, StartComponent, @@ -127,5 +129,6 @@ export class AppModule { library.addIcons(faExchangeAlt); library.addIcons(faAngleDoubleUp); library.addIcons(faAngleDoubleDown); + library.addIcons(faChevronDown); } } diff --git a/frontend/src/app/bisq/bisq-address/bisq-address.component.ts b/frontend/src/app/bisq/bisq-address/bisq-address.component.ts index aded75d8e..75225d9de 100644 --- a/frontend/src/app/bisq/bisq-address/bisq-address.component.ts +++ b/frontend/src/app/bisq/bisq-address/bisq-address.component.ts @@ -5,6 +5,7 @@ import { ParamMap, ActivatedRoute } from '@angular/router'; import { Subscription, of } from 'rxjs'; import { BisqTransaction } from '../bisq.interfaces'; import { BisqApiService } from '../bisq-api.service'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-address', @@ -22,12 +23,15 @@ export class BisqAddressComponent implements OnInit, OnDestroy { totalSent = 0; constructor( + private websocketService: WebsocketService, private route: ActivatedRoute, private seoService: SeoService, private bisqApiService: BisqApiService, ) { } ngOnInit() { + this.websocketService.want(['blocks']); + this.mainSubscription = this.route.paramMap .pipe( switchMap((params: ParamMap) => { diff --git a/frontend/src/app/bisq/bisq-api.service.ts b/frontend/src/app/bisq/bisq-api.service.ts index 0f9eb0868..2d8994d86 100644 --- a/frontend/src/app/bisq/bisq-api.service.ts +++ b/frontend/src/app/bisq/bisq-api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpResponse, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { BisqTransaction, BisqBlock, BisqStats } from './bisq.interfaces'; +import { BisqTransaction, BisqBlock, BisqStats, MarketVolume, Trade, Markets, Tickers, Offers, Currencies, HighLowOpenClose, SummarizedInterval } from './bisq.interfaces'; const API_BASE_URL = '/bisq/api'; @@ -42,4 +42,37 @@ export class BisqApiService { getAddress$(address: string): Observable { return this.httpClient.get(API_BASE_URL + '/address/' + address); } + + getMarkets$(): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/markets'); + } + + getMarketsTicker$(): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/ticker'); + } + + getMarketsCurrencies$(): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/currencies'); + } + + getMarketsHloc$(market: string, interval: 'minute' | 'half_hour' | 'hour' | 'half_day' | 'day' + | 'week' | 'month' | 'year' | 'auto'): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/hloc?market=' + market + '&interval=' + interval); + } + + getMarketOffers$(market: string): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/offers?market=' + market); + } + + getMarketTrades$(market: string): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/trades?market=' + market); + } + + getMarketVolumesByTime$(period: string): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/volumes/' + period); + } + + getAllVolumesDay$(): Observable { + return this.httpClient.get(API_BASE_URL + '/markets/volumes?interval=week'); + } } diff --git a/frontend/src/app/bisq/bisq-block/bisq-block.component.ts b/frontend/src/app/bisq/bisq-block/bisq-block.component.ts index d1cc3eeca..2510ee67f 100644 --- a/frontend/src/app/bisq/bisq-block/bisq-block.component.ts +++ b/frontend/src/app/bisq/bisq-block/bisq-block.component.ts @@ -8,6 +8,7 @@ import { switchMap, catchError } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; import { ElectrsApiService } from 'src/app/services/electrs-api.service'; import { HttpErrorResponse } from '@angular/common/http'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-block', @@ -23,6 +24,7 @@ export class BisqBlockComponent implements OnInit, OnDestroy { error: HttpErrorResponse | null; constructor( + private websocketService: WebsocketService, private bisqApiService: BisqApiService, private route: ActivatedRoute, private seoService: SeoService, @@ -32,6 +34,8 @@ export class BisqBlockComponent implements OnInit, OnDestroy { ) { } ngOnInit(): void { + this.websocketService.want(['blocks']); + this.subscription = this.route.paramMap .pipe( switchMap((params: ParamMap) => { diff --git a/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.ts b/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.ts index ee4108ae5..6ca90611e 100644 --- a/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.ts +++ b/frontend/src/app/bisq/bisq-blocks/bisq-blocks.component.ts @@ -5,6 +5,7 @@ import { Observable } from 'rxjs'; import { BisqBlock, BisqOutput, BisqTransaction } from '../bisq.interfaces'; import { SeoService } from 'src/app/services/seo.service'; import { ActivatedRoute, Router } from '@angular/router'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-blocks', @@ -25,6 +26,7 @@ export class BisqBlocksComponent implements OnInit { paginationMaxSize = 10; constructor( + private websocketService: WebsocketService, private bisqApiService: BisqApiService, private seoService: SeoService, private route: ActivatedRoute, @@ -32,6 +34,7 @@ export class BisqBlocksComponent implements OnInit { ) { } ngOnInit(): void { + this.websocketService.want(['blocks']); this.seoService.setTitle($localize`:@@8a7b4bd44c0ac71b2e72de0398b303257f7d2f54:Blocks`); this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10); this.loadingItems = Array(this.itemsPerPage); diff --git a/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.html b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.html new file mode 100644 index 000000000..3cd09744c --- /dev/null +++ b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.html @@ -0,0 +1,60 @@ +
+ +

Bisq trading volume

+ +
+ +
+
+
+
+ + + +
+ +

+ +

+ Markets + Bitcoin markets +

+ + + + + + + + + + + + + + + + + +
Currency PriceVolume (7d) Trades (7d)
{{ ticker.name }}) + + + {{ ticker.last | currency: ticker.market.rsymbol }} + + + + {{ ticker.volume?.num_trades }}
+ +

+ +

Latest trades

+ + +
+
+ + + + + + diff --git a/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.scss b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.scss new file mode 100644 index 000000000..55ad9d40a --- /dev/null +++ b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.scss @@ -0,0 +1,10 @@ +#volumeHolder { + height: 500px; + background-color: #000; +} + +.loadingVolumes { + position: relative; + top: 50%; + z-index: 100; +} \ No newline at end of file diff --git a/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.ts b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.ts new file mode 100644 index 000000000..defac305c --- /dev/null +++ b/frontend/src/app/bisq/bisq-dashboard/bisq-dashboard.component.ts @@ -0,0 +1,131 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable, combineLatest, BehaviorSubject, of } from 'rxjs'; +import { map, share, switchMap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { StateService } from 'src/app/services/state.service'; +import { WebsocketService } from 'src/app/services/websocket.service'; +import { BisqApiService } from '../bisq-api.service'; +import { Trade } from '../bisq.interfaces'; + +@Component({ + selector: 'app-bisq-dashboard', + templateUrl: './bisq-dashboard.component.html', + styleUrls: ['./bisq-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BisqDashboardComponent implements OnInit { + tickers$: Observable; + volumes$: Observable; + trades$: Observable; + sort$ = new BehaviorSubject('trades'); + + allowCryptoCoins = ['usdc', 'l-btc', 'bsq']; + + constructor( + private websocketService: WebsocketService, + private bisqApiService: BisqApiService, + public stateService: StateService, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.seoService.setTitle(`Markets`); + this.websocketService.want(['blocks']); + + this.volumes$ = this.bisqApiService.getAllVolumesDay$() + .pipe( + map((volumes) => { + const data = volumes.map((volume) => { + return { + time: volume.period_start, + value: volume.volume, + }; + }); + + const linesData = volumes.map((volume) => { + return { + time: volume.period_start, + value: volume.num_trades, + }; + }); + + return { + data: data, + linesData: linesData, + }; + }) + ); + + const getMarkets = this.bisqApiService.getMarkets$().pipe(share()); + + this.tickers$ = combineLatest([ + this.bisqApiService.getMarketsTicker$(), + getMarkets, + this.bisqApiService.getMarketVolumesByTime$('7d'), + ]) + .pipe( + map(([tickers, markets, volumes]) => { + + const newTickers = []; + for (const t in tickers) { + + if (!this.stateService.env.OFFICIAL_BISQ_MARKETS) { + const pair = t.split('_'); + if (pair[1] === 'btc' && this.allowCryptoCoins.indexOf(pair[0]) === -1) { + continue; + } + } + + const mappedTicker: any = tickers[t]; + + mappedTicker.pair_url = t; + mappedTicker.pair = t.replace('_', '/').toUpperCase(); + mappedTicker.market = markets[t]; + mappedTicker.volume = volumes[t]; + mappedTicker.name = `${mappedTicker.market.rtype === 'crypto' ? mappedTicker.market.lname : mappedTicker.market.rname} (${mappedTicker.market.rtype === 'crypto' ? mappedTicker.market.lsymbol : mappedTicker.market.rsymbol}`; + newTickers.push(mappedTicker); + } + return newTickers; + }), + switchMap((tickers) => combineLatest([this.sort$, of(tickers)])), + map(([sort, tickers]) => { + if (sort === 'trades') { + tickers.sort((a, b) => (b.volume && b.volume.num_trades || 0) - (a.volume && a.volume.num_trades || 0)); + } else if (sort === 'volumes') { + tickers.sort((a, b) => (b.volume && b.volume.volume || 0) - (a.volume && a.volume.volume || 0)); + } else if (sort === 'name') { + tickers.sort((a, b) => a.name.localeCompare(b.name)); + } + return tickers; + }) + ); + + this.trades$ = combineLatest([ + this.bisqApiService.getMarketTrades$('all'), + getMarkets, + ]) + .pipe( + map(([trades, markets]) => { + if (!this.stateService.env.OFFICIAL_BISQ_MARKETS) { + trades = trades.filter((trade) => { + const pair = trade.market.split('_'); + return !(pair[1] === 'btc' && this.allowCryptoCoins.indexOf(pair[0]) === -1); + }); + } + return trades.map((trade => { + trade._market = markets[trade.market]; + return trade; + })); + }) + ); + } + + trackByFn(index: number) { + return index; + } + + sort(by: string) { + this.sort$.next(by); + } + +} diff --git a/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.html b/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.html deleted file mode 100644 index 0680b43f9..000000000 --- a/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.ts b/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.ts deleted file mode 100644 index bb9a37809..000000000 --- a/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { WebsocketService } from 'src/app/services/websocket.service'; - -@Component({ - selector: 'app-bisq-explorer', - templateUrl: './bisq-explorer.component.html', - styleUrls: ['./bisq-explorer.component.scss'] -}) -export class BisqExplorerComponent implements OnInit { - - constructor( - private websocketService: WebsocketService, - ) { } - - ngOnInit(): void { - this.websocketService.want(['blocks']); - } -} diff --git a/frontend/src/app/bisq/bisq-market/bisq-market.component.html b/frontend/src/app/bisq/bisq-market/bisq-market.component.html new file mode 100644 index 000000000..e1c5b53ac --- /dev/null +++ b/frontend/src/app/bisq/bisq-market/bisq-market.component.html @@ -0,0 +1,113 @@ +
+ + + + +

{{ currency.market.rtype === 'crypto' ? currency.market.lname : currency.market.rname }} - {{ currency.pair }}

+
+ + {{ hlocData.hloc[hlocData.hloc.length - 1].close | currency: currency.market.rsymbol }} + {{ hlocData.hloc[hlocData.hloc.length - 1].close | number: '1.' + currency.market.rprecision + '-' + currency.market.rprecision }} {{ currency.market.rsymbol }} + +
+ +
+
+ + + + + + + +
+
+ +
+ +
+
+
+
+ +
+ +
+ + +
+ + +
+
+ +

+ + +

Latest trades

+ + +
+ +
+
+ +
+ + + +
+

+ Buy offers + Sell offers +

+ + + + + + + + + + + + + + +
Price
+ {{ offer.price | currency: market.rsymbol }} + {{ offer.price | number: '1.2-' + market.rprecision }} {{ market.rsymbol }} + + {{ offer.amount | currency: market.rsymbol }} + {{ offer.amount | number: '1.2-' + market.lprecision }} {{ market.lsymbol }} + + {{ offer.volume | currency: market.rsymbol }} + {{ offer.volume | number: '1.2-' + market.rprecision }} {{ market.rsymbol }} +
+
+
+ + +
+
+
+
+
+
+ +Amount ({{ i }}) diff --git a/frontend/src/app/bisq/bisq-market/bisq-market.component.scss b/frontend/src/app/bisq/bisq-market/bisq-market.component.scss new file mode 100644 index 000000000..2a7beb167 --- /dev/null +++ b/frontend/src/app/bisq/bisq-market/bisq-market.component.scss @@ -0,0 +1,14 @@ +.priceheader { + font-size: 24px; +} + +.loadingChart { + z-index: 100; + position: absolute; + top: 50%; + left: 50%; +} + +#graphHolder { + height: 550px; +} diff --git a/frontend/src/app/bisq/bisq-market/bisq-market.component.ts b/frontend/src/app/bisq/bisq-market/bisq-market.component.ts new file mode 100644 index 000000000..90832122f --- /dev/null +++ b/frontend/src/app/bisq/bisq-market/bisq-market.component.ts @@ -0,0 +1,158 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { combineLatest, merge, Observable, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { WebsocketService } from 'src/app/services/websocket.service'; +import { BisqApiService } from '../bisq-api.service'; +import { OffersMarket, Trade } from '../bisq.interfaces'; + +@Component({ + selector: 'app-bisq-market', + templateUrl: './bisq-market.component.html', + styleUrls: ['./bisq-market.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BisqMarketComponent implements OnInit, OnDestroy { + hlocData$: Observable; + currency$: Observable; + offers$: Observable; + trades$: Observable; + radioGroupForm: FormGroup; + defaultInterval = 'day'; + + isLoadingGraph = false; + + constructor( + private websocketService: WebsocketService, + private route: ActivatedRoute, + private bisqApiService: BisqApiService, + private formBuilder: FormBuilder, + private seoService: SeoService, + private router: Router, + ) { } + + ngOnInit(): void { + this.radioGroupForm = this.formBuilder.group({ + interval: [this.defaultInterval], + }); + + if (['half_hour', 'hour', 'half_day', 'day', 'week', 'month', 'year', 'auto'].indexOf(this.route.snapshot.fragment) > -1) { + this.radioGroupForm.controls.interval.setValue(this.route.snapshot.fragment, { emitEvent: false }); + } + + this.currency$ = this.bisqApiService.getMarkets$() + .pipe( + switchMap((markets) => combineLatest([of(markets), this.route.paramMap])), + map(([markets, routeParams]) => { + const pair = routeParams.get('pair'); + const pairUpperCase = pair.replace('_', '/').toUpperCase(); + this.seoService.setTitle(`Bisq market: ${pairUpperCase}`); + + return { + pair: pairUpperCase, + market: markets[pair], + }; + }) + ); + + this.trades$ = this.route.paramMap + .pipe( + map(routeParams => routeParams.get('pair')), + switchMap((marketPair) => this.bisqApiService.getMarketTrades$(marketPair)), + ); + + this.offers$ = this.route.paramMap + .pipe( + map(routeParams => routeParams.get('pair')), + switchMap((marketPair) => this.bisqApiService.getMarketOffers$(marketPair)), + map((offers) => offers[Object.keys(offers)[0]]) + ); + + this.hlocData$ = combineLatest([ + this.route.paramMap, + merge(this.radioGroupForm.get('interval').valueChanges, of(this.radioGroupForm.get('interval').value)), + ]) + .pipe( + switchMap(([routeParams, interval]) => { + this.isLoadingGraph = true; + const pair = routeParams.get('pair'); + return this.bisqApiService.getMarketsHloc$(pair, interval); + }), + map((hlocData) => { + this.isLoadingGraph = false; + + hlocData = hlocData.map((h) => { + h.time = h.period_start; + return h; + }); + + const hlocVolume = hlocData.map((h) => { + return { + time: h.time, + value: h.volume_right, + color: h.close > h.avg ? 'rgba(0, 41, 74, 0.7)' : 'rgba(0, 41, 74, 1)', + }; + }); + + // Add whitespace + if (hlocData.length > 1) { + const newHloc = []; + newHloc.push(hlocData[0]); + + const period = this.getUnixTimestampFromInterval(this.radioGroupForm.get('interval').value); // temp + let periods = 0; + const startingDate = hlocData[0].period_start; + let index = 1; + while (true) { + periods++; + if (hlocData[index].period_start > startingDate + period * periods) { + newHloc.push({ + time: startingDate + period * periods, + }); + } else { + newHloc.push(hlocData[index]); + index++; + if (!hlocData[index]) { + break; + } + } + } + hlocData = newHloc; + } + + return { + hloc: hlocData, + volume: hlocVolume, + }; + }), + ); + } + + setFragment(fragment: string) { + this.router.navigate([], { + relativeTo: this.route, + queryParamsHandling: 'merge', + fragment: fragment + }); + } + + ngOnDestroy(): void { + this.websocketService.stopTrackingBisqMarket(); + } + + getUnixTimestampFromInterval(interval: string): number { + switch (interval) { + case 'minute': return 60; + case 'half_hour': return 1800; + case 'hour': return 3600; + case 'half_day': return 43200; + case 'day': return 86400; + case 'week': return 604800; + case 'month': return 2592000; + case 'year': return 31579200; + } + } + +} diff --git a/frontend/src/app/bisq/bisq-stats/bisq-stats.component.ts b/frontend/src/app/bisq/bisq-stats/bisq-stats.component.ts index 53f2051ae..11064b5fe 100644 --- a/frontend/src/app/bisq/bisq-stats/bisq-stats.component.ts +++ b/frontend/src/app/bisq/bisq-stats/bisq-stats.component.ts @@ -3,6 +3,7 @@ import { BisqApiService } from '../bisq-api.service'; import { BisqStats } from '../bisq.interfaces'; import { SeoService } from 'src/app/services/seo.service'; import { StateService } from 'src/app/services/state.service'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-stats', @@ -15,12 +16,15 @@ export class BisqStatsComponent implements OnInit { price: number; constructor( + private websocketService: WebsocketService, private bisqApiService: BisqApiService, private seoService: SeoService, private stateService: StateService, ) { } ngOnInit() { + this.websocketService.want(['blocks']); + this.seoService.setTitle($localize`:@@2a30a4cdb123a03facc5ab8c5b3e6d8b8dbbc3d4:BSQ statistics`); this.stateService.bsqPrice$ .subscribe((bsqPrice) => { diff --git a/frontend/src/app/bisq/bisq-trades/bisq-trades.component.html b/frontend/src/app/bisq/bisq-trades/bisq-trades.component.html new file mode 100644 index 000000000..bf783ffa2 --- /dev/null +++ b/frontend/src/app/bisq/bisq-trades/bisq-trades.component.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + +
DatePrice + + Amount +
+ {{ trade.trade_date | date:'yyyy-MM-dd HH:mm' }} + + {{ trade.price | currency: (trade._market || market).rsymbol }} + {{ trade.price | number: '1.2-' + (trade._market || market).rprecision }} {{ (trade._market || market).rsymbol }} + + {{ trade.amount | currency: (trade._market || market).rsymbol }} + {{ trade.amount | number: '1.2-' + (trade._market || market).lprecision }} {{ (trade._market || market).lsymbol }} + + {{ trade.volume | currency: (trade._market || market).rsymbol }} + {{ trade.volume | number: '1.2-' + (trade._market || market).rprecision }} {{ (trade._market || market).rsymbol }} +
+ + + + + + + +Amount ({{ i }}) diff --git a/frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.scss b/frontend/src/app/bisq/bisq-trades/bisq-trades.component.scss similarity index 100% rename from frontend/src/app/bisq/bisq-explorer/bisq-explorer.component.scss rename to frontend/src/app/bisq/bisq-trades/bisq-trades.component.scss diff --git a/frontend/src/app/bisq/bisq-trades/bisq-trades.component.ts b/frontend/src/app/bisq/bisq-trades/bisq-trades.component.ts new file mode 100644 index 000000000..ac9f7c5f8 --- /dev/null +++ b/frontend/src/app/bisq/bisq-trades/bisq-trades.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'app-bisq-trades', + templateUrl: './bisq-trades.component.html', + styleUrls: ['./bisq-trades.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BisqTradesComponent { + @Input() trades$: Observable; + @Input() market: any; +} diff --git a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts index 2dd745762..d42b7d521 100644 --- a/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts +++ b/frontend/src/app/bisq/bisq-transaction/bisq-transaction.component.ts @@ -9,6 +9,7 @@ import { BisqApiService } from '../bisq-api.service'; import { SeoService } from 'src/app/services/seo.service'; import { ElectrsApiService } from 'src/app/services/electrs-api.service'; import { HttpErrorResponse } from '@angular/common/http'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-transaction', @@ -27,6 +28,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { subscription: Subscription; constructor( + private websocketService: WebsocketService, private route: ActivatedRoute, private bisqApiService: BisqApiService, private electrsApiService: ElectrsApiService, @@ -36,6 +38,8 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { ) { } ngOnInit(): void { + this.websocketService.want(['blocks']); + this.subscription = this.route.paramMap.pipe( switchMap((params: ParamMap) => { this.isLoading = true; diff --git a/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.ts b/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.ts index 9a9fe4e45..fd0393e8c 100644 --- a/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.ts +++ b/frontend/src/app/bisq/bisq-transactions/bisq-transactions.component.ts @@ -8,6 +8,7 @@ import { SeoService } from 'src/app/services/seo.service'; import { FormGroup, FormBuilder } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts } from 'ngx-bootrap-multiselect'; +import { WebsocketService } from 'src/app/services/websocket.service'; @Component({ selector: 'app-bisq-transactions', @@ -65,6 +66,7 @@ export class BisqTransactionsComponent implements OnInit { 'PROOF_OF_BURN', 'PROPOSAL', 'REIMBURSEMENT_REQUEST', 'TRANSFER_BSQ', 'UNLOCK', 'VOTE_REVEAL', 'IRREGULAR']; constructor( + private websocketService: WebsocketService, private bisqApiService: BisqApiService, private seoService: SeoService, private formBuilder: FormBuilder, @@ -74,6 +76,7 @@ export class BisqTransactionsComponent implements OnInit { ) { } ngOnInit(): void { + this.websocketService.want(['blocks']); this.seoService.setTitle($localize`:@@add4cd82e3e38a3110fe67b3c7df56e9602644ee:Transactions`); this.radioGroupForm = this.formBuilder.group({ diff --git a/frontend/src/app/bisq/bisq.interfaces.ts b/frontend/src/app/bisq/bisq.interfaces.ts index 710bada2a..7c2377fa1 100644 --- a/frontend/src/app/bisq/bisq.interfaces.ts +++ b/frontend/src/app/bisq/bisq.interfaces.ts @@ -80,3 +80,182 @@ interface SpentInfo { inputIndex: number; txId: string; } + + +export interface BisqTrade { + direction: string; + price: string; + amount: string; + volume: string; + payment_method: string; + trade_id: string; + trade_date: number; + market?: string; +} + +export interface Currencies { [txid: string]: Currency; } + +export interface Currency { + code: string; + name: string; + precision: number; + + _type: string; +} + +export interface Depth { [market: string]: Market; } + +interface Market { + 'buys': string[]; + 'sells': string[]; +} + +export interface HighLowOpenClose { + period_start: number | string; + open: string; + high: string; + low: string; + close: string; + volume_left: string; + volume_right: string; + avg: string; +} + +export interface Markets { [txid: string]: Pair; } + +interface Pair { + pair: string; + lname: string; + rname: string; + lsymbol: string; + rsymbol: string; + lprecision: number; + rprecision: number; + ltype: string; + rtype: string; + name: string; +} + +export interface Offers { [market: string]: OffersMarket; } + +export interface OffersMarket { + buys: Offer[] | null; + sells: Offer[] | null; +} + +export interface OffersData { + direction: string; + currencyCode: string; + minAmount: number; + amount: number; + price: number; + date: number; + useMarketBasedPrice: boolean; + marketPriceMargin: number; + paymentMethod: string; + id: string; + currencyPair: string; + primaryMarketDirection: string; + priceDisplayString: string; + primaryMarketAmountDisplayString: string; + primaryMarketMinAmountDisplayString: string; + primaryMarketVolumeDisplayString: string; + primaryMarketMinVolumeDisplayString: string; + primaryMarketPrice: number; + primaryMarketAmount: number; + primaryMarketMinAmount: number; + primaryMarketVolume: number; + primaryMarketMinVolume: number; +} + +export interface Offer { + offer_id: string; + offer_date: number; + direction: string; + min_amount: string; + amount: string; + price: string; + volume: string; + payment_method: string; + offer_fee_txid: any; +} + +export interface Tickers { [market: string]: Ticker | null; } + +export interface Ticker { + last: string; + high: string; + low: string; + volume_left: string; + volume_right: string; + buy: string | null; + sell: string | null; +} + +export interface Trade { + market?: string; + price: string; + amount: string; + volume: string; + payment_method: string; + trade_id: string; + trade_date: number; + _market: Pair; +} + +export interface TradesData { + currency: string; + direction: string; + tradePrice: number; + tradeAmount: number; + tradeDate: number; + paymentMethod: string; + offerDate: number; + useMarketBasedPrice: boolean; + marketPriceMargin: number; + offerAmount: number; + offerMinAmount: number; + offerId: string; + depositTxId?: string; + currencyPair: string; + primaryMarketDirection: string; + primaryMarketTradePrice: number; + primaryMarketTradeAmount: number; + primaryMarketTradeVolume: number; + + _market: string; + _tradePriceStr: string; + _tradeAmountStr: string; + _tradeVolumeStr: string; + _offerAmountStr: string; + _tradePrice: number; + _tradeAmount: number; + _tradeVolume: number; + _offerAmount: number; +} + +export interface MarketVolume { + period_start: number; + num_trades: number; + volume: string; +} + +export interface MarketsApiError { + success: number; + error: string; +} + +export type Interval = 'minute' | 'half_hour' | 'hour' | 'half_day' | 'day' | 'week' | 'month' | 'year' | 'auto'; + +export interface SummarizedIntervals { [market: string]: SummarizedInterval; } +export interface SummarizedInterval { + period_start: number; + open: number; + close: number; + high: number; + low: number; + avg: number; + volume_right: number; + volume_left: number; + time?: number; +} diff --git a/frontend/src/app/bisq/bisq.module.ts b/frontend/src/app/bisq/bisq.module.ts index 9c8bfca0f..1ad40c850 100644 --- a/frontend/src/app/bisq/bisq.module.ts +++ b/frontend/src/app/bisq/bisq.module.ts @@ -3,10 +3,14 @@ import { BisqRoutingModule } from './bisq.routing.module'; import { SharedModule } from '../shared/shared.module'; import { NgxBootstrapMultiselectModule } from 'ngx-bootrap-multiselect'; +import { LightweightChartsComponent } from './lightweight-charts/lightweight-charts.component'; +import { LightweightChartsAreaComponent } from './lightweight-charts-area/lightweight-charts-area.component'; +import { BisqMarketComponent } from './bisq-market/bisq-market.component'; import { BisqTransactionsComponent } from './bisq-transactions/bisq-transactions.component'; import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; import { BisqTransactionComponent } from './bisq-transaction/bisq-transaction.component'; import { BisqBlockComponent } from './bisq-block/bisq-block.component'; +import { BisqDashboardComponent } from './bisq-dashboard/bisq-dashboard.component'; import { BisqIconComponent } from './bisq-icon/bisq-icon.component'; import { BisqTransactionDetailsComponent } from './bisq-transaction-details/bisq-transaction-details.component'; import { BisqTransfersComponent } from './bisq-transfers/bisq-transfers.component'; @@ -14,11 +18,11 @@ import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontaweso import { faLeaf, faQuestion, faExclamationTriangle, faRocket, faRetweet, faFileAlt, faMoneyBill, faEye, faEyeSlash, faLock, faLockOpen, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { BisqBlocksComponent } from './bisq-blocks/bisq-blocks.component'; -import { BisqExplorerComponent } from './bisq-explorer/bisq-explorer.component'; import { BisqApiService } from './bisq-api.service'; import { BisqAddressComponent } from './bisq-address/bisq-address.component'; import { BisqStatsComponent } from './bisq-stats/bisq-stats.component'; import { BsqAmountComponent } from './bsq-amount/bsq-amount.component'; +import { BisqTradesComponent } from './bisq-trades/bisq-trades.component'; @NgModule({ declarations: [ @@ -30,10 +34,14 @@ import { BsqAmountComponent } from './bsq-amount/bsq-amount.component'; BisqTransactionDetailsComponent, BisqTransfersComponent, BisqBlocksComponent, - BisqExplorerComponent, BisqAddressComponent, BisqStatsComponent, BsqAmountComponent, + LightweightChartsComponent, + LightweightChartsAreaComponent, + BisqDashboardComponent, + BisqMarketComponent, + BisqTradesComponent, ], imports: [ BisqRoutingModule, diff --git a/frontend/src/app/bisq/bisq.routing.module.ts b/frontend/src/app/bisq/bisq.routing.module.ts index 90bec63ce..7794b61bd 100644 --- a/frontend/src/app/bisq/bisq.routing.module.ts +++ b/frontend/src/app/bisq/bisq.routing.module.ts @@ -5,55 +5,58 @@ import { BisqTransactionsComponent } from './bisq-transactions/bisq-transactions import { BisqTransactionComponent } from './bisq-transaction/bisq-transaction.component'; import { BisqBlockComponent } from './bisq-block/bisq-block.component'; import { BisqBlocksComponent } from './bisq-blocks/bisq-blocks.component'; -import { BisqExplorerComponent } from './bisq-explorer/bisq-explorer.component'; import { BisqAddressComponent } from './bisq-address/bisq-address.component'; import { BisqStatsComponent } from './bisq-stats/bisq-stats.component'; import { ApiDocsComponent } from '../components/api-docs/api-docs.component'; +import { BisqDashboardComponent } from './bisq-dashboard/bisq-dashboard.component'; +import { BisqMarketComponent } from './bisq-market/bisq-market.component'; const routes: Routes = [ { - path: '', - component: BisqExplorerComponent, - children: [ - { - path: '', - component: BisqTransactionsComponent - }, - { - path: 'tx/:id', - component: BisqTransactionComponent - }, - { - path: 'blocks', - children: [], - component: BisqBlocksComponent - }, - { - path: 'block/:id', - component: BisqBlockComponent, - }, - { - path: 'address/:id', - component: BisqAddressComponent, - }, - { - path: 'stats', - component: BisqStatsComponent, - }, - { - path: 'about', - component: AboutComponent, - }, - { - path: 'api', - component: ApiDocsComponent, - }, - { - path: '**', - redirectTo: '' - } - ] - } + path: '', + component: BisqDashboardComponent, + }, + { + path: 'transactions', + component: BisqTransactionsComponent + }, + { + path: 'market/:pair', + component: BisqMarketComponent, + }, + { + path: 'tx/:id', + component: BisqTransactionComponent + }, + { + path: 'blocks', + children: [], + component: BisqBlocksComponent + }, + { + path: 'block/:id', + component: BisqBlockComponent, + }, + { + path: 'address/:id', + component: BisqAddressComponent, + }, + { + path: 'stats', + component: BisqStatsComponent, + }, + { + path: 'about', + component: AboutComponent, + }, + { + path: 'api', + component: ApiDocsComponent, + }, + { + path: '**', + redirectTo: '' + } ]; @NgModule({ diff --git a/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.scss b/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.scss new file mode 100644 index 000000000..56fe6ab0e --- /dev/null +++ b/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.scss @@ -0,0 +1,25 @@ +:host ::ng-deep .floating-tooltip-2 { + width: 160px; + height: 80px; + position: absolute; + display: none; + padding: 8px; + box-sizing: border-box; + font-size: 12px; + color:rgba(255, 255, 255, 1); + background-color: #131722; + text-align: left; + z-index: 1000; + top: 12px; + left: 12px; + pointer-events: none; + border-radius: 2px; +} + +:host ::ng-deep .volumeText { + color: rgba(33, 150, 243, 0.7); +} + +:host ::ng-deep .tradesText { + color: rgba(37, 177, 53, 1); +} diff --git a/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.ts b/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.ts new file mode 100644 index 000000000..9c0cf3242 --- /dev/null +++ b/frontend/src/app/bisq/lightweight-charts-area/lightweight-charts-area.component.ts @@ -0,0 +1,133 @@ +import { createChart, CrosshairMode, isBusinessDay } from 'lightweight-charts'; +import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'app-lightweight-charts-area', + template: '', + styleUrls: ['./lightweight-charts-area.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LightweightChartsAreaComponent implements OnChanges, OnDestroy { + @Input() data: any; + @Input() lineData: any; + @Input() precision: number; + + areaSeries: any; + volumeSeries: any; + chart: any; + lineSeries: any; + container: any; + + width = 1110; + height = 500; + + constructor( + private element: ElementRef, + ) { + this.container = document.createElement('div'); + const chartholder = this.element.nativeElement.appendChild(this.container); + + this.chart = createChart(chartholder, { + width: this.width, + height: this.height, + crosshair: { + mode: CrosshairMode.Normal, + }, + layout: { + backgroundColor: '#000', + textColor: 'rgba(255, 255, 255, 0.8)', + }, + grid: { + vertLines: { + color: 'rgba(255, 255, 255, 0.1)', + }, + horzLines: { + color: 'rgba(255, 255, 255, 0.1)', + }, + }, + rightPriceScale: { + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + timeScale: { + borderColor: 'rgba(255, 255, 255, 0.2)', + }, + }); + + this.lineSeries = this.chart.addLineSeries({ + color: 'rgba(37, 177, 53, 1)', + lineColor: 'rgba(216, 27, 96, 1)', + lineWidth: 2, + }); + + this.areaSeries = this.chart.addAreaSeries({ + topColor: 'rgba(33, 150, 243, 0.7)', + bottomColor: 'rgba(33, 150, 243, 0.1)', + lineColor: 'rgba(33, 150, 243, 0.1)', + lineWidth: 2, + }); + + const toolTip = document.createElement('div'); + toolTip.className = 'floating-tooltip-2'; + chartholder.appendChild(toolTip); + + this.chart.subscribeCrosshairMove((param) => { + if (!param.time || param.point.x < 0 || param.point.x > this.width || param.point.y < 0 || param.point.y > this.height) { + toolTip.style.display = 'none'; + return; + } + + const dateStr = isBusinessDay(param.time) + ? this.businessDayToString(param.time) + : new Date(param.time * 1000).toLocaleDateString(); + + toolTip.style.display = 'block'; + const price = param.seriesPrices.get(this.areaSeries); + const line = param.seriesPrices.get(this.lineSeries); + + const tradesText = $localize`:@@bisq-graph-trades:Trades`; + const volumeText = $localize`:@@bisq-graph-volume:Volume`; + + toolTip.innerHTML = ` + + +
${tradesText}:${Math.round(line * 100) / 100}
${volumeText}:${Math.round(price * 100) / 100} BTC
+
${dateStr}
`; + + const y = param.point.y; + + const toolTipWidth = 100; + const toolTipHeight = 80; + const toolTipMargin = 15; + + let left = param.point.x + toolTipMargin; + if (left > this.width - toolTipWidth) { + left = param.point.x - toolTipMargin - toolTipWidth; + } + + let top = y + toolTipMargin; + if (top > this.height - toolTipHeight) { + top = y - toolTipHeight - toolTipMargin; + } + + toolTip.style.left = left + 'px'; + toolTip.style.top = top + 'px'; + }); + } + + businessDayToString(businessDay) { + return businessDay.year + '-' + businessDay.month + '-' + businessDay.day; + } + + ngOnChanges() { + if (!this.data) { + return; + } + this.areaSeries.setData(this.data); + this.lineSeries.setData(this.lineData); + } + + ngOnDestroy() { + this.chart.remove(); + } + +} diff --git a/frontend/src/app/bisq/lightweight-charts/lightweight-charts.component.scss b/frontend/src/app/bisq/lightweight-charts/lightweight-charts.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/bisq/lightweight-charts/lightweight-charts.component.ts b/frontend/src/app/bisq/lightweight-charts/lightweight-charts.component.ts new file mode 100644 index 000000000..928030a49 --- /dev/null +++ b/frontend/src/app/bisq/lightweight-charts/lightweight-charts.component.ts @@ -0,0 +1,77 @@ +import { createChart, CrosshairMode } from 'lightweight-charts'; +import { ChangeDetectionStrategy, Component, ElementRef, Input, OnChanges, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'app-lightweight-charts', + template: '', + styleUrls: ['./lightweight-charts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LightweightChartsComponent implements OnChanges, OnDestroy { + @Input() data: any; + @Input() volumeData: any; + @Input() precision: number; + + lineSeries: any; + volumeSeries: any; + chart: any; + + constructor( + private element: ElementRef, + ) { + this.chart = createChart(this.element.nativeElement, { + width: 1110, + height: 500, + layout: { + backgroundColor: '#000000', + textColor: '#d1d4dc', + }, + crosshair: { + mode: CrosshairMode.Normal, + }, + grid: { + vertLines: { + visible: true, + color: 'rgba(42, 46, 57, 0.5)', + }, + horzLines: { + color: 'rgba(42, 46, 57, 0.5)', + }, + }, + }); + this.lineSeries = this.chart.addCandlestickSeries(); + + this.volumeSeries = this.chart.addHistogramSeries({ + color: '#26a69a', + priceFormat: { + type: 'volume', + }, + priceScaleId: '', + scaleMargins: { + top: 0.85, + bottom: 0, + }, + }); + } + + ngOnChanges() { + if (!this.data) { + return; + } + this.lineSeries.setData(this.data); + this.volumeSeries.setData(this.volumeData); + + this.lineSeries.applyOptions({ + priceFormat: { + type: 'price', + precision: this.precision, + minMove: 0.0000001, + }, + }); + } + + ngOnDestroy() { + this.chart.remove(); + } + +} diff --git a/frontend/src/app/components/bisq-master-page/bisq-master-page.component.html b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.html new file mode 100644 index 000000000..8ac6bf4be --- /dev/null +++ b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.html @@ -0,0 +1,30 @@ +
+ +
+ +
+ + + +
diff --git a/frontend/src/app/components/bisq-master-page/bisq-master-page.component.scss b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.scss new file mode 100644 index 000000000..3c0e31490 --- /dev/null +++ b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.scss @@ -0,0 +1,85 @@ +li.nav-item.active { + background-color: #653b9c; +} + +fa-icon { + font-size: 1.66em; +} + +.navbar { + z-index: 100; +} + +li.nav-item { + padding-left: 10px; + padding-right: 10px; +} + +@media (min-width: 768px) { + .navbar { + padding: 0rem 2rem; + } + fa-icon { + font-size: 1.2em; + } + .dropdown-container { + margin-right: 16px; + } + li.nav-item { + padding: 10px; + } +} + +li.nav-item a { + color: #ffffff; +} + +.navbar-nav { + flex-direction: row; + justify-content: center; +} + +nav { + box-shadow: 0px 0px 15px 0px #000; +} + +.connection-badge { + position: absolute; + top: 13px; + left: 0px; + width: 140px; +} + +.badge { + margin: 0 auto; + display: table; +} + +.mainnet.active { + background-color: #653b9c; +} + +.liquid.active { + background-color: #116761; +} + +.testnet.active { + background-color: #1d486f; +} + +.signet.active { + background-color: #6f1d5d; +} + +.dropdown-divider { + border-top: 1px solid #121420; +} + +.dropdown-toggle::after { + vertical-align: 0.1em; +} + +.dropdown-item { + display: flex; + align-items:center; +} diff --git a/frontend/src/app/components/bisq-master-page/bisq-master-page.component.ts b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.ts new file mode 100644 index 000000000..060de6e45 --- /dev/null +++ b/frontend/src/app/components/bisq-master-page/bisq-master-page.component.ts @@ -0,0 +1,25 @@ +import { Component, OnInit } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Observable } from 'rxjs'; + +@Component({ + selector: 'app-bisq-master-page', + templateUrl: './bisq-master-page.component.html', + styleUrls: ['./bisq-master-page.component.scss'], +}) +export class BisqMasterPageComponent implements OnInit { + connectionState$: Observable; + navCollapsed = false; + + constructor( + private stateService: StateService, + ) { } + + ngOnInit() { + this.connectionState$ = this.stateService.connectionState$; + } + + collapse(): void { + this.navCollapsed = !this.navCollapsed; + } +} diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 4c6112e8a..42de6b17a 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -27,21 +27,21 @@