mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge 650de97578 into 3b6ed8bde7
This commit is contained in:
commit
955c1cf6c2
8 changed files with 735 additions and 103 deletions
|
|
@ -5,6 +5,7 @@ import { BlockViewComponent } from '@components/block-view/block-view.component'
|
|||
import { EightBlocksComponent } from '@components/eight-blocks/eight-blocks.component';
|
||||
import { MempoolBlockViewComponent } from '@components/mempool-block-view/mempool-block-view.component';
|
||||
import { ClockComponent } from '@components/clock/clock.component';
|
||||
import { DifficultyFullscreenComponent } from '@components/difficulty-fullscreen/difficulty-fullscreen.component';
|
||||
import { StatusViewComponent } from '@components/status-view/status-view.component';
|
||||
import { AddressGroupComponent } from '@components/address-group/address-group.component';
|
||||
import { TrackerGuard } from '@app/route-guards';
|
||||
|
|
@ -253,6 +254,10 @@ let routes: Routes = [
|
|||
path: 'clock/:mode/:index',
|
||||
component: ClockComponent,
|
||||
},
|
||||
{
|
||||
path: 'difficulty',
|
||||
component: DifficultyFullscreenComponent,
|
||||
},
|
||||
{
|
||||
path: 'view/block/:id',
|
||||
component: BlockViewComponent,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<div class="difficulty-fullscreen-wrapper">
|
||||
<ng-container *ngIf="(isLoadingWebSocket$ | async) === false && (difficultyEpoch$ | async) as epochData; else loading">
|
||||
<div class="header">
|
||||
<div class="stats">
|
||||
<div class="item">
|
||||
<div class="value">~<app-time [time]="epochData.timeAvg / 1000" [fractionDigits]="1"></app-time></div>
|
||||
<div class="label" i18n="difficulty-box.average-block-time">Average block time</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="value" [ngStyle]="{'color': epochData.colorAdjustments}">
|
||||
<span *ngIf="epochData.change > 0; else arrowDownDifficulty">
|
||||
<fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon>
|
||||
</span>
|
||||
<ng-template #arrowDownDifficulty>
|
||||
<fa-icon class="retarget-sign" [icon]="['fas', 'caret-down']" [fixedWidth]="true"></fa-icon>
|
||||
</ng-template>
|
||||
{{ epochData.change | absolute | number: '1.2-2' }}<span class="symbol">%</span>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span i18n="difficulty-box.previous">Previous</span>:
|
||||
<span [ngStyle]="{'color': epochData.colorPreviousAdjustments}">
|
||||
{{ epochData.previousRetarget | absolute | number: '1.2-2' }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="value">{{ epochData.minedBlocks | number }} / {{ epochBlockLength | number }}</div>
|
||||
<div class="label" i18n="difficulty-box.blocks-mined">Blocks mined</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="value"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true" [precision]="1"></app-time></div>
|
||||
<div class="label">{{ epochData.retargetDateString }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="epoch-canvas-wrapper">
|
||||
<canvas #epochCanvas class="epoch-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<span class="swatch mined"></span>
|
||||
<span class="legend-label" i18n="difficulty.legend.mined-expected">Mined & expected</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="swatch ahead"></span>
|
||||
<span class="legend-label" i18n="difficulty.legend.mined-unexpected">Mined & unexpected</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="swatch behind"></span>
|
||||
<span class="legend-label" i18n="difficulty.legend.unmined-expected">Unmined & expected</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="swatch next"></span>
|
||||
<span class="legend-label" i18n="difficulty.legend.being-mined">Being mined</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<div class="loading-wrapper">
|
||||
<div class="skeleton-loader"></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
.difficulty-fullscreen-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
background: #11131f;
|
||||
|
||||
--block-size: 32px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding: 16px 24px 8px;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: clamp(1.2rem, 2.4vw, 2rem);
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px 48px;
|
||||
|
||||
.item {
|
||||
min-width: 120px;
|
||||
|
||||
.value {
|
||||
font-size: clamp(1rem, 1.8vw, 1.6rem);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
|
||||
.symbol {
|
||||
font-size: 0.7em;
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: clamp(0.65rem, 1vw, 0.85rem);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retarget-sign {
|
||||
font-size: 0.8em;
|
||||
margin-right: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.epoch-canvas-wrapper {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.epoch-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px 28px;
|
||||
padding: 12px 24px 18px;
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #232838;
|
||||
|
||||
&.mined {
|
||||
background: linear-gradient(135deg, var(--primary), var(--mainnet-alt));
|
||||
}
|
||||
&.ahead {
|
||||
background: var(--success);
|
||||
}
|
||||
&.behind {
|
||||
background: var(--red);
|
||||
}
|
||||
&.next {
|
||||
background: #2d3348;
|
||||
animation: legend-next-pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: clamp(0.7rem, 1vw, 0.9rem);
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legend-next-pulse {
|
||||
0% { background: #2d3348; }
|
||||
50% { background: #ffffff; }
|
||||
100% { background: #2d3348; }
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
.skeleton-loader {
|
||||
width: 60%;
|
||||
max-width: 600px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { combineLatest, Observable, Subscription } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
import { WebsocketService } from '@app/services/websocket.service';
|
||||
import { BlockStatus, EPOCH_BLOCK_LENGTH, EpochProgress, getEpochProgress, getEpochState } from '@app/shared/difficulty.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-difficulty-fullscreen',
|
||||
templateUrl: './difficulty-fullscreen.component.html',
|
||||
styleUrls: ['./difficulty-fullscreen.component.scss'],
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DifficultyFullscreenComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@ViewChild('epochCanvas') canvas: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
readonly epochBlockLength = EPOCH_BLOCK_LENGTH;
|
||||
|
||||
isLoadingWebSocket$: Observable<boolean>;
|
||||
difficultyEpoch$: Observable<EpochProgress>;
|
||||
epochSubscription: Subscription;
|
||||
|
||||
now: number = Date.now();
|
||||
epochStart: number;
|
||||
currentHeight: number;
|
||||
currentIndex: number;
|
||||
expectedHeight: number;
|
||||
expectedIndex: number;
|
||||
difference: number;
|
||||
|
||||
// canvas layout
|
||||
columns: number = 48;
|
||||
rows: number = 42;
|
||||
cellSize: number = 32;
|
||||
gap: number = 2;
|
||||
dpr: number = 1;
|
||||
|
||||
// theme colors resolved from CSS variables
|
||||
private colors = {
|
||||
primary: '#105fb0',
|
||||
mainnetAlt: '#9339f4',
|
||||
red: '#dc3545',
|
||||
green: '#3bcc49',
|
||||
background: '#11131f',
|
||||
};
|
||||
|
||||
private animationFrame: number;
|
||||
private pulse: number = 0;
|
||||
private hasNextBlock: boolean = false;
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private websocketService: WebsocketService,
|
||||
private cd: ChangeDetectorRef,
|
||||
@Inject(LOCALE_ID) private locale: string,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.websocketService.want(['blocks', 'stats']);
|
||||
|
||||
this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$;
|
||||
this.difficultyEpoch$ = combineLatest([
|
||||
this.stateService.blocks$,
|
||||
this.stateService.difficultyAdjustment$,
|
||||
])
|
||||
.pipe(
|
||||
map(([, da]) => {
|
||||
const epoch = getEpochState(this.stateService.latestBlockHeight, da);
|
||||
this.now = new Date().getTime();
|
||||
|
||||
if (epoch.epochStart !== this.epochStart || epoch.expectedHeight !== this.expectedHeight || epoch.currentHeight !== this.currentHeight) {
|
||||
this.epochStart = epoch.epochStart;
|
||||
this.expectedHeight = epoch.expectedHeight;
|
||||
this.currentHeight = epoch.currentHeight;
|
||||
this.currentIndex = epoch.currentIndex;
|
||||
this.expectedIndex = epoch.expectedIndex;
|
||||
this.difference = epoch.difference;
|
||||
this.hasNextBlock = this.currentIndex + 1 < EPOCH_BLOCK_LENGTH;
|
||||
}
|
||||
|
||||
const data: EpochProgress = {
|
||||
...getEpochProgress(da, this.locale),
|
||||
minedBlocks: this.currentIndex + 1,
|
||||
};
|
||||
return data;
|
||||
})
|
||||
);
|
||||
|
||||
if (this.stateService.isBrowser) {
|
||||
// Redraw whenever the epoch data changes.
|
||||
this.epochSubscription = this.difficultyEpoch$.subscribe(() => {
|
||||
this.resizeCanvas();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// The canvas lives behind an *ngIf and is only available after the view is
|
||||
// initialized, so do the first draw and start the animation here.
|
||||
if (this.stateService.isBrowser) {
|
||||
this.resizeCanvas();
|
||||
this.startAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.epochSubscription) {
|
||||
this.epochSubscription.unsubscribe();
|
||||
}
|
||||
if (this.animationFrame) {
|
||||
cancelAnimationFrame(this.animationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
statusForIndex(i: number): BlockStatus {
|
||||
if (i <= this.currentIndex) {
|
||||
return i > this.expectedIndex ? 'ahead' : 'mined';
|
||||
}
|
||||
if (i === this.currentIndex + 1) {
|
||||
return 'next';
|
||||
}
|
||||
return i <= this.expectedIndex ? 'behind' : 'remaining';
|
||||
}
|
||||
|
||||
startAnimation(): void {
|
||||
// Redraw at ~30fps rather than every frame: drawing all 2016 cells each
|
||||
// frame is wasteful, and the pulse animation reads fine at this rate.
|
||||
const minFrameInterval = 1000 / 30;
|
||||
let lastDraw = 0;
|
||||
const loop = (now: number): void => {
|
||||
this.animationFrame = requestAnimationFrame(loop);
|
||||
// Nothing animates once the epoch is full (no "next" block to pulse),
|
||||
// so skip the per-frame redraws entirely.
|
||||
if (!this.hasNextBlock) {
|
||||
return;
|
||||
}
|
||||
if (now - lastDraw < minFrameInterval) {
|
||||
return;
|
||||
}
|
||||
lastDraw = now;
|
||||
this.pulse = (Math.sin(Date.now() / 1000 * Math.PI) + 1) / 2; // 0..1, ~2s period
|
||||
this.draw();
|
||||
};
|
||||
this.animationFrame = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
if (!this.stateService.isBrowser) {
|
||||
return;
|
||||
}
|
||||
const canvasEl = this.canvas?.nativeElement;
|
||||
if (!canvasEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resolveColors();
|
||||
|
||||
const parent = canvasEl.parentElement;
|
||||
const width = parent ? parent.clientWidth : (window.innerWidth || 800);
|
||||
const height = parent ? parent.clientHeight : (window.innerHeight || 800);
|
||||
|
||||
// Choose a column count so that 2016 cells roughly fill the available area.
|
||||
const targetAspect = width / Math.max(1, height);
|
||||
const columns = Math.max(12, Math.min(EPOCH_BLOCK_LENGTH, Math.round(Math.sqrt(EPOCH_BLOCK_LENGTH * targetAspect))));
|
||||
const rows = Math.ceil(EPOCH_BLOCK_LENGTH / columns);
|
||||
const gap = 2;
|
||||
const sizeByWidth = Math.floor((width - (columns + 1) * gap) / columns);
|
||||
const sizeByHeight = Math.floor((height - (rows + 1) * gap) / rows);
|
||||
|
||||
this.columns = columns;
|
||||
this.rows = rows;
|
||||
this.gap = gap;
|
||||
this.cellSize = Math.max(2, Math.min(sizeByWidth, sizeByHeight));
|
||||
this.dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvasEl.width = Math.floor(width * this.dpr);
|
||||
canvasEl.height = Math.floor(height * this.dpr);
|
||||
canvasEl.style.width = `${width}px`;
|
||||
canvasEl.style.height = `${height}px`;
|
||||
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private resolveColors(): void {
|
||||
const root = this.canvas?.nativeElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(root);
|
||||
const read = (name: string, fallback: string): string => {
|
||||
const v = style.getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
};
|
||||
this.colors = {
|
||||
primary: read('--primary', this.colors.primary),
|
||||
mainnetAlt: read('--mainnet-alt', this.colors.mainnetAlt),
|
||||
red: read('--red', this.colors.red),
|
||||
green: read('--success', this.colors.green),
|
||||
background: read('--active-bg', this.colors.background) || this.colors.background,
|
||||
};
|
||||
}
|
||||
|
||||
draw(): void {
|
||||
const canvasEl = this.canvas?.nativeElement;
|
||||
if (!canvasEl || this.currentIndex === undefined || this.currentIndex === null) {
|
||||
return;
|
||||
}
|
||||
const ctx = canvasEl.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = this.dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const widthCss = canvasEl.width / dpr;
|
||||
const heightCss = canvasEl.height / dpr;
|
||||
ctx.clearRect(0, 0, widthCss, heightCss);
|
||||
|
||||
const cell = this.cellSize;
|
||||
const gap = this.gap;
|
||||
const gridWidth = this.columns * cell + (this.columns - 1) * gap;
|
||||
const gridHeight = this.rows * cell + (this.rows - 1) * gap;
|
||||
const offsetX = Math.max(0, (widthCss - gridWidth) / 2);
|
||||
const offsetY = Math.max(0, (heightCss - gridHeight) / 2);
|
||||
|
||||
for (let i = 0; i < EPOCH_BLOCK_LENGTH; i++) {
|
||||
const col = i % this.columns;
|
||||
const row = Math.floor(i / this.columns);
|
||||
const x = offsetX + col * (cell + gap);
|
||||
const y = offsetY + row * (cell + gap);
|
||||
this.drawBlock(ctx, x, y, cell, this.statusForIndex(i));
|
||||
}
|
||||
}
|
||||
|
||||
private drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, status: BlockStatus): void {
|
||||
let frontTop: string;
|
||||
let frontBottom: string;
|
||||
let topFace: string;
|
||||
let rightFace: string;
|
||||
|
||||
switch (status) {
|
||||
case 'mined':
|
||||
frontTop = this.colors.primary;
|
||||
frontBottom = this.colors.mainnetAlt;
|
||||
topFace = this.shade(this.colors.primary, 0.25);
|
||||
rightFace = this.shade(this.colors.mainnetAlt, -0.25);
|
||||
break;
|
||||
case 'ahead':
|
||||
// mined faster than expected — green like the mempool blocks
|
||||
frontTop = this.shade(this.colors.green, 0.1);
|
||||
frontBottom = this.shade(this.colors.green, -0.12);
|
||||
topFace = this.shade(this.colors.green, 0.25);
|
||||
rightFace = this.shade(this.colors.green, -0.3);
|
||||
break;
|
||||
case 'behind':
|
||||
// expected by now but not yet mined — red
|
||||
frontTop = this.shade(this.colors.red, 0.1);
|
||||
frontBottom = this.shade(this.colors.red, -0.12);
|
||||
topFace = this.shade(this.colors.red, 0.25);
|
||||
rightFace = this.shade(this.colors.red, -0.3);
|
||||
break;
|
||||
case 'next': {
|
||||
// pulsing white highlight (matches the old difficulty component)
|
||||
const p = this.pulse;
|
||||
frontTop = this.mix('#2d3348', '#ffffff', p);
|
||||
frontBottom = this.mix('#20253a', '#ffffff', p);
|
||||
topFace = this.mix('#3a4258', '#ffffff', p);
|
||||
rightFace = this.mix('#191d2c', '#ffffff', p);
|
||||
break;
|
||||
}
|
||||
case 'remaining':
|
||||
default:
|
||||
frontTop = '#1b1e2e';
|
||||
frontBottom = '#15182b';
|
||||
topFace = '#232838';
|
||||
rightFace = '#101220';
|
||||
break;
|
||||
}
|
||||
|
||||
// Small isometric bevel proportional to the cell size.
|
||||
const depth = Math.max(1, Math.round(size * 0.18));
|
||||
const fx = x;
|
||||
const fy = y + depth;
|
||||
const fw = size - depth;
|
||||
const fh = size - depth;
|
||||
|
||||
// Top face (parallelogram)
|
||||
ctx.fillStyle = topFace;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fx, fy);
|
||||
ctx.lineTo(fx + depth, y);
|
||||
ctx.lineTo(fx + depth + fw, y);
|
||||
ctx.lineTo(fx + fw, fy);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Right face (parallelogram)
|
||||
ctx.fillStyle = rightFace;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(fx + fw, fy);
|
||||
ctx.lineTo(fx + fw + depth, y);
|
||||
ctx.lineTo(fx + fw + depth, y + fh);
|
||||
ctx.lineTo(fx + fw, fy + fh);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Front face (gradient, like the blockchain blocks)
|
||||
if (fw > 3 && fh > 3) {
|
||||
const grad = ctx.createLinearGradient(fx, fy, fx, fy + fh);
|
||||
grad.addColorStop(0, frontTop);
|
||||
grad.addColorStop(1, frontBottom);
|
||||
ctx.fillStyle = grad;
|
||||
} else {
|
||||
ctx.fillStyle = frontBottom;
|
||||
}
|
||||
ctx.fillRect(fx, fy, fw, fh);
|
||||
}
|
||||
|
||||
// Lighten (amount > 0) or darken (amount < 0) a hex color.
|
||||
private shade(hex: string, amount: number): string {
|
||||
const c = this.parseColor(hex);
|
||||
if (!c) {
|
||||
return hex;
|
||||
}
|
||||
const adj = (v: number): number => Math.max(0, Math.min(255, Math.round(v + amount * 255)));
|
||||
return `rgb(${adj(c.r)}, ${adj(c.g)}, ${adj(c.b)})`;
|
||||
}
|
||||
|
||||
// Linear blend between two colors. t = 0 -> a, t = 1 -> b.
|
||||
private mix(a: string, b: string, t: number): string {
|
||||
const ca = this.parseColor(a);
|
||||
const cb = this.parseColor(b);
|
||||
if (!ca || !cb) {
|
||||
return a;
|
||||
}
|
||||
const m = (x: number, y: number): number => Math.round(x + (y - x) * t);
|
||||
return `rgb(${m(ca.r, cb.r)}, ${m(ca.g, cb.g)}, ${m(ca.b, cb.b)})`;
|
||||
}
|
||||
|
||||
private parseColor(input: string): { r: number; g: number; b: number } | null {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const str = input.trim();
|
||||
if (str.startsWith('#')) {
|
||||
let hex = str.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map(ch => ch + ch).join('');
|
||||
}
|
||||
if (hex.length >= 6) {
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const match = str.match(/rgba?\(([^)]+)\)/);
|
||||
if (match) {
|
||||
const parts = match[1].split(',').map(p => parseFloat(p));
|
||||
if (parts.length >= 3) {
|
||||
return { r: parts[0], g: parts[1], b: parts[2] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,5 @@
|
|||
import { Component, ElementRef, ViewChild, Input, OnChanges, HostListener } from '@angular/core';
|
||||
|
||||
interface EpochProgress {
|
||||
base: string;
|
||||
change: number;
|
||||
progress: number;
|
||||
minedBlocks: number;
|
||||
remainingBlocks: number;
|
||||
expectedBlocks: number;
|
||||
newDifficultyHeight: number;
|
||||
colorAdjustments: string;
|
||||
colorPreviousAdjustments: string;
|
||||
estimatedRetargetDate: number;
|
||||
previousRetarget: number;
|
||||
blocksUntilHalving: number;
|
||||
timeUntilHalving: number;
|
||||
}
|
||||
|
||||
const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet
|
||||
import { EpochProgress } from '@app/shared/difficulty.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-difficulty-tooltip',
|
||||
|
|
|
|||
|
|
@ -2,27 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, El
|
|||
import { combineLatest, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { StateService } from '@app/services/state.service';
|
||||
|
||||
interface EpochProgress {
|
||||
base: string;
|
||||
change: number;
|
||||
progress: number;
|
||||
minedBlocks: number;
|
||||
remainingBlocks: number;
|
||||
expectedBlocks: number;
|
||||
newDifficultyHeight: number;
|
||||
colorAdjustments: string;
|
||||
colorPreviousAdjustments: string;
|
||||
estimatedRetargetDate: number;
|
||||
retargetDateString: string;
|
||||
previousRetarget: number;
|
||||
blocksUntilHalving: number;
|
||||
timeUntilHalving: number;
|
||||
timeAvg: number;
|
||||
adjustedTimeAvg: number;
|
||||
}
|
||||
|
||||
type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining';
|
||||
import { BlockStatus, EpochProgress, getEpochProgress, getEpochState, getNextBlockSubsidy } from '@app/shared/difficulty.utils';
|
||||
|
||||
interface DiffShape {
|
||||
x: number;
|
||||
|
|
@ -33,8 +13,6 @@ interface DiffShape {
|
|||
expected: boolean;
|
||||
}
|
||||
|
||||
const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet
|
||||
|
||||
@Component({
|
||||
selector: 'app-difficulty',
|
||||
templateUrl: './difficulty.component.html',
|
||||
|
|
@ -83,30 +61,10 @@ export class DifficultyComponent implements OnInit {
|
|||
.pipe(
|
||||
map(([blocks, da]) => {
|
||||
const maxHeight = blocks.reduce((max, block) => Math.max(max, block.height), 0);
|
||||
let colorAdjustments = 'var(--transparent-fg)';
|
||||
if (da.difficultyChange > 0) {
|
||||
colorAdjustments = 'var(--green)';
|
||||
}
|
||||
if (da.difficultyChange < 0) {
|
||||
colorAdjustments = 'var(--red)';
|
||||
}
|
||||
|
||||
let colorPreviousAdjustments = 'var(--red)';
|
||||
if (da.previousRetarget) {
|
||||
if (da.previousRetarget >= 0) {
|
||||
colorPreviousAdjustments = 'var(--green)';
|
||||
}
|
||||
if (da.previousRetarget === 0) {
|
||||
colorPreviousAdjustments = 'var(--transparent-fg)';
|
||||
}
|
||||
} else {
|
||||
colorPreviousAdjustments = 'var(--transparent-fg)';
|
||||
}
|
||||
|
||||
const blocksUntilHalving = 210000 - (maxHeight % 210000);
|
||||
const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000);
|
||||
const newEpochStart = Math.floor(this.stateService.latestBlockHeight / EPOCH_BLOCK_LENGTH) * EPOCH_BLOCK_LENGTH;
|
||||
const newExpectedHeight = Math.floor(newEpochStart + da.expectedBlocks);
|
||||
const epoch = getEpochState(this.stateService.latestBlockHeight, da);
|
||||
this.now = new Date().getTime();
|
||||
this.nextSubsidy = getNextBlockSubsidy(maxHeight);
|
||||
|
||||
|
|
@ -114,13 +72,13 @@ export class DifficultyComponent implements OnInit {
|
|||
this.mode = 'halving';
|
||||
}
|
||||
|
||||
if (newEpochStart !== this.epochStart || newExpectedHeight !== this.expectedHeight || this.currentHeight !== this.stateService.latestBlockHeight) {
|
||||
this.epochStart = newEpochStart;
|
||||
this.expectedHeight = newExpectedHeight;
|
||||
this.currentHeight = this.stateService.latestBlockHeight;
|
||||
this.currentIndex = this.currentHeight - this.epochStart;
|
||||
this.expectedIndex = Math.min(this.expectedHeight - this.epochStart, 2016) - 1;
|
||||
this.difference = this.currentIndex - this.expectedIndex;
|
||||
if (epoch.epochStart !== this.epochStart || epoch.expectedHeight !== this.expectedHeight || epoch.currentHeight !== this.currentHeight) {
|
||||
this.epochStart = epoch.epochStart;
|
||||
this.expectedHeight = epoch.expectedHeight;
|
||||
this.currentHeight = epoch.currentHeight;
|
||||
this.currentIndex = epoch.currentIndex;
|
||||
this.expectedIndex = epoch.expectedIndex;
|
||||
this.difference = epoch.difference;
|
||||
|
||||
this.shapes = [];
|
||||
this.shapes = this.shapes.concat(this.blocksToShapes(
|
||||
|
|
@ -143,30 +101,11 @@ export class DifficultyComponent implements OnInit {
|
|||
}
|
||||
|
||||
|
||||
let retargetDateString;
|
||||
if (da.remainingBlocks > 1870) {
|
||||
retargetDateString = (new Date(da.estimatedRetargetDate)).toLocaleDateString(this.locale, { month: 'long', day: 'numeric' });
|
||||
} else {
|
||||
retargetDateString = (new Date(da.estimatedRetargetDate)).toLocaleTimeString(this.locale, { month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
|
||||
}
|
||||
|
||||
const data = {
|
||||
base: `${da.progressPercent.toFixed(2)}%`,
|
||||
change: da.difficultyChange,
|
||||
progress: da.progressPercent,
|
||||
const data: EpochProgress = {
|
||||
...getEpochProgress(da, this.locale),
|
||||
minedBlocks: this.currentIndex,
|
||||
remainingBlocks: da.remainingBlocks,
|
||||
expectedBlocks: Math.floor(da.expectedBlocks),
|
||||
colorAdjustments,
|
||||
colorPreviousAdjustments,
|
||||
newDifficultyHeight: da.nextRetargetHeight,
|
||||
estimatedRetargetDate: da.estimatedRetargetDate,
|
||||
retargetDateString,
|
||||
previousRetarget: da.previousRetarget,
|
||||
blocksUntilHalving,
|
||||
timeUntilHalving,
|
||||
timeAvg: da.timeAvg,
|
||||
adjustedTimeAvg: da.adjustedTimeAvg,
|
||||
};
|
||||
return data;
|
||||
})
|
||||
|
|
@ -236,16 +175,3 @@ export class DifficultyComponent implements OnInit {
|
|||
this.hoverSection = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getNextBlockSubsidy(height: number): number {
|
||||
const halvings = Math.floor(height / 210_000) + 1;
|
||||
// Force block reward to zero when right shift is undefined.
|
||||
if (halvings >= 64) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let subsidy = BigInt(50 * 100_000_000);
|
||||
// Subsidy is cut in half every 210,000 blocks which will occur approximately every 4 years.
|
||||
subsidy >>= BigInt(halvings);
|
||||
return Number(subsidy);
|
||||
}
|
||||
|
|
|
|||
130
frontend/src/app/shared/difficulty.utils.ts
Normal file
130
frontend/src/app/shared/difficulty.utils.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { DifficultyAdjustment } from '@interfaces/node-api.interface';
|
||||
|
||||
export const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet
|
||||
|
||||
export type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining';
|
||||
|
||||
export interface EpochState {
|
||||
epochStart: number;
|
||||
currentHeight: number;
|
||||
currentIndex: number;
|
||||
expectedHeight: number;
|
||||
expectedIndex: number;
|
||||
difference: number;
|
||||
}
|
||||
|
||||
export interface EpochProgress {
|
||||
base: string;
|
||||
change: number;
|
||||
progress: number;
|
||||
remainingBlocks: number;
|
||||
expectedBlocks: number;
|
||||
newDifficultyHeight: number;
|
||||
colorAdjustments: string;
|
||||
colorPreviousAdjustments: string;
|
||||
estimatedRetargetDate: number;
|
||||
retargetDateString: string;
|
||||
previousRetarget: number;
|
||||
timeAvg: number;
|
||||
adjustedTimeAvg: number;
|
||||
// set by callers that render them
|
||||
minedBlocks?: number;
|
||||
blocksUntilHalving?: number;
|
||||
timeUntilHalving?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the colors used to highlight the upcoming and previous difficulty
|
||||
* adjustments, based on whether they are positive, negative or neutral.
|
||||
*/
|
||||
export function getAdjustmentColors(da: DifficultyAdjustment): {
|
||||
colorAdjustments: string;
|
||||
colorPreviousAdjustments: string;
|
||||
} {
|
||||
let colorAdjustments = 'var(--transparent-fg)';
|
||||
if (da.difficultyChange > 0) {
|
||||
colorAdjustments = 'var(--green)';
|
||||
}
|
||||
if (da.difficultyChange < 0) {
|
||||
colorAdjustments = 'var(--red)';
|
||||
}
|
||||
|
||||
let colorPreviousAdjustments = 'var(--red)';
|
||||
if (da.previousRetarget) {
|
||||
if (da.previousRetarget >= 0) {
|
||||
colorPreviousAdjustments = 'var(--green)';
|
||||
}
|
||||
if (da.previousRetarget === 0) {
|
||||
colorPreviousAdjustments = 'var(--transparent-fg)';
|
||||
}
|
||||
} else {
|
||||
colorPreviousAdjustments = 'var(--transparent-fg)';
|
||||
}
|
||||
|
||||
return { colorAdjustments, colorPreviousAdjustments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the epoch state (start height, current/expected indices and their
|
||||
* difference) for the current difficulty epoch.
|
||||
*/
|
||||
export function getEpochState(latestBlockHeight: number, da: DifficultyAdjustment): EpochState {
|
||||
const epochStart = Math.floor(latestBlockHeight / EPOCH_BLOCK_LENGTH) * EPOCH_BLOCK_LENGTH;
|
||||
const expectedHeight = Math.floor(epochStart + da.expectedBlocks);
|
||||
const currentHeight = latestBlockHeight;
|
||||
const currentIndex = currentHeight - epochStart;
|
||||
const expectedIndex = Math.min(expectedHeight - epochStart, EPOCH_BLOCK_LENGTH) - 1;
|
||||
const difference = currentIndex - expectedIndex;
|
||||
|
||||
return { epochStart, currentHeight, currentIndex, expectedHeight, expectedIndex, difference };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the estimated retarget date. Once the retarget is close enough
|
||||
* (fewer than 1870 remaining blocks) the time of day is included as well.
|
||||
*/
|
||||
export function getRetargetDateString(da: DifficultyAdjustment, locale: string): string {
|
||||
const date = new Date(da.estimatedRetargetDate);
|
||||
if (da.remainingBlocks > 1870) {
|
||||
return date.toLocaleDateString(locale, { month: 'long', day: 'numeric' });
|
||||
}
|
||||
// toLocaleString (not toLocaleTimeString) so month/day are reliably included alongside the time.
|
||||
return date.toLocaleString(locale, { month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the common difficulty-epoch progress object shared by the difficulty
|
||||
* components. Caller-specific fields (minedBlocks and the halving fields) are
|
||||
* added by each component on top of the returned object.
|
||||
*/
|
||||
export function getEpochProgress(da: DifficultyAdjustment, locale: string): EpochProgress {
|
||||
const { colorAdjustments, colorPreviousAdjustments } = getAdjustmentColors(da);
|
||||
return {
|
||||
base: `${da.progressPercent.toFixed(2)}%`,
|
||||
change: da.difficultyChange,
|
||||
progress: da.progressPercent,
|
||||
remainingBlocks: da.remainingBlocks,
|
||||
expectedBlocks: Math.floor(da.expectedBlocks),
|
||||
colorAdjustments,
|
||||
colorPreviousAdjustments,
|
||||
newDifficultyHeight: da.nextRetargetHeight,
|
||||
estimatedRetargetDate: da.estimatedRetargetDate,
|
||||
retargetDateString: getRetargetDateString(da, locale),
|
||||
previousRetarget: da.previousRetarget,
|
||||
timeAvg: da.timeAvg,
|
||||
adjustedTimeAvg: da.adjustedTimeAvg,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextBlockSubsidy(height: number): number {
|
||||
const halvings = Math.floor(height / 210_000) + 1;
|
||||
// Force block reward to zero when right shift is undefined.
|
||||
if (halvings >= 64) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let subsidy = BigInt(50 * 100_000_000);
|
||||
// Subsidy is cut in half every 210,000 blocks which will occur approximately every 4 years.
|
||||
subsidy >>= BigInt(halvings);
|
||||
return Number(subsidy);
|
||||
}
|
||||
|
|
@ -122,6 +122,7 @@ import { MempoolBlockOverviewComponent } from '@components/mempool-block-overvie
|
|||
import { ClockchainComponent } from '@components/clockchain/clockchain.component';
|
||||
import { ClockFaceComponent } from '@components/clock-face/clock-face.component';
|
||||
import { ClockComponent } from '@components/clock/clock.component';
|
||||
import { DifficultyFullscreenComponent } from '@components/difficulty-fullscreen/difficulty-fullscreen.component';
|
||||
import { CalculatorComponent } from '@components/calculator/calculator.component';
|
||||
import { BitcoinsatoshisPipe } from '@app/shared/pipes/bitcoinsatoshis.pipe';
|
||||
import { HttpErrorComponent } from '@app/shared/components/http-error/http-error.component';
|
||||
|
|
@ -243,6 +244,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com
|
|||
MempoolBlockOverviewComponent,
|
||||
ClockchainComponent,
|
||||
ClockComponent,
|
||||
DifficultyFullscreenComponent,
|
||||
ClockFaceComponent,
|
||||
OnlyVsizeDirective,
|
||||
OnlyWeightDirective,
|
||||
|
|
@ -409,6 +411,7 @@ import { GithubLogin } from '@components/github-login.component/github-login.com
|
|||
MempoolBlockOverviewComponent,
|
||||
ClockchainComponent,
|
||||
ClockComponent,
|
||||
DifficultyFullscreenComponent,
|
||||
ClockFaceComponent,
|
||||
|
||||
OnlyVsizeDirective,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue