From f2f6e3769a6f7cddbd7ac2f1f7bf3685c21e6ddf Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 2 Jan 2023 12:26:10 -0600 Subject: [PATCH] implement theme switching service --- frontend/src/app/app.constants.ts | 35 +++++- .../block-overview-graph.component.ts | 12 +- .../block-overview-graph/block-scene.ts | 21 ++-- .../block-overview-graph/tx-view.ts | 64 +++-------- .../components/fees-box/fees-box.component.ts | 10 +- .../mempool-blocks.component.ts | 6 +- frontend/src/app/services/theme.service.ts | 103 ++++++++++++++++++ frontend/src/theme-contrast.scss | 70 ++++++++++++ 8 files changed, 254 insertions(+), 67 deletions(-) create mode 100644 frontend/src/app/services/theme.service.ts create mode 100644 frontend/src/theme-contrast.scss diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 95e1e756e..19518c121 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', @@ -31,6 +31,39 @@ export const mempoolFeeColors = [ 'b9254b', ]; +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', +]; + 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 37225ea1d..507c9566b 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 @@ -5,6 +5,8 @@ import BlockScene from './block-scene'; import TxSprite from './tx-sprite'; import TxView from './tx-view'; import { Position } from './sprite-types'; +import { ThemeService } from 'src/app/services/theme.service'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-block-overview-graph', @@ -26,6 +28,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @ViewChild('blockCanvas') canvas: ElementRef; + themeChangedSubscription: Subscription; gl: WebGLRenderingContext; animationFrameRequest: number; @@ -48,6 +51,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On constructor( readonly ngZone: NgZone, readonly elRef: ElementRef, + private themeService: ThemeService, ) { this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); } @@ -59,6 +63,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.initCanvas(); this.resizeCanvas(); + + this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => { + // force full re-render + this.resizeCanvas(); + }); } ngOnChanges(changes): void { @@ -79,6 +88,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost); this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored); + this.themeChangedSubscription.unsubscribe(); } clear(direction): void { @@ -195,7 +205,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 }); 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 8d3c46af4..e7cded0b0 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -2,11 +2,13 @@ import { FastVertexArray } from './fast-vertex-array'; import TxView from './tx-view'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { Position, Square, ViewUpdateParams } from './sprite-types'; +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 }; + theme: ThemeService; orientation: string; flip: boolean; width: number; @@ -22,11 +24,11 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, theme }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService } ) { - this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }); + this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, theme }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { @@ -67,7 +69,7 @@ export default class BlockScene { }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); txs.forEach(tx => { - const txView = new TxView(tx, this.vertexArray); + const txView = new TxView(tx, this.vertexArray, this.theme); this.txs[tx.txid] = txView; this.place(txView); this.saveGridToScreenPosition(txView); @@ -114,7 +116,7 @@ export default class BlockScene { }); txs.forEach(tx => { if (!this.txs[tx.txid]) { - this.txs[tx.txid] = new TxView(tx, this.vertexArray); + this.txs[tx.txid] = new TxView(tx, this.vertexArray, this.theme); } }); @@ -156,7 +158,7 @@ export default class BlockScene { if (resetLayout) { add.forEach(tx => { if (!this.txs[tx.txid]) { - this.txs[tx.txid] = new TxView(tx, this.vertexArray); + this.txs[tx.txid] = new TxView(tx, this.vertexArray, this.theme); } }); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); @@ -166,7 +168,7 @@ export default class BlockScene { } else { // try to insert new txs directly const remaining = []; - add.map(tx => new TxView(tx, this.vertexArray)).sort(feeRateDescending).forEach(tx => { + add.map(tx => new TxView(tx, this.vertexArray, this.theme)).sort(feeRateDescending).forEach(tx => { if (!this.tryInsertByFee(tx)) { remaining.push(tx); } @@ -192,13 +194,14 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); } - private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray }: + private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, theme }: { width: number, height: number, resolution: number, blockLimit: number, - orientation: string, flip: boolean, vertexArray: FastVertexArray } + orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService } ): void { this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; + 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 f73b83fd4..5fe4c65ab 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -2,20 +2,10 @@ import TxSprite from './tx-sprite'; import { FastVertexArray } from './fast-vertex-array'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; -import BlockScene from './block-scene'; +import { feeLevels } from '../../app.constants'; +import { ThemeService } from 'src/app/services/theme.service'; const hoverTransitionTime = 300; -const defaultHoverColor = hexToColor('1bd8f4'); - -const feeColors = mempoolFeeColors.map(hexToColor); -const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); -const auditColors = { - censored: hexToColor('f344df'), - missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), - added: hexToColor('0099ff'), - selected: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7), -} // convert from this class's update format to TxSprite's update format function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams { @@ -37,6 +27,7 @@ export default class TxView implements TransactionStripped { feerate: number; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; context?: 'projected' | 'actual'; + theme: ThemeService; initialised: boolean; vertexArray: FastVertexArray; @@ -49,7 +40,7 @@ export default class TxView implements TransactionStripped { dirty: boolean; - constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { + constructor(tx: TransactionStripped, vertexArray: FastVertexArray, theme: ThemeService) { this.context = tx.context; this.txid = tx.txid; this.fee = tx.fee; @@ -59,6 +50,7 @@ export default class TxView implements TransactionStripped { this.status = tx.status; this.initialised = false; this.vertexArray = vertexArray; + this.theme = theme; this.hover = false; @@ -131,10 +123,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, @@ -155,22 +147,22 @@ export default class TxView implements TransactionStripped { getColor(): Color { const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; - const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; + const feeLevelColor = this.theme.feeColors[feeLevelIndex] || this.theme.feeColors[this.theme.mempoolFeeColors.length - 1]; // Block audit switch(this.status) { case 'censored': - return auditColors.censored; + return this.theme.auditColors.censored; case 'missing': - return auditColors.missing; + return this.theme.auditColors.missing; case 'fresh': - return auditColors.missing; + return this.theme.auditColors.missing; case 'added': - return auditColors.added; + return this.theme.auditColors.added; case 'selected': - return auditColors.selected; + return this.theme.auditColors.selected; case 'found': if (this.context === 'projected') { - return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + return this.theme.auditFeeColors[feeLevelIndex] || this.theme.auditFeeColors[this.theme.mempoolFeeColors.length - 1]; } else { return feeLevelColor; } @@ -179,31 +171,3 @@ export default class TxView implements TransactionStripped { } } } - -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 - }; -} - -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, - }; -} - -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/app/components/fees-box/fees-box.component.ts b/frontend/src/app/components/fees-box/fees-box.component.ts index 48098db7b..c8cde1f17 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 } from 'rxjs'; import { Recommendedfees } from '../../interfaces/websocket.interface'; -import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import { feeLevels } from '../../app.constants'; import { 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 { @@ -28,11 +30,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 e1a443680..025eafea2 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -4,11 +4,12 @@ import { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; import { Router } from '@angular/router'; import { take, map, switchMap } 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 } from '../../interfaces/node-api.interface'; +import { ThemeService } from 'src/app/services/theme.service'; @Component({ selector: 'app-mempool-blocks', @@ -58,6 +59,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { constructor( private router: Router, public stateService: StateService, + private themeService: ThemeService, private cd: ChangeDetectorRef, private relativeUrlPipe: RelativeUrlPipe, private location: Location @@ -245,7 +247,7 @@ export class MempoolBlocksComponent implements OnInit, 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