diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 17105d97e..2af8117b8 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -1,4 +1,4 @@ -export const mempoolFeeColors = [ +export const defaultMempoolFeeColors = [ '557d00', '5d7d01', '637d02', @@ -39,6 +39,47 @@ export const mempoolFeeColors = [ 'ae005b', ]; +export const contrastMempoolFeeColors = [ + '83fd00', + '83f609', + '83ef12', + '83e71a', + '83e023', + '83d92c', + '83d235', + '83cb3e', + '83c446', + '83bc4f', + '83b558', + '83ae61', + '83a76a', + '83a072', + '83997b', + '839184', + '838a8d', + '838395', + '837c9e', + '8375a7', + '836eb0', + '8366b9', + '835fc1', + '8358ca', + '8351d3', + '834adc', + '8343e5', + '833bed', + '8334f6', + '832dff', + '832dff', + '832dff', + '832dff', + '832dff', + '832dff', + '832dff', + '832dff', + '832dff', +]; + export const chartColors = [ "#D81B60", "#8E24AA", diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 003531fce..7ab147345 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -7,6 +7,7 @@ import TxView from './tx-view'; import { Color, Position } from './sprite-types'; import { Price } from '../../services/price.service'; import { StateService } from '../../services/state.service'; +import { ThemeService } from 'src/app/services/theme.service'; import { Subscription } from 'rxjs'; import { defaultColorFunction, setOpacity, defaultFeeColors, defaultAuditFeeColors, defaultMarginalFeeColors, defaultAuditColors } from './utils'; import { ActiveFilter, FilterMode, toFlags } from '../../shared/filters.utils'; @@ -55,6 +56,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @ViewChild('blockCanvas') canvas: ElementRef; + themeChangedSubscription: Subscription; gl: WebGLRenderingContext; animationFrameRequest: number; @@ -86,6 +88,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On readonly ngZone: NgZone, readonly elRef: ElementRef, public stateService: StateService, + private themeService: ThemeService, ) { this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); @@ -104,6 +107,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.gl) { this.initCanvas(); this.resizeCanvas(); + this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => { + // force full re-render + this.resizeCanvas(); + }); } } } @@ -149,6 +156,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (this.canvas) { this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost); this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored); + this.themeChangedSubscription?.unsubscribe(); } } @@ -291,7 +299,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.start(); } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, - blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, + blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, theme: this.themeService, highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset, colorFunction: this.getColorFunction() }); this.start(); diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index 5d2196f1e..853301ee7 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -3,12 +3,14 @@ import TxView from './tx-view'; import { TransactionStripped } from '../../interfaces/node-api.interface'; import { Color, Position, Square, ViewUpdateParams } from './sprite-types'; import { defaultColorFunction } from './utils'; +import { ThemeService } from 'src/app/services/theme.service'; export default class BlockScene { scene: { count: number, offset: { x: number, y: number}}; vertexArray: FastVertexArray; txs: { [key: string]: TxView }; getColor: ((tx: TxView) => Color) = defaultColorFunction; + theme: ThemeService; orientation: string; flip: boolean; animationDuration: number = 900; @@ -29,11 +31,11 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: + constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ) { - this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }); + this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { @@ -90,7 +92,7 @@ export default class BlockScene { }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); txs.forEach(tx => { - const txView = new TxView(tx, this); + const txView = new TxView(tx, this, this.theme); this.txs[tx.txid] = txView; this.place(txView); this.saveGridToScreenPosition(txView); @@ -136,7 +138,7 @@ export default class BlockScene { }); txs.forEach(tx => { if (!this.txs[tx.txid]) { - this.txs[tx.txid] = new TxView(tx, this); + this.txs[tx.txid] = new TxView(tx, this, this.theme); } }); @@ -178,7 +180,7 @@ export default class BlockScene { if (resetLayout) { add.forEach(tx => { if (!this.txs[tx.txid]) { - this.txs[tx.txid] = new TxView(tx, this); + this.txs[tx.txid] = new TxView(tx, this, this.theme); } }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); @@ -198,7 +200,7 @@ export default class BlockScene { // try to insert new txs directly const remaining = []; - add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => { + add.map(tx => new TxView(tx, this, this.theme)).sort(feeRateDescending).forEach(tx => { if (!this.tryInsertByFee(tx)) { remaining.push(tx); } @@ -228,9 +230,9 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value)); } - private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting, colorFunction }: + private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }: { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ): void { this.animationDuration = animationDuration || 1000; this.configAnimationOffset = animationOffset; @@ -240,6 +242,7 @@ export default class BlockScene { this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; this.getColor = colorFunction || defaultColorFunction; + this.theme = theme; this.scene = { count: 0, diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 742c305f5..13de08aa6 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -5,6 +5,8 @@ import { hexToColor } from './utils'; import BlockScene from './block-scene'; import { TransactionStripped } from '../../interfaces/node-api.interface'; import { TransactionFlags } from '../../shared/filters.utils'; +import { feeLevels } from '../../app.constants'; +import { ThemeService } from 'src/app/services/theme.service'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); @@ -36,6 +38,7 @@ export default class TxView implements TransactionStripped { status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated'; context?: 'projected' | 'actual'; scene?: BlockScene; + theme: ThemeService; initialised: boolean; vertexArray: FastVertexArray; @@ -50,7 +53,7 @@ export default class TxView implements TransactionStripped { dirty: boolean; - constructor(tx: TransactionStripped, scene: BlockScene) { + constructor(tx: TransactionStripped, scene: BlockScene, theme: ThemeService) { this.scene = scene; this.context = tx.context; this.txid = tx.txid; @@ -66,6 +69,7 @@ export default class TxView implements TransactionStripped { this.bigintFlags = tx.flags ? (BigInt(tx.flags) | (this.acc ? TransactionFlags.acceleration : 0n)): 0n; this.initialised = false; this.vertexArray = scene.vertexArray; + this.theme = theme; this.hover = false; @@ -138,10 +142,10 @@ export default class TxView implements TransactionStripped { // Temporarily override the tx color // returns minimum transition end time - setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): number { + setHover(hoverOn: boolean, color: Color | void): number { if (hoverOn) { this.hover = true; - this.hoverColor = color; + this.hoverColor = color || this.theme.defaultHoverColor; this.sprite.update({ ...this.hoverColor, @@ -191,4 +195,30 @@ export default class TxView implements TransactionStripped { this.dirty = false; return performance.now() + hoverTransitionTime; } + + getColor(): Color { + const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; + const feeLevelColor = this.theme.feeColors[feeLevelIndex] || this.theme.feeColors[this.theme.mempoolFeeColors.length - 1]; + // Block audit + switch(this.status) { + case 'censored': + return this.theme.auditColors.censored; + case 'missing': + return this.theme.auditColors.missing; + case 'fresh': + return this.theme.auditColors.missing; + case 'added': + return this.theme.auditColors.added; + case 'selected': + return this.theme.auditColors.selected; + case 'found': + if (this.context === 'projected') { + return this.theme.auditFeeColors[feeLevelIndex] || this.theme.auditFeeColors[this.theme.mempoolFeeColors.length - 1]; + } else { + return feeLevelColor; + } + default: + return feeLevelColor; + } + } } diff --git a/frontend/src/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index 4f9772b22..2523822f4 100644 --- a/frontend/src/app/components/fees-box/fees-box.component.ts +++ b/frontend/src/app/components/fees-box/fees-box.component.ts @@ -2,8 +2,9 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { StateService } from '../../services/state.service'; import { Observable, combineLatest } from 'rxjs'; import { Recommendedfees } from '../../interfaces/websocket.interface'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { feeLevels } from '../../app.constants'; import { map, startWith, tap } from 'rxjs/operators'; +import { ThemeService } from 'src/app/services/theme.service'; @Component({ selector: 'app-fees-box', @@ -18,7 +19,8 @@ export class FeesBoxComponent implements OnInit { noPriority = '#2e324e'; constructor( - private stateService: StateService + private stateService: StateService, + private themeService: ThemeService, ) { } ngOnInit(): void { @@ -33,11 +35,11 @@ export class FeesBoxComponent implements OnInit { tap((fees) => { let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => fees.minimumFee >= feeLvl); feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; - const startColor = '#' + (mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]); + const startColor = '#' + (this.themeService.mempoolFeeColors[feeLevelIndex - 1] || this.themeService.mempoolFeeColors[this.themeService.mempoolFeeColors.length - 1]); feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => fees.fastestFee >= feeLvl); feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; - const endColor = '#' + (mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]); + const endColor = '#' + (this.themeService.mempoolFeeColors[feeLevelIndex - 1] || this.themeService.mempoolFeeColors[this.themeService.mempoolFeeColors.length - 1]); this.gradient = `linear-gradient(to right, ${startColor}, ${endColor})`; this.noPriority = startColor; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index ed5b61f2b..0614bfec0 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -4,12 +4,13 @@ import { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; import { Router } from '@angular/router'; import { map, switchMap, tap } from 'rxjs/operators'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { feeLevels } from '../../app.constants'; import { specialBlocks } from '../../app.constants'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { Location } from '@angular/common'; import { DifficultyAdjustment, MempoolPosition } from '../../interfaces/node-api.interface'; import { animate, style, transition, trigger } from '@angular/animations'; +import { ThemeService } from 'src/app/services/theme.service'; @Component({ selector: 'app-mempool-blocks', @@ -84,6 +85,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { constructor( private router: Router, public stateService: StateService, + private themeService: ThemeService, private cd: ChangeDetectorRef, private relativeUrlPipe: RelativeUrlPipe, private location: Location, @@ -354,7 +356,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { trimmedFeeRange.forEach((fee: number) => { let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => fee >= feeLvl); feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex; - gradientColors.push(mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]); + gradientColors.push(this.themeService.mempoolFeeColors[feeLevelIndex - 1] || this.themeService.mempoolFeeColors[this.themeService.mempoolFeeColors.length - 1]); }); gradientColors.forEach((color, i, gc) => { diff --git a/frontend/src/app/services/theme.service.ts b/frontend/src/app/services/theme.service.ts new file mode 100644 index 000000000..2d4ba17a3 --- /dev/null +++ b/frontend/src/app/services/theme.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@angular/core'; +import { audit, Subject } from 'rxjs'; +import { Color } from '../components/block-overview-graph/sprite-types'; +import { defaultMempoolFeeColors, contrastMempoolFeeColors } from '../app.constants'; +import { StorageService } from './storage.service'; + +const defaultAuditColors = { + censored: hexToColor('f344df'), + missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), + added: hexToColor('0099ff'), + selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), +}; +const contrastAuditColors = { + censored: hexToColor('ffa8ff'), + missing: darken(desaturate(hexToColor('ffa8ff'), 0.3), 0.7), + added: hexToColor('00bb98'), + selected: darken(desaturate(hexToColor('00bb98'), 0.3), 0.7), +}; + +@Injectable({ + providedIn: 'root' +}) +export class ThemeService { + style: HTMLLinkElement; + theme: string = 'default'; + themeChanged$: Subject = new Subject(); + mempoolFeeColors: string[] = defaultMempoolFeeColors; + + /* block visualization colors */ + defaultHoverColor: Color; + feeColors: Color[]; + auditFeeColors: Color[]; + auditColors: { [category: string]: Color } = defaultAuditColors; + + constructor( + private storageService: StorageService, + ) { + const theme = this.storageService.getValue('theme-preference') || 'default'; + this.apply(theme); + } + + apply(theme) { + this.theme = theme; + if (theme !== 'default') { + if (theme === 'contrast') { + this.mempoolFeeColors = contrastMempoolFeeColors; + this.auditColors = contrastAuditColors; + } + if (!this.style) { + this.style = document.createElement('link'); + this.style.rel = 'stylesheet'; + this.style.href = `theme-${theme}.css`; + document.head.appendChild(this.style); + } else { + this.style.href = `theme-${theme}.css`; + } + } else { + this.mempoolFeeColors = defaultMempoolFeeColors; + this.auditColors = defaultAuditColors; + if (this.style) { + this.style.remove(); + this.style = null; + } + } + this.updateFeeColors(); + this.storageService.setValue('theme-preference', theme); + this.themeChanged$.next(this.theme); + } + + updateFeeColors() { + this.defaultHoverColor = hexToColor('1bd8f4'); + this.feeColors = this.mempoolFeeColors.map(hexToColor); + this.auditFeeColors = this.feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); + } +} + +export function hexToColor(hex: string): Color { + return { + r: parseInt(hex.slice(0, 2), 16) / 255, + g: parseInt(hex.slice(2, 4), 16) / 255, + b: parseInt(hex.slice(4, 6), 16) / 255, + a: 1 + }; +} + +export function desaturate(color: Color, amount: number): Color { + const gray = (color.r + color.g + color.b) / 6; + return { + r: color.r + ((gray - color.r) * amount), + g: color.g + ((gray - color.g) * amount), + b: color.b + ((gray - color.b) * amount), + a: color.a, + }; +} + +export function darken(color: Color, amount: number): Color { + return { + r: color.r * amount, + g: color.g * amount, + b: color.b * amount, + a: color.a, + } +} diff --git a/frontend/src/theme-contrast.scss b/frontend/src/theme-contrast.scss new file mode 100644 index 000000000..48d9a1034 --- /dev/null +++ b/frontend/src/theme-contrast.scss @@ -0,0 +1,70 @@ +/* Theme */ +$bg: #ff1f31; +$active-bg: #ff131f; +$hover-bg: #ff131e; +$fg: #ff0; + +/* Bootstrap */ + +$body-bg: $bg; +$body-color: $fg; +$gray-800: $bg; +$gray-700: $fg; + +$nav-tabs-link-active-bg: $active-bg; + +$primary: #105fb0; +$secondary: #2d3348; +$tertiary: #653b9c; +$success: #1a9436; +$info: #1bd8f4; + +$h5-font-size: 1.15rem !default; + +$pagination-bg: $body-bg; +$pagination-border-color: $gray-800; +$pagination-disabled-bg: $fg; +$pagination-disabled-border-color: $bg; +$pagination-active-color: $fg; +$pagination-active-bg: $tertiary; +$pagination-hover-bg: $hover-bg; +$pagination-hover-border-color: $bg; +$pagination-disabled-bg: $bg; + +$custom-select-indicator-color: $fg; + +.input-group-text { + background-color: #1c2031 !important; + border: 1px solid #20263e !important; +} + +$link-color: $info; +$link-decoration: none !default; +$link-hover-color: darken($link-color, 15%) !default; +$link-hover-decoration: underline !default; + +$dropdown-bg: $bg; +$dropdown-link-color: $fg; + +$dropdown-link-hover-color: $fg; +$dropdown-link-hover-bg: $active-bg; + +$dropdown-link-active-color: $fg; +$dropdown-link-active-bg: $active-bg; + +@import "~bootstrap/scss/bootstrap"; + +:root { + --bg: #{$bg}; + --active-bg: #{$active-bg}; + --hover-bg: #{$hover-bg}; + --fg: #{$fg}; + + --primary: #{$primary}; + --secondary: #{$secondary}; + --tertiary: #{$tertiary}; + --success: #{$success}; + --info: #{$info}; + + --box-bg: var(--box-bg); +} \ No newline at end of file