This commit is contained in:
softsimon 2026-08-08 21:17:49 -07:00 committed by GitHub
commit 955c1cf6c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 735 additions and 103 deletions

View file

@ -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,

View file

@ -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 &amp; expected</span>
</div>
<div class="legend-item">
<span class="swatch ahead"></span>
<span class="legend-label" i18n="difficulty.legend.mined-unexpected">Mined &amp; unexpected</span>
</div>
<div class="legend-item">
<span class="swatch behind"></span>
<span class="legend-label" i18n="difficulty.legend.unmined-expected">Unmined &amp; 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>

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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);
}

View 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);
}

View file

@ -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,