├── .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 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | {{ blocks - stoppedElections - confirmations }} / {{ stoppedElections }}
37 |
38 |
39 |
40 | {{ confirmations }} ({{ cps }})
41 |
42 |
43 |
44 |
45 |
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 | Alias
53 | Online weight %
54 | Vote count
55 | Participation %
56 |
57 |
58 | {{ rep.value.alias || rep.key }}
59 |
60 |
61 | {{ rep.value.weight | percent: '1.2-2' }}
62 |
63 |
64 | {{ rep.value.voteCount }}
65 |
66 |
67 | {{ rep.value.voteCount / blocks | percent: '1.2-2' }}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
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 | }
--------------------------------------------------------------------------------