From 648903874a90278d50cdc93f126fb38d537fa12e Mon Sep 17 00:00:00 2001 From: softsimon Date: Fri, 29 May 2026 21:53:03 +0800 Subject: [PATCH 1/2] Full screen difficulty estimator --- frontend/src/app/app-routing.module.ts | 5 + .../difficulty-fullscreen.component.html | 66 ++++ .../difficulty-fullscreen.component.scss | 148 ++++++++ .../difficulty-fullscreen.component.ts | 348 ++++++++++++++++++ .../difficulty-tooltip.component.ts | 19 +- .../difficulty/difficulty.component.ts | 96 +---- frontend/src/app/shared/difficulty.utils.ts | 128 +++++++ frontend/src/app/shared/shared.module.ts | 3 + 8 files changed, 710 insertions(+), 103 deletions(-) create mode 100644 frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html create mode 100644 frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.scss create mode 100644 frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts create mode 100644 frontend/src/app/shared/difficulty.utils.ts 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..2d57d0252 --- /dev/null +++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html @@ -0,0 +1,66 @@ +
+ +
+
+
+
~
+
Average block time
+
+
+
+ + + + + + + {{ epochData.change | absolute | number: '1.2-2' }}% +
+
+ Previous: + + {{ epochData.previousRetarget | absolute | number: '1.2-2' }}% + +
+
+
+
{{ epochData.minedBlocks | number }} / {{ 2016 | number }}
+
Blocks mined
+
+
+
+
{{ epochData.retargetDateString }}
+
+
+
+ +
+ +
+ +
+
+ + 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..8b4c5686f --- /dev/null +++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts @@ -0,0 +1,348 @@ +import { 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, OnDestroy { + @ViewChild('epochCanvas') canvas: ElementRef; + + 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(); + }); + 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 { + const loop = (): void => { + this.pulse = (Math.sin(Date.now() / 1000 * Math.PI) + 1) / 2; // 0..1, ~2s period + this.draw(); + this.animationFrame = requestAnimationFrame(loop); + }; + 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..9ba4b0659 --- /dev/null +++ b/frontend/src/app/shared/difficulty.utils.ts @@ -0,0 +1,128 @@ +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 { + if (da.remainingBlocks > 1870) { + return (new Date(da.estimatedRetargetDate)).toLocaleDateString(locale, { month: 'long', day: 'numeric' }); + } + return (new Date(da.estimatedRetargetDate)).toLocaleTimeString(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, From 650de975786f44cb6c5eb574bfac2279c3897973 Mon Sep 17 00:00:00 2001 From: softsimon Date: Sat, 30 May 2026 19:45:00 +0800 Subject: [PATCH 2/2] Feedback fixes --- .../difficulty-fullscreen.component.html | 2 +- .../difficulty-fullscreen.component.ts | 31 ++++++++++++++++--- frontend/src/app/shared/difficulty.utils.ts | 6 ++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html index 2d57d0252..513c43b63 100644 --- a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html +++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.html @@ -24,7 +24,7 @@
-
{{ epochData.minedBlocks | number }} / {{ 2016 | number }}
+
{{ epochData.minedBlocks | number }} / {{ epochBlockLength | number }}
Blocks mined
diff --git a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts index 8b4c5686f..4a64c10fb 100644 --- a/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts +++ b/frontend/src/app/components/difficulty-fullscreen/difficulty-fullscreen.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, LOCALE_ID, OnDestroy, OnInit, ViewChild } from '@angular/core'; +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'; @@ -12,9 +12,11 @@ import { BlockStatus, EPOCH_BLOCK_LENGTH, EpochProgress, getEpochProgress, getEp standalone: false, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DifficultyFullscreenComponent implements OnInit, OnDestroy { +export class DifficultyFullscreenComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('epochCanvas') canvas: ElementRef; + readonly epochBlockLength = EPOCH_BLOCK_LENGTH; + isLoadingWebSocket$: Observable; difficultyEpoch$: Observable; epochSubscription: Subscription; @@ -90,6 +92,14 @@ export class DifficultyFullscreenComponent implements OnInit, OnDestroy { 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(); } } @@ -114,10 +124,23 @@ export class DifficultyFullscreenComponent implements OnInit, OnDestroy { } startAnimation(): void { - const loop = (): 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); }; this.animationFrame = requestAnimationFrame(loop); } diff --git a/frontend/src/app/shared/difficulty.utils.ts b/frontend/src/app/shared/difficulty.utils.ts index 9ba4b0659..a01a5b70a 100644 --- a/frontend/src/app/shared/difficulty.utils.ts +++ b/frontend/src/app/shared/difficulty.utils.ts @@ -84,10 +84,12 @@ export function getEpochState(latestBlockHeight: number, da: DifficultyAdjustmen * (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 (new Date(da.estimatedRetargetDate)).toLocaleDateString(locale, { month: 'long', day: 'numeric' }); + return date.toLocaleDateString(locale, { month: 'long', day: 'numeric' }); } - return (new Date(da.estimatedRetargetDate)).toLocaleTimeString(locale, { month: 'long', day: 'numeric', hour: 'numeric', minute: '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' }); } /**