diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 80efc5d6f..1b2ed36fc 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -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,
diff --git a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html
new file mode 100644
index 000000000..513c43b63
--- /dev/null
+++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+ Mined & expected
+
+
+
+ Mined & unexpected
+
+
+
+ Unmined & expected
+
+
+
+ Being mined
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.scss b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.scss
new file mode 100644
index 000000000..ba9e6b303
--- /dev/null
+++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.scss
@@ -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;
+ }
+}
diff --git a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts
new file mode 100644
index 000000000..4a64c10fb
--- /dev/null
+++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts
@@ -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;
+
+ readonly epochBlockLength = EPOCH_BLOCK_LENGTH;
+
+ isLoadingWebSocket$: Observable;
+ difficultyEpoch$: Observable;
+ 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;
+ }
+}
diff --git a/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts b/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts
index f889c68e1..e362f499d 100644
--- a/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts
+++ b/frontend/src/app/components/difficulty/difficulty-tooltip.component.ts
@@ -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',
diff --git a/frontend/src/app/components/difficulty/difficulty.component.ts b/frontend/src/app/components/difficulty/difficulty.component.ts
index 74347d6f0..b26e72756 100644
--- a/frontend/src/app/components/difficulty/difficulty.component.ts
+++ b/frontend/src/app/components/difficulty/difficulty.component.ts
@@ -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);
-}
diff --git a/frontend/src/app/shared/difficulty.utils.ts b/frontend/src/app/shared/difficulty.utils.ts
new file mode 100644
index 000000000..a01a5b70a
--- /dev/null
+++ b/frontend/src/app/shared/difficulty.utils.ts
@@ -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);
+}
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index 9fa98bc63..afeff08db 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -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,