├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── angular.json ├── decs.d.ts ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.sass │ ├── app.component.ts │ ├── app.module.ts │ ├── order-by.pipe.ts │ ├── util.ts │ └── ws.service.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.beta.ts │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.sass └── test.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish GH pages 2 | on: 3 | push: 4 | branches: [ master ] 5 | jobs: 6 | build-and-deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 🛎️ 10 | uses: actions/checkout@v2 11 | with: 12 | persist-credentials: false 13 | - name: Force Node version 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 14 17 | - name: Install and Build 🔧 18 | run: | 19 | npm install 20 | npm run build:live 21 | - name: Deploy 🚀 22 | uses: JamesIves/github-pages-deploy-action@releases/v4 23 | with: 24 | access_token: ${{ secrets.ACCESS_TOKEN }} 25 | branch: gh-pages 26 | folder: dist/nano-vote-visualizer 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | 16 | # IDEs and editors 17 | /.idea 18 | .project 19 | .classpath 20 | .c9/ 21 | *.launch 22 | .settings/ 23 | *.sublime-workspace 24 | 25 | # IDE - VSCode 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | .history/* 32 | 33 | # misc 34 | /.sass-cache 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | npm-debug.log 39 | yarn-error.log 40 | testem.log 41 | /typings 42 | 43 | # System Files 44 | .DS_Store 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine as build 2 | ARG ENVIRONMENT="live" 3 | WORKDIR /usr/local/app 4 | COPY ./ . 5 | RUN npm install 6 | RUN npm run build:${ENVIRONMENT} 7 | 8 | FROM nginx:alpine 9 | COPY --from=build /usr/local/app/dist/nano-vote-visualizer /usr/share/nginx/html 10 | EXPOSE 80 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Miro Metsänheimo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nano vote visualizer 2 | 3 | This app visualizes all of the elections which are happening on the Nano network on a graph. It's live at https://nanovisual.numsu.dev 4 | 5 | ## Development server 6 | 7 | Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Build 10 | 11 | Run `npm run build:live` to build the project source to use the live network configuration. 12 | 13 | Run `npm run build:beta` to build the project source to use the beta network configuration. 14 | 15 | Change the environment.*.ts files if you want to host the service with your own node. 16 | ## Docker 17 | 18 | ### Building 19 | Run `docker build -t nano-vote-visualizer:latest .` to build a runnable container for the live network. 20 | 21 | Run `docker build -t nano-vote-visualizer:latest --build-arg ENVIRONMENT=beta .` to build a runnable container for the beta network. 22 | 23 | ### Running 24 | Run `docker run -d --rm -p 8080:80 nano-vote-visualizer:latest` to spin up the container to http://localhost:8080. 25 | 26 | ## Contributing 27 | Please be welcomed to open issues and contribute to the project with pull requests. If you require assistance, you can contact me on Discord. 28 | 29 | ## Donations 30 | Donations are welcome, you can sent Nano to: 31 | 32 | `nano_1iic4ggaxy3eyg89xmswhj1r5j9uj66beka8qjcte11bs6uc3wdwr7i9hepm` -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "nano-vote-visualizer": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "sass" 11 | }, 12 | "@schematics/angular:application": { 13 | "strict": true 14 | } 15 | }, 16 | "root": "", 17 | "sourceRoot": "src", 18 | "prefix": "app", 19 | "architect": { 20 | "build": { 21 | "builder": "@angular-devkit/build-angular:browser", 22 | "options": { 23 | "outputPath": "dist/nano-vote-visualizer", 24 | "index": "src/index.html", 25 | "main": "src/main.ts", 26 | "polyfills": "src/polyfills.ts", 27 | "tsConfig": "tsconfig.app.json", 28 | "inlineStyleLanguage": "sass", 29 | "assets": [ 30 | "src/favicon.ico", 31 | "src/assets" 32 | ], 33 | "styles": [ 34 | "src/styles.sass" 35 | ], 36 | "scripts": [ 37 | ] 38 | }, 39 | "configurations": { 40 | "live": { 41 | "budgets": [ 42 | { 43 | "type": "initial", 44 | "maximumWarning": "500kb", 45 | "maximumError": "1mb" 46 | }, 47 | { 48 | "type": "anyComponentStyle", 49 | "maximumWarning": "2kb", 50 | "maximumError": "4kb" 51 | } 52 | ], 53 | "fileReplacements": [ 54 | { 55 | "replace": "src/environments/environment.ts", 56 | "with": "src/environments/environment.prod.ts" 57 | } 58 | ], 59 | "outputHashing": "all" 60 | }, 61 | "beta": { 62 | "budgets": [ 63 | { 64 | "type": "initial", 65 | "maximumWarning": "500kb", 66 | "maximumError": "1mb" 67 | }, 68 | { 69 | "type": "anyComponentStyle", 70 | "maximumWarning": "2kb", 71 | "maximumError": "4kb" 72 | } 73 | ], 74 | "fileReplacements": [ 75 | { 76 | "replace": "src/environments/environment.ts", 77 | "with": "src/environments/environment.beta.ts" 78 | } 79 | ], 80 | "outputHashing": "all" 81 | }, 82 | "development": { 83 | "buildOptimizer": false, 84 | "optimization": false, 85 | "vendorChunk": true, 86 | "extractLicenses": false, 87 | "sourceMap": true, 88 | "namedChunks": true 89 | } 90 | }, 91 | "defaultConfiguration": "production" 92 | }, 93 | "serve": { 94 | "builder": "@angular-devkit/build-angular:dev-server", 95 | "configurations": { 96 | "production": { 97 | "browserTarget": "nano-vote-visualizer:build:production" 98 | }, 99 | "development": { 100 | "browserTarget": "nano-vote-visualizer:build:development" 101 | } 102 | }, 103 | "defaultConfiguration": "development" 104 | }, 105 | "extract-i18n": { 106 | "builder": "@angular-devkit/build-angular:extract-i18n", 107 | "options": { 108 | "browserTarget": "nano-vote-visualizer:build" 109 | } 110 | }, 111 | "test": { 112 | "builder": "@angular-devkit/build-angular:karma", 113 | "options": { 114 | "main": "src/test.ts", 115 | "polyfills": "src/polyfills.ts", 116 | "tsConfig": "tsconfig.spec.json", 117 | "karmaConfig": "karma.conf.js", 118 | "inlineStyleLanguage": "sass", 119 | "assets": [ 120 | "src/favicon.ico", 121 | "src/assets" 122 | ], 123 | "styles": [ 124 | "src/styles.sass" 125 | ], 126 | "scripts": [] 127 | } 128 | } 129 | } 130 | } 131 | }, 132 | "defaultProject": "nano-vote-visualizer" 133 | } -------------------------------------------------------------------------------- /decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'd3fc'; 2 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/nano-vote-visualizer'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false, 42 | restartOnFileChange: true 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nano-vote-visualizer", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build:live": "ng build --configuration live", 8 | "build:beta": "ng build --configuration beta", 9 | "watch": "ng build --watch --configuration development", 10 | "test": "ng test" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "12.0.0", 15 | "@angular/common": "12.0.0", 16 | "@angular/compiler": "12.0.0", 17 | "@angular/core": "12.0.0", 18 | "@angular/forms": "12.0.0", 19 | "@angular/platform-browser": "12.0.0", 20 | "@angular/platform-browser-dynamic": "12.0.0", 21 | "@angular/router": "12.0.0", 22 | "@d3fc/d3fc-annotation": "3.0.11", 23 | "@d3fc/d3fc-element": "6.1.2", 24 | "@d3fc/d3fc-series": "6.0.4", 25 | "@d3fc/d3fc-webgl": "3.1.0", 26 | "@fortawesome/angular-fontawesome": "0.9.0", 27 | "@fortawesome/fontawesome-svg-core": "1.2.35", 28 | "@fortawesome/free-solid-svg-icons": "5.15.3", 29 | "angularx-qrcode": "11.0.0", 30 | "bignumber.js": "9.0.1", 31 | "d3": "6.1.1", 32 | "d3fc": "15.2.1", 33 | "lodash": "4.17.21", 34 | "nanocurrency-web": "1.3.2", 35 | "rxjs": "6.6.0", 36 | "tslib": "2.1.0", 37 | "zone.js": "0.11.4" 38 | }, 39 | "devDependencies": { 40 | "@angular-devkit/build-angular": "12.0.0", 41 | "@angular/cli": "12.0.0", 42 | "@angular/compiler-cli": "12.0.0", 43 | "@types/d3": "6.7.0", 44 | "@types/jasmine": "3.6.0", 45 | "@types/node": "12.11.1", 46 | "jasmine-core": "3.7.0", 47 | "karma": "6.3.0", 48 | "karma-chrome-launcher": "3.1.0", 49 | "karma-coverage": "2.0.3", 50 | "karma-jasmine": "4.0.0", 51 | "karma-jasmine-html-reporter": "1.5.0", 52 | "typescript": "4.2.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | const routes: Routes = []; 5 | 6 | @NgModule({ 7 | imports: [RouterModule.forRoot(routes)], 8 | exports: [RouterModule], 9 | }) 10 | export class AppRoutingModule { } 11 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ network == 'live' ? 'Live' : 'Beta' }} Nano election visualization

4 | Every item in the visualization is a block waiting to be validated. When the quorum reaches 100%, the block is confirmed. This data is based on the perspective of a single node.
5 | You can change settings below to display more data. Larger time frame will degrade performance.

6 |
7 |
8 |
9 |

Graph style

10 |
11 | 12 | 13 |
14 |
15 |
16 |

Tuning

17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 |
34 |
35 |

Elections Active / Stopped

36 | {{ blocks - stoppedElections - confirmations }} / {{ stoppedElections }} 37 |
38 |
39 |

Confirmations (avg. CPS)

40 | {{ confirmations }} ({{ cps }}) 41 |
42 |
43 | 44 |
45 |

Principal vote participation

46 | 47 | Disclaimer: Node marked with *** is biased because it is the source of this information. This might cause the actual vote participation to be higher, even though it is a powerful server. 48 | This represents the perspective of a single node and might not count all of the votes in the network. 49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | 60 | 63 | 66 | 69 | 70 |
AliasOnline weight %Vote countParticipation %
58 | {{ rep.value.alias || rep.key }} 59 | 61 | {{ rep.value.weight | percent: '1.2-2' }} 62 | 64 | {{ rep.value.voteCount }} 65 | 67 | {{ rep.value.voteCount / blocks | percent: '1.2-2' }} 68 |
71 |
72 |
73 | 74 |
75 |

Latest confirmations

76 |
77 | 78 | 79 | 80 | 81 | 85 | 88 | 89 |
Account/HashAmount
82 | {{ confirmation.account }}
83 | {{ confirmation.hash }} 84 |
86 | {{ confirmation.amount }} 87 |
90 |
91 |
92 | 93 |
94 | Made by Numsu
95 | If you appreciate my work in the community, please consider donating Nano:
96 | nano_1iic4ggaxy3eyg89xmswhj1r5j9uj66beka8qjcte11bs6uc3wdwr7i9hepm

97 | 98 |
99 |
100 | -------------------------------------------------------------------------------- /src/app/app.component.sass: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numsu/nano-vote-visualizer/6c0a6ca2a97d773e0aea287328346e0ccd2c46eb/src/app/app.component.sass -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import * as fc from 'd3fc'; 2 | import * as d3 from 'd3'; 3 | import { tools } from 'nanocurrency-web'; 4 | import { environment } from 'src/environments/environment'; 5 | 6 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; 7 | import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; 8 | 9 | import { Util } from './util'; 10 | import { ConfirmationMessage, NanoWebsocketService } from './ws.service'; 11 | import BigNumber from 'bignumber.js'; 12 | 13 | @Component({ 14 | selector: 'app-root', 15 | templateUrl: './app.component.html', 16 | styleUrls: ['./app.component.sass'], 17 | changeDetection: ChangeDetectionStrategy.OnPush, 18 | }) 19 | export class AppComponent implements OnInit, OnDestroy { 20 | 21 | electionChart: any; 22 | 23 | // Icons 24 | downArrow = faChevronDown; 25 | upArrow = faChevronUp; 26 | 27 | // Intervals 28 | pageUpdateInterval: any; 29 | upkeepInterval: any; 30 | wsHealthCheckInterval: any; 31 | 32 | // Data handling 33 | readonly data: ElectionChartData[] = []; 34 | readonly blockToIndex = new Map(); 35 | readonly indexToAnimating = new Map(); 36 | readonly repToBlocks = new Map>(); 37 | readonly latestConfirmations: ConfirmationMessage[] = []; 38 | readonly representativeStats = new Map(); 39 | readonly electionChartRecentlyRemoved = new Set(); 40 | readonly startTime = new Date().getTime() / 1000; 41 | 42 | // User defined settings 43 | fps: number; 44 | timeframe: number; 45 | graphStyle: GraphStyle = 2; 46 | smooth = true; 47 | useMaxFPS = true; 48 | showSettings = false; 49 | 50 | // Counters 51 | index = 0; 52 | blocks = 0; 53 | stoppedElections = 0; 54 | confirmations = 0; 55 | cps = '0'; 56 | 57 | // Environment settings 58 | readonly network = environment.network; 59 | readonly maxTimeframeMinutes = 10; 60 | readonly maxFps = 60; 61 | readonly hostAccount = environment.hostAccount; 62 | readonly explorerUrl = environment.explorerUrl; 63 | readonly repInfoUrl = environment.repInfoUrl; 64 | 65 | constructor(private ws: NanoWebsocketService, 66 | private changeDetectorRef: ChangeDetectorRef) { 67 | } 68 | 69 | ngOnDestroy() { 70 | this.stopInterval(); 71 | if (this.upkeepInterval) { 72 | clearInterval(this.upkeepInterval); 73 | } 74 | if (this.wsHealthCheckInterval) { 75 | clearInterval(this.wsHealthCheckInterval); 76 | } 77 | } 78 | 79 | async ngOnInit() { 80 | this.startUpkeepInterval(); 81 | this.initSettings(); 82 | this.buildElectionChart(); 83 | this.startInterval(); 84 | await this.ws.updatePrincipalsAndQuorum(); 85 | this.initPrincipals(); 86 | this.start(); 87 | } 88 | 89 | initSettings() { 90 | this.fps = Math.min(+localStorage.getItem('nv-fps') || 24, this.maxFps); 91 | this.timeframe = Math.min(+localStorage.getItem('nv-timeframe') || 5, this.maxTimeframeMinutes); 92 | this.graphStyle = +localStorage.getItem('nv-style') || GraphStyle.HEATMAP; 93 | this.smooth = (localStorage.getItem('nv-smooth') || 'true') == 'true'; 94 | this.useMaxFPS = (localStorage.getItem('nv-max-fps') || 'true') == 'true'; 95 | } 96 | 97 | initPrincipals() { 98 | this.ws.principals.forEach(principal => { 99 | let alias = principal.alias; 100 | if (principal.account == this.hostAccount) { 101 | alias = '*** ' + alias; 102 | } 103 | 104 | this.repToBlocks.set(principal.account, new Set()); 105 | this.representativeStats.set(principal.account, { 106 | weight: this.ws.principalWeights.get(principal.account) / this.ws.onlineStake, 107 | alias, 108 | voteCount: 0, 109 | }); 110 | }); 111 | } 112 | 113 | getRelativeTimeInSeconds(): number { 114 | return (new Date().getTime() / 1000) - this.startTime; 115 | } 116 | 117 | async start() { 118 | const subjects = await this.ws.subscribe(); 119 | this.wsHealthCheckInterval = setInterval(() => this.ws.checkAndReconnectSocket(), 2000); 120 | 121 | subjects.votes.subscribe(async vote => { 122 | if (vote.message.timestamp != '18446744073709551615') { // Only count final votes 123 | return; 124 | } 125 | 126 | const principalWeight = this.ws.principalWeights.get(vote.message.account); 127 | if (principalWeight === undefined) { 128 | return; 129 | } 130 | 131 | const principalWeightPercent = new BigNumber(principalWeight).div(new BigNumber(this.ws.quorumDelta)).times(100); 132 | const blocks = this.repToBlocks.get(vote.message.account); 133 | 134 | for (const block of vote.message.blocks) { 135 | const index = this.blockToIndex.get(block); 136 | const item = this.data[index]; 137 | 138 | // The node is reporting representative votes which are already counted, only count first occurrences 139 | if (!blocks.has(vote.message.blocks[0])) { 140 | blocks.add(vote.message.blocks[0]); 141 | if (index !== undefined && item) { 142 | const previousQuorum = item.quorum; 143 | 144 | if (previousQuorum < 100) { 145 | const newQuorum = new BigNumber(previousQuorum).plus(principalWeightPercent); 146 | if (newQuorum.isGreaterThanOrEqualTo(100)) { 147 | if (this.smooth) { 148 | this.indexToAnimating.set(index, 100 - previousQuorum); 149 | } else { 150 | item.quorum = 100; 151 | this.indexToAnimating.delete(index); 152 | } 153 | } else { 154 | if (this.smooth) { 155 | const previousAnimating = this.indexToAnimating.get(index); 156 | let newAnimating = principalWeightPercent.toNumber(); 157 | if (previousAnimating) { 158 | newAnimating = Math.min(principalWeightPercent.plus(previousAnimating).toNumber(), 100); 159 | if (newAnimating + previousQuorum > 100) { 160 | newAnimating = 100 - previousQuorum; 161 | } 162 | } 163 | this.indexToAnimating.set(index, newAnimating); 164 | } else { 165 | item.quorum = Math.min(principalWeightPercent.plus(previousQuorum).toNumber(), 100); 166 | } 167 | } 168 | } 169 | } else if (!this.electionChartRecentlyRemoved.has(block)) { 170 | this.addNewBlock(block, principalWeightPercent.toNumber()); 171 | } 172 | 173 | this.representativeStats.get(vote.message.account).voteCount++; 174 | } 175 | } 176 | }); 177 | 178 | subjects.confirmations.subscribe(async confirmation => { 179 | const block = confirmation.message.hash; 180 | const index = this.blockToIndex.get(block); 181 | const item = this.data[index]; 182 | if (index !== undefined && item) { 183 | if (this.smooth && !isNaN(item.quorum)) { 184 | this.indexToAnimating.set(index, 100 - item.quorum); 185 | } else { 186 | item.quorum = 100; 187 | } 188 | } else { 189 | this.addNewBlock(block, 100); 190 | } 191 | this.confirmations++; 192 | 193 | const nanoAmount = Number(tools.convert(confirmation.message.amount, 'RAW', 'NANO')).toFixed(8); 194 | const trailingZeroesCleared = String(+nanoAmount / 1); 195 | confirmation.message.amount = trailingZeroesCleared; 196 | if (this.latestConfirmations.unshift(confirmation.message) > 20) { 197 | this.latestConfirmations.pop(); 198 | } 199 | }); 200 | 201 | subjects.stoppedElections.subscribe(async stoppedElection => { 202 | const block = stoppedElection.message.hash; 203 | const index = this.blockToIndex.get(block); 204 | if (index !== undefined) { 205 | const item = this.data[index]; 206 | if (item?.quorum < 100) { 207 | item.quorum = null; 208 | this.stoppedElections++; 209 | this.electionChartRecentlyRemoved.add(block); 210 | setTimeout(() => this.electionChartRecentlyRemoved.delete(block), 500); 211 | } 212 | 213 | this.blockToIndex.delete(block); 214 | this.indexToAnimating.delete(index); 215 | } 216 | }); 217 | } 218 | 219 | addNewBlock(block: string, quorum: number) { 220 | const previousIndex = this.blockToIndex.get(block); 221 | if (previousIndex) { 222 | return; 223 | } 224 | 225 | const index = this.index++; 226 | const added = this.getRelativeTimeInSeconds(); 227 | this.blockToIndex.set(block, index); 228 | 229 | if (this.smooth) { 230 | this.data[index] = { 231 | added: added, 232 | quorum: 0, 233 | }; 234 | this.indexToAnimating.set(index, quorum); 235 | } else { 236 | this.data[index] = { 237 | added: added, 238 | quorum, 239 | }; 240 | } 241 | 242 | this.blocks++; 243 | } 244 | 245 | async buildElectionChart() { 246 | const xScale = d3.scaleLinear().domain([0, 1000]); 247 | const yScale = d3.scaleLinear().domain([0, 101]); 248 | 249 | const yearColorScale = d3 250 | .scaleSequential() 251 | .domain([0, 100]) 252 | .interpolator(d3.interpolateRdYlGn); 253 | 254 | const webglColor = (color: string) => { 255 | if (color) { 256 | const { r, g, b, opacity } = d3.color(color).rgb(); 257 | return [r / 255, g / 255, b / 255, opacity]; 258 | } else { 259 | return [0, 0, 0, 0]; 260 | } 261 | } 262 | 263 | const fillColor = (fc) 264 | .webglFillColor() 265 | .value((item: ElectionChartData) => webglColor(yearColorScale(item?.quorum))) 266 | .data(this.data); 267 | 268 | this.electionChart = document.querySelector('d3fc-canvas'); 269 | const series = (fc) 270 | .seriesWebglPoint() 271 | .xScale(xScale) 272 | .yScale(yScale) 273 | .size(10) 274 | .crossValue((item: ElectionChartData) => item?.added) 275 | .mainValue((item: ElectionChartData) => item?.quorum) 276 | .defined(() => true) 277 | .equals((_, __) => false) 278 | .decorate(program => fillColor(program)); 279 | 280 | let pixels = null; 281 | let gl = null; 282 | 283 | d3.select(this.electionChart) 284 | .on('measure', event => { 285 | const { width, height } = event.detail; 286 | xScale.range([0, width]); 287 | yScale.range([height, 0]); 288 | gl = this.electionChart.querySelector('canvas').getContext('webgl'); 289 | series.context(gl); 290 | }) 291 | .on('draw', () => { 292 | if (pixels == null) { 293 | pixels = new Uint8Array( 294 | gl.drawingBufferWidth * gl.drawingBufferHeight * 4 295 | ); 296 | } 297 | 298 | const now = this.getRelativeTimeInSeconds(); 299 | const start = now - (60 * this.timeframe); 300 | 301 | // Handle animation 302 | if (this.smooth) { 303 | for (const [index, animating] of this.indexToAnimating.entries()) { 304 | // Delete the queued animation if the target is no longer present 305 | const item = this.data[index]; 306 | if (!item || isNaN(animating) || item.quorum >= 100) { 307 | this.indexToAnimating.delete(index); 308 | continue; 309 | } 310 | 311 | // Animate only the ones which are currently rendered, just increment the quorum of others 312 | if (start < item.added) { 313 | // Interpolate linear increments down to a minimum increment of 0.13 to save resources 314 | const increment = Math.max(Util.lerp(0, item.quorum + animating, animating / (item.quorum + animating) / 20), 0.1); 315 | 316 | // If the increment is smaller than the remainder animation, keep animating 317 | // Else add the rest of remaining animation. Cap quorum at 100 318 | if (animating > increment) { 319 | item.quorum = Math.min(item.quorum + increment, 100); 320 | this.indexToAnimating.set(index, animating - increment); 321 | } else { 322 | item.quorum = Math.min(item.quorum + animating, 100); 323 | this.indexToAnimating.delete(index); 324 | } 325 | } else { 326 | item.quorum = Math.min(item.quorum + animating, 100); 327 | this.indexToAnimating.delete(index); 328 | } 329 | } 330 | } 331 | 332 | // Fill out the timeline even though new data hasn't come by 333 | const lastAdded = this.data[this.data.length - 1].added; 334 | if (now > lastAdded) { 335 | const nextIndex = this.index++; 336 | this.data[nextIndex] = { 337 | added: now, 338 | quorum: null, 339 | }; 340 | } 341 | 342 | // Binary search the nearest index to the current minimum displayed area 343 | const lastTooOldIndex = Util.binarySearchNearestIndex(this.data, 'added', start); 344 | let displayedData; 345 | if (lastTooOldIndex > 0) { 346 | displayedData = this.data.slice(lastTooOldIndex); 347 | } else { 348 | displayedData = this.data; 349 | } 350 | 351 | // Set data to color function and chart 352 | fillColor.data(displayedData); 353 | series(displayedData); 354 | 355 | // Set the displayed area to be from start time to current time 356 | xScale.domain([ Math.max(start, 0), now ]); 357 | 358 | gl.readPixels( 359 | 0, 360 | 0, 361 | gl.drawingBufferWidth, 362 | gl.drawingBufferHeight, 363 | gl.RGBA, 364 | gl.UNSIGNED_BYTE, 365 | pixels 366 | ); 367 | }); 368 | } 369 | 370 | changeUseMaxFps() { 371 | this.useMaxFPS = !this.useMaxFPS; 372 | localStorage.setItem('nv-max-fps', this.useMaxFPS ? 'true' : 'false'); 373 | this.startInterval(); 374 | } 375 | 376 | changeFps(e: any) { 377 | this.fps = e.target.value; 378 | this.startInterval(); 379 | localStorage.setItem('nv-fps', String(this.fps)); 380 | } 381 | 382 | changeTimeframe(e: any) { 383 | this.timeframe = e.target.value; 384 | localStorage.setItem('nv-timeframe', String(this.timeframe)); 385 | } 386 | 387 | changeGraphStyle(style: GraphStyle) { 388 | this.graphStyle = style; 389 | localStorage.setItem('nv-style', String(this.graphStyle)); 390 | this.buildElectionChart(); 391 | } 392 | 393 | changeSmooth() { 394 | this.smooth = !this.smooth; 395 | localStorage.setItem('nv-smooth', this.smooth ? 'true' : 'false'); 396 | if (!this.smooth) { 397 | this.clearAnimatingQueue(); 398 | } 399 | } 400 | 401 | clearAnimatingQueue() { 402 | for (const [index, animating] of this.indexToAnimating.entries()) { 403 | const item = this.data[index]; 404 | if (item) { 405 | item.quorum = Math.min(item.quorum + animating, 100); 406 | } 407 | } 408 | this.indexToAnimating.clear(); 409 | } 410 | 411 | async startInterval() { 412 | this.stopInterval(); 413 | if (this.useMaxFPS) { 414 | const startAnimation = () => { 415 | this.update(); 416 | this.pageUpdateInterval = requestAnimationFrame(startAnimation); 417 | } 418 | startAnimation(); 419 | } else if (this.fps != 0) { 420 | this.pageUpdateInterval = setInterval(() => { 421 | this.update(); 422 | }, 1000 / this.fps); 423 | } 424 | } 425 | 426 | update() { 427 | if (this.data.length > 0) { 428 | const now = this.getRelativeTimeInSeconds(); 429 | this.cps = (this.confirmations / now).toFixed(4); 430 | this.electionChart.requestRedraw(); 431 | this.changeDetectorRef.markForCheck(); 432 | } 433 | } 434 | 435 | stopInterval() { 436 | if (this.pageUpdateInterval) { 437 | clearInterval(this.pageUpdateInterval); 438 | cancelAnimationFrame(this.pageUpdateInterval); 439 | this.pageUpdateInterval = undefined; 440 | } 441 | } 442 | 443 | startUpkeepInterval() { 444 | this.upkeepInterval = setInterval(async () => { 445 | console.log('Upkeep triggered...'); 446 | await this.ws.updatePrincipalsAndQuorum(); 447 | 448 | const now = this.getRelativeTimeInSeconds(); 449 | const tooOld = Math.max(now - (60 * this.maxTimeframeMinutes), 0); 450 | let lastTooOldIndex = 0; 451 | for (let i = 0; i < this.data.length; i++) { 452 | const item = this.data[i]; 453 | if (item && tooOld > item?.added) { 454 | delete this.data[i]; 455 | lastTooOldIndex = i; 456 | } 457 | } 458 | 459 | for (const index of this.indexToAnimating.keys()) { 460 | if (index < lastTooOldIndex) { 461 | this.indexToAnimating.delete(index); 462 | } 463 | } 464 | 465 | for (const principal of this.ws.principals) { 466 | const stat = this.representativeStats.get(principal.account); 467 | if (stat) { 468 | stat.alias = principal.alias; 469 | stat.weight = this.ws.principalWeights.get(principal.account) / this.ws.onlineStake; 470 | } 471 | } 472 | }, 1000 * 60 * this.maxTimeframeMinutes); 473 | } 474 | 475 | } 476 | 477 | export interface ElectionChartData { 478 | added: number; 479 | quorum: number; 480 | } 481 | 482 | export interface RepsetentativeStatItem { 483 | weight: number; 484 | alias: string; 485 | voteCount: number; 486 | } 487 | 488 | export enum GraphStyle { 489 | X0, 490 | HEATMAP, 491 | } 492 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { QRCodeModule } from 'angularx-qrcode'; 2 | 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; 5 | import { BrowserModule } from '@angular/platform-browser'; 6 | import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 7 | 8 | import { AppRoutingModule } from './app-routing.module'; 9 | import { AppComponent } from './app.component'; 10 | import { OrderByPipe } from './order-by.pipe'; 11 | import { NanoWebsocketService } from './ws.service'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | AppComponent, 16 | OrderByPipe, 17 | ], 18 | imports: [ 19 | BrowserModule, 20 | AppRoutingModule, 21 | HttpClientModule, 22 | QRCodeModule, 23 | FontAwesomeModule, 24 | ], 25 | providers: [ 26 | NanoWebsocketService, 27 | ], 28 | bootstrap: [AppComponent], 29 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 30 | }) 31 | export class AppModule { } 32 | -------------------------------------------------------------------------------- /src/app/order-by.pipe.ts: -------------------------------------------------------------------------------- 1 | import { orderBy } from 'lodash'; 2 | 3 | import { Pipe, PipeTransform } from "@angular/core"; 4 | 5 | @Pipe({ name: 'orderBy', pure: false }) 6 | export class OrderByPipe implements PipeTransform { 7 | 8 | transform(value: any[], order = '', column: string = ''): any[] { 9 | if (!value || order === '' || !order) { 10 | return value; 11 | } 12 | 13 | if (value.length <= 1) { 14 | return value; 15 | } 16 | 17 | if (!column || column === '') { 18 | if (order === 'asc') { 19 | return value.sort() 20 | } else { 21 | return value.sort().reverse(); 22 | } 23 | } 24 | 25 | return orderBy(value, [column], [order]); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/app/util.ts: -------------------------------------------------------------------------------- 1 | export class Util { 2 | 3 | static binarySearchNearestIndex(arr: any[], property: string, target: any, lo?, hi = arr.length - 1): number { 4 | if (!lo) { 5 | arr.some((_, i) => (lo = i, true)); 6 | } 7 | if (arr.length == 0) { 8 | return 0; 9 | } 10 | if (target < arr[lo][property]) { 11 | return 0; 12 | } 13 | if (target > arr[hi][property]) { 14 | return hi; 15 | } 16 | 17 | const mid = Math.floor((hi + lo) / 2); 18 | 19 | return hi - lo < 2 20 | ? (target - arr[lo][property]) < (arr[hi][property] - target) ? lo : hi 21 | : target < arr[mid][property] 22 | ? Util.binarySearchNearestIndex(arr, property, target, lo, mid) 23 | : target > arr[mid][property] 24 | ? Util.binarySearchNearestIndex(arr, property, target, mid, hi) 25 | : mid; 26 | } 27 | 28 | static lerp(min: number, max: number, interpolation: number): number { 29 | return min * (1 - interpolation) + max * interpolation 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/app/ws.service.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { tools } from 'nanocurrency-web'; 3 | import { Subject } from 'rxjs'; 4 | import { delay, retryWhen, tap } from 'rxjs/operators'; 5 | import { WebSocketSubject, webSocket } from 'rxjs/webSocket'; 6 | import { environment } from 'src/environments/environment'; 7 | 8 | import { HttpClient } from '@angular/common/http'; 9 | import { Injectable } from "@angular/core"; 10 | 11 | @Injectable() 12 | export class NanoWebsocketService { 13 | 14 | readonly wsUrl = environment.wsUrl; 15 | readonly rpcUrl = environment.rpcUrl; 16 | readonly principalsUrl = environment.principalsUrl; 17 | 18 | principals: Principal[] = []; 19 | principalWeights = new Map(); 20 | quorumDelta: number; 21 | onlineStake: number; 22 | 23 | voteSubscription = new Subject(); 24 | confirmationSubscription = new Subject(); 25 | stopppedElectionsSubscription = new Subject(); 26 | 27 | socket: WebSocketSubject; 28 | 29 | constructor(private http: HttpClient) { 30 | } 31 | 32 | async subscribe(): Promise { 33 | this.socket = webSocket(this.wsUrl); 34 | this.socket.pipe( 35 | retryWhen(errors => 36 | errors.pipe(tap(e => 37 | console.error('Socket encountered an error, retrying...', e), 38 | delay(2000), 39 | )) 40 | ) 41 | ); 42 | 43 | this.socket.asObservable().subscribe(res => { 44 | switch (res.topic) { 45 | case 'vote': 46 | this.voteSubscription.next(res); 47 | break; 48 | case 'confirmation': 49 | this.confirmationSubscription.next(res); 50 | break; 51 | case 'stopped_election': 52 | this.stopppedElectionsSubscription.next(res); 53 | break; 54 | default: 55 | break; 56 | } 57 | }, e => { 58 | console.error('Socket has encountered an error', e); 59 | this.socket.error(e); 60 | this.socket.complete(); 61 | this.socket.hasError = true; 62 | }); 63 | 64 | this.socket.next({ 65 | 'action': 'subscribe', 66 | 'topic': 'vote', 67 | 'options': { 68 | 'representatives': this.principals.map(p => p.account), 69 | }, 70 | }); 71 | this.socket.next({ 72 | 'action': 'subscribe', 73 | 'topic': 'confirmation', 74 | 'options': { 75 | 'confirmation_type': 'active', 76 | 'include_election_info': 'false', 77 | 'include_block': 'false', 78 | }, 79 | }); 80 | this.socket.next({ 81 | 'action': 'subscribe', 82 | 'topic': 'stopped_election', 83 | }); 84 | 85 | return { 86 | votes: this.voteSubscription, 87 | confirmations: this.confirmationSubscription, 88 | stoppedElections: this.stopppedElectionsSubscription, 89 | }; 90 | } 91 | 92 | checkAndReconnectSocket() { 93 | if (this.socket?.hasError) { 94 | console.log('Socket encountered an error, reconnecting...'); 95 | this.socket.complete(); 96 | this.subscribe(); 97 | } 98 | } 99 | 100 | async updatePrincipalsAndQuorum() { 101 | try { 102 | if (environment.network == 'live') { 103 | this.principals = await this.http.get(this.principalsUrl).toPromise(); 104 | this.principals.forEach(p => this.principalWeights.set(p.account, new BigNumber(p.votingweight).shiftedBy(-30).toNumber())); 105 | } else { 106 | this.principals = (await this.http.get(this.principalsUrl).toPromise()).map(i => ({ 107 | account: i.nanoNodeAccount, 108 | alias: i.name || i.nanoNodeAccount, 109 | votingweight: i.weight, 110 | } as Principal)); 111 | this.principals.forEach(p => this.principalWeights.set(p.account, p.votingweight)); 112 | } 113 | 114 | const quorumResponse = await this.http.post(this.rpcUrl, { 115 | 'action': 'confirmation_quorum' 116 | }).toPromise(); 117 | 118 | this.onlineStake = new BigNumber(tools.convert(quorumResponse.online_stake_total, 'RAW', 'NANO')).toNumber(); 119 | this.quorumDelta = new BigNumber(tools.convert(quorumResponse.quorum_delta, 'RAW', 'NANO')).toNumber(); 120 | } catch (e) { 121 | console.error('Error updaging principals and quorum', e); 122 | } 123 | } 124 | 125 | } 126 | 127 | export interface Subscriptions { 128 | votes: Subject; 129 | confirmations: Subject; 130 | stoppedElections: Subject; 131 | } 132 | 133 | export interface BetaPrincipal { 134 | name: string; 135 | nanoNodeAccount: string; 136 | weight : number; 137 | cementedBlocks: number; 138 | } 139 | 140 | export interface Principal { 141 | account: string; 142 | alias: string; 143 | delegators: number; 144 | uptime: number; 145 | votelatency: number; 146 | votingweight: number; 147 | cemented: string; 148 | } 149 | 150 | export interface Vote extends ResponseBase { 151 | message: VoteMessage; 152 | } 153 | 154 | export interface VoteMessage { 155 | account: string; 156 | signature: string; 157 | sequence: string; 158 | blocks: string[]; 159 | type: string; 160 | timestamp: string; 161 | } 162 | 163 | export interface Confirmation extends ResponseBase { 164 | message: ConfirmationMessage; 165 | } 166 | 167 | export interface ConfirmationMessage { 168 | account: string; 169 | amount: string; 170 | hash: string; 171 | confirmation_type: string; 172 | } 173 | 174 | export interface StoppedElection extends ResponseBase { 175 | message: StoppedElectionHash; 176 | } 177 | 178 | export interface StoppedElectionHash { 179 | hash: string; 180 | } 181 | 182 | export interface ResponseBase { 183 | topic: string; 184 | time: string; 185 | } 186 | 187 | export interface ConfirmationQuorumResponse { 188 | quorum_delta: string; 189 | online_weight_quorum_percent: string; 190 | online_weight_minimum: string; 191 | online_stake_total: string; 192 | peers_stake_total: string; 193 | trended_stake_total: string; 194 | } 195 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numsu/nano-vote-visualizer/6c0a6ca2a97d773e0aea287328346e0ccd2c46eb/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.beta.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | network: 'beta', 4 | wsUrl: 'wss://ws-beta.nanoticker.info', 5 | rpcUrl: 'https://beta-proxy.nanos.cc', 6 | principalsUrl: 'https://json.nanoticker.info/?file=monitors-beta', 7 | explorerUrl: 'https://beta.nanocrawler.cc', 8 | repInfoUrl: 'https://beta.nanocrawler.cc', 9 | hostAccount: 'nano_3zapp5z141qpjipsb1jnjdmk49jwqy58i6u6wnyrh6x7woajeyme85shxewt', 10 | }; 11 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | network: 'live', 4 | wsUrl: 'wss://nanows.numsu.dev', 5 | rpcUrl: 'https://nanoproxy.numsu.dev/proxy', 6 | principalsUrl: 'https://nanobrowse.com/api/reps_online', 7 | explorerUrl: 'https://nanolooker.com', 8 | repInfoUrl: 'https://mynano.ninja', 9 | hostAccount: 'nano_3zapp5z141qpjipsb1jnjdmk49jwqy58i6u6wnyrh6x7woajeyme85shxewt', 10 | }; 11 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | network: 'live', 4 | wsUrl: 'wss://nanows.numsu.dev', 5 | rpcUrl: 'https://nanoproxy.numsu.dev/proxy', 6 | principalsUrl: 'https://nanobrowse.com/api/reps_online', 7 | explorerUrl: 'https://nanolooker.com', 8 | repInfoUrl: 'https://mynano.ninja', 9 | hostAccount: 'nano_3zapp5z141qpjipsb1jnjdmk49jwqy58i6u6wnyrh6x7woajeyme85shxewt', 10 | // network: 'beta', 11 | // wsUrl: 'wss://ws-beta.nanoticker.info', 12 | // rpcUrl: 'https://beta-proxy.nanos.cc', 13 | // principalsUrl: 'https://json.nanoticker.info/?file=monitors-beta', 14 | // explorerUrl: 'https://beta.nanocrawler.cc', 15 | // repInfoUrl: 'https://beta.nanocrawler.cc', 16 | // hostAccount: 'nano_3zapp5z141qpjipsb1jnjdmk49jwqy58i6u6wnyrh6x7woajeyme85shxewt', 17 | }; 18 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numsu/nano-vote-visualizer/6c0a6ca2a97d773e0aea287328346e0ccd2c46eb/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nano vote visualizer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(e => console.error(e)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * IE11 requires the following for NgClass support on SVG elements 23 | */ 24 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 25 | 26 | /** 27 | * Web Animations `@angular/platform-browser/animations` 28 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 29 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 30 | */ 31 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 32 | 33 | /** 34 | * By default, zone.js will patch all possible macroTask and DomEvents 35 | * user can disable parts of macroTask/DomEvents patch by setting following flags 36 | * because those flags need to be set before `zone.js` being loaded, and webpack 37 | * will put import in the top of bundle, so user need to create a separate file 38 | * in this directory (for example: zone-flags.ts), and put the following flags 39 | * into that file, and then add the following code before importing zone.js. 40 | * import './zone-flags'; 41 | * 42 | * The flags allowed in zone-flags.ts are listed here. 43 | * 44 | * The following flags will work for all browsers. 45 | * 46 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 47 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 48 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 49 | * 50 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 51 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 52 | * 53 | * (window as any).__Zone_enable_cross_context_check = true; 54 | * 55 | */ 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js'; // Included with Angular CLI. 61 | 62 | 63 | /*************************************************************************************************** 64 | * APPLICATION IMPORTS 65 | */ 66 | -------------------------------------------------------------------------------- /src/styles.sass: -------------------------------------------------------------------------------- 1 | body, html 2 | margin: 0 3 | padding: 0 4 | 5 | body 6 | background: #141619 7 | color: #c7d0d9 8 | -webkit-transform: translate3d(0,0,0) 9 | -moz-transform: translate3d(0,0,0) 10 | -ms-transform: translate3d(0,0,0) 11 | -o-transform: translate3d(0,0,0) 12 | transform: translate3d(0,0,0) 13 | 14 | .header 15 | text-align: center 16 | 17 | h1 18 | color: rgba(74, 144, 226, 0.8) 19 | 20 | .content 21 | flex: 1 22 | flex-direction: column 23 | 24 | .settings-trigger 25 | background-color: #202124 26 | height: 20px 27 | width: 80% 28 | margin: 0 auto 29 | 30 | &:hover 31 | background-color: #424549 32 | cursor: pointer 33 | 34 | .settings-container 35 | display: flex 36 | flex-direction: row 37 | justify-content: space-around 38 | 39 | .setting 40 | margin-bottom: 5px 41 | text-align: right 42 | 43 | input 44 | height: 10px 45 | margin-left: 10px 46 | 47 | #electionChart 48 | margin-top: 10px 49 | margin-horizontal: 5px 50 | width: 98.6vw 51 | height: 50vh 52 | 53 | .stats-container 54 | display: flex 55 | flex-direction: row 56 | justify-content: space-around 57 | align-items: center 58 | 59 | .stat-container 60 | display: flex 61 | flex-direction: column 62 | align-items: center 63 | margin-bottom: 50px 64 | 65 | .stat-header 66 | color: rgba(74, 144, 226, 0.8) 67 | text-align: center 68 | 69 | .stat 70 | color: #ccc 71 | font-size: 1.3em 72 | 73 | .u-legend 74 | display: none !important 75 | 76 | .table-section 77 | display: flex 78 | flex-direction: column 79 | align-items: center 80 | padding-bottom: 25px 81 | 82 | .table-section-header 83 | color: rgba(74, 144, 226, 0.8) 84 | text-align: center 85 | 86 | .table-section-list 87 | margin-bottom: 10px 88 | 89 | .table-section-table 90 | text-align: left 91 | 92 | th, td 93 | padding: 2px 10px 94 | 95 | .text-right 96 | text-align: right 97 | 98 | .node-disclaimer 99 | font-size: 12px 100 | width: 50% 101 | text-align: center 102 | margin-bottom: 15px 103 | 104 | a 105 | color: rgba(74, 144, 226, 0.8) 106 | text-decoration: none 107 | 108 | &:hover 109 | color: rgba(74, 144, 226, 1) 110 | 111 | .credits 112 | display: flex 113 | align-items: center 114 | flex-direction: column 115 | margin-top: 100px 116 | padding-bottom: 50px 117 | 118 | @media (max-width: 768px) 119 | .hide-mobile 120 | display: none 121 | 122 | .setting-container:last-child 123 | margin-right: 15px 124 | 125 | .credits 126 | font-size: 11px 127 | 128 | .table-section-table 129 | width: 100vw 130 | table-layout: fixed 131 | whitespace: nowrap 132 | overflow: hidden 133 | 134 | .table-section-table 135 | width: 100% 136 | 137 | td:first-child 138 | overflow: hidden 139 | text-overflow: ellipsis 140 | white-space: nowrap 141 | 142 | .stat-header 143 | font-size: 16px 144 | 145 | .stat 146 | font-size: 18px 147 | 148 | .node-disclaimer 149 | width: 90% 150 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/testing'; 2 | 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { 5 | BrowserDynamicTestingModule, 6 | platformBrowserDynamicTesting, 7 | } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | declare const require: { 10 | context(path: string, deep?: boolean, filter?: RegExp): { 11 | keys(): string[]; 12 | (id: string): T; 13 | }; 14 | }; 15 | 16 | getTestBed().initTestEnvironment( 17 | BrowserDynamicTestingModule, 18 | platformBrowserDynamicTesting(), 19 | ); 20 | 21 | const context = require.context('./', true, /\.spec\.ts$/); 22 | context.keys().map(context); 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": false, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "downlevelIteration": true, 14 | "experimentalDecorators": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "allowSyntheticDefaultImports": true, 18 | "target": "es2017", 19 | "module": "es2020", 20 | "lib": [ 21 | "es2018", 22 | "dom" 23 | ] 24 | }, 25 | "angularCompilerOptions": { 26 | "enableI18nLegacyMessageIdFormat": false, 27 | "strictInjectionParameters": true, 28 | "strictInputAccessModifiers": true, 29 | "strictTemplates": true 30 | } 31 | } -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } --------------------------------------------------------------------------------