From 604111b199d0c069f7a4bd35a398c67694bf6c4b Mon Sep 17 00:00:00 2001 From: Mathis Dedial Date: Sat, 25 May 2024 14:19:01 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Signals!!!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.component.html | 14 +-- src/app/app.component.ts | 89 ++++++++++--------- src/app/app.module.ts | 4 +- src/app/controls/controls.component.html | 27 +++--- src/app/controls/controls.component.ts | 65 ++++++-------- .../fuel-gage/fuel-gage.component.html | 6 +- .../fuel-gage/fuel-gage.component.ts | 26 +++--- .../fuel-gage/overlay/overlay.component.ts | 87 +++++++++--------- src/app/fuel-gages/fuel-gages.component.html | 6 +- src/app/fuel-gages/fuel-gages.component.ts | 8 +- src/app/{fuel-gages => }/fuel-tank.model.ts | 20 ++--- .../local-storage-results.service.ts | 5 +- src/app/{ => results}/result.model.ts | 0 src/app/{ => results}/results.service.ts | 2 +- 14 files changed, 177 insertions(+), 182 deletions(-) rename src/app/{fuel-gages => }/fuel-tank.model.ts (58%) rename src/app/{ => results}/local-storage-results.service.ts (90%) rename src/app/{ => results}/result.model.ts (100%) rename src/app/{ => results}/results.service.ts (86%) diff --git a/src/app/app.component.html b/src/app/app.component.html index aaa2baa..eee3656 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -11,16 +11,16 @@

for Robinson R44

diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6d3dcee..1c648ca 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,88 +1,97 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { FuelTank } from './fuel-gages/fuel-tank.model'; -import { Subscription, combineLatest, interval, timer } from 'rxjs'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnInit, + computed, + signal, +} from '@angular/core'; +import { FuelTank } from './fuel-tank.model'; +import { Subscription, interval, timer } from 'rxjs'; import { FuelGagesState } from './fuel-gages/fuel-gages.state'; -import { Result } from './result.model'; -import { RESULTS_SERVICE, ResultsService } from './results.service'; +import { Result } from './results/result.model'; +import { RESULTS_SERVICE, ResultsService } from './results/results.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit { + private static readonly MAX_DURATION = 30; + readonly tanks = [new FuelTank(64), new FuelTank(112)]; - readonly totalCapacity = this.tanks + readonly totalCapacity: number = this.tanks .map((tank) => tank.getCapacity()) .reduce((a, b) => a + b, 0); - private static readonly MAX_DURATION = 30; + totalQuantity = computed(() => { + let sum = 0; + for (const tank of this.tanks) { + sum += tank.getQuantity$()(); + } + return sum; + }); + fuelGagesState = signal(FuelGagesState.HIDDEN); + time = signal(0); + result = signal(null); + showOverlay = signal(false); - totalQuantity: number = 0; - fuelGagesState = FuelGagesState.HIDDEN; - time = 0; - timeSubscription: Subscription | null = null; - result: Result | null = null; - showOverlay: boolean = false; + private _timeSubscription: Subscription | null = null; constructor( @Inject(RESULTS_SERVICE) private readonly resultsService: ResultsService, - ) { - const quantityObservables = []; - for (const tank of this.tanks) { - quantityObservables.push(tank.getQuantity$()); - } - combineLatest(quantityObservables).subscribe((quantities) => { - let sum = 0; - for (let quantity of quantities) { - sum += quantity; - } - this.totalQuantity = sum; - }); - } + ) {} ngOnInit(): void { this.roll(); } restartButtonClicked(): void { - if (this.fuelGagesState === FuelGagesState.VISIBLE) { + if (this.fuelGagesState() === FuelGagesState.VISIBLE) { this.roll(); } } private roll(): void { - this.fuelGagesState = FuelGagesState.RESETTING; + this.fuelGagesState.set(FuelGagesState.RESETTING); for (const tank of this.tanks) { tank.setLevel(Math.random() * 100); } // FIXME: Instead of a timer, can we get an event when the CSS transition has completed? timer(2000).subscribe((_value) => { - this.fuelGagesState = FuelGagesState.HIDDEN; - this.time = 0; - this.timeSubscription = interval(1000).subscribe((_value) => { - ++this.time; - if (this.time === AppComponent.MAX_DURATION) { - this.timeSubscription?.unsubscribe(); + this.fuelGagesState.set(FuelGagesState.HIDDEN); + this.time.set(0); + this._timeSubscription = interval(1000).subscribe((_value) => { + this.time.update((t) => t + 1); + if (this.time() === AppComponent.MAX_DURATION) { + this._timeSubscription?.unsubscribe(); } }); }); } private reveal(guess: number): void { - this.timeSubscription?.unsubscribe(); - this.fuelGagesState = FuelGagesState.VISIBLE; - this.result = new Result(new Date(), guess, this.totalQuantity, this.time); - this.resultsService.add(this.result); + this._timeSubscription?.unsubscribe(); + this.fuelGagesState.set(FuelGagesState.VISIBLE); + const result = new Result( + new Date(), + guess, + this.totalQuantity(), + this.time(), + ); + this.result.set(result); + this.resultsService.add(result); } guessSubmitted(guess: number): void { - if (this.fuelGagesState === FuelGagesState.HIDDEN) { + if (this.fuelGagesState() === FuelGagesState.HIDDEN) { this.reveal(guess); } } overlayToggled(visible: boolean): void { - this.showOverlay = visible; + this.showOverlay.set(visible); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 83e650d..0d6a27d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -6,8 +6,8 @@ import { FuelGagesComponent } from './fuel-gages/fuel-gages.component'; import { FuelGageComponent } from './fuel-gages/fuel-gage/fuel-gage.component'; import { ControlsComponent } from './controls/controls.component'; import { FormsModule } from '@angular/forms'; -import { LocalStorageResultsService } from './local-storage-results.service'; -import { RESULTS_SERVICE } from './results.service'; +import { LocalStorageResultsService } from './results/local-storage-results.service'; +import { RESULTS_SERVICE } from './results/results.service'; import { OverlayComponent } from './fuel-gages/fuel-gage/overlay/overlay.component'; @NgModule({ diff --git a/src/app/controls/controls.component.html b/src/app/controls/controls.component.html index 8062c7c..50a15d0 100644 --- a/src/app/controls/controls.component.html +++ b/src/app/controls/controls.component.html @@ -1,41 +1,44 @@
-
+
Your Guess - {{ result.getGuess() | number: "1.0-0" }} l + {{ result()?.getGuess() | number: "1.0-0" }} l
Total - {{ result.getActual() | number: "1.0-0" }} l + {{ result()?.getActual() | number: "1.0-0" }} l
Error - {{ result.getError() | number: "1.0-0" }} l + {{ result()?.getError() | number: "1.0-0" }} l
Time Taken - {{ result.getTime() }} s + {{ result()?.getTime() }} s
-
-
{{ time }} s
-
+
{{ time() }} s
+
- {{ state === "RESETTING" ? "Resetting..." : "Enter your guess:" }} + {{ state() === "RESETTING" ? "Resetting..." : "Enter your guess:" }}
-
diff --git a/src/app/controls/controls.component.ts b/src/app/controls/controls.component.ts index 3df8209..b73f226 100644 --- a/src/app/controls/controls.component.ts +++ b/src/app/controls/controls.component.ts @@ -2,15 +2,13 @@ import { ChangeDetectionStrategy, Component, ElementRef, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, - ViewChild, + effect, + input, + output, + viewChild, } from '@angular/core'; import { FuelGagesState } from '../fuel-gages/fuel-gages.state'; -import { Result } from '../result.model'; +import { Result } from '../results/result.model'; @Component({ selector: 'app-controls', @@ -18,55 +16,44 @@ import { Result } from '../result.model'; styleUrls: ['./controls.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ControlsComponent implements OnChanges { - @Input() totalCapacity: number = 0; - @Input() state: FuelGagesState = FuelGagesState.RESETTING; - @Input() time: number = 0; - @Input() result: Result | null = null; - @Output() restartButtonClicked$: EventEmitter = new EventEmitter(); - @Output() guessSubmitted$: EventEmitter = new EventEmitter(); - @Output() overlayToggled$: EventEmitter = new EventEmitter(); - @ViewChild('guessInput') guessInput: ElementRef = - {} as ElementRef; +export class ControlsComponent { + totalCapacity = input.required(); + state = input.required(); + time = input.required(); + result = input.required(); + overlayToggled = output(); + restartButtonClicked = output(); + guessSubmitted = output(); + guessInput = viewChild('guessInput', { read: ElementRef }); guess = ''; - showOverlayState = false; - ngOnChanges(changes: SimpleChanges): void { - if (changes['state']) { - switch (changes['state'].currentValue) { + constructor() { + effect(() => { + switch (this.state()) { case FuelGagesState.RESETTING: this.guess = ''; - if (this.showOverlayState) { - this.showOverlayState = false; - this.overlayToggled$.emit(false); - } + this.overlayToggled.emit(false); break; case FuelGagesState.HIDDEN: - // TODO: what is the correct lifecycle method to use to avoid setTimeout? - setTimeout(() => this.guessInput.nativeElement.focus(), 0); + // TODO: what is the correct way to use to avoid setTimeout? + setTimeout(() => this.guessInput()?.nativeElement.focus(), 0); break; } - } - } - - restartButtonClicked(): void { - this.restartButtonClicked$.emit(); + }); } - guessSubmitted(): void { + emitGuessSubmitted(): void { const guess = Number.parseInt(this.guess); - if (!Number.isNaN(guess) && 0 <= guess && guess <= this.totalCapacity) { - this.guessSubmitted$.emit(guess); + if (!Number.isNaN(guess) && 0 <= guess && guess <= this.totalCapacity()) { + this.guessSubmitted.emit(guess); } } showOverlay(): void { - this.showOverlayState = true; - this.overlayToggled$.emit(this.showOverlayState); + this.overlayToggled.emit(true); } hideOverlay(): void { - this.showOverlayState = false; - this.overlayToggled$.emit(this.showOverlayState); + this.overlayToggled.emit(false); } } diff --git a/src/app/fuel-gages/fuel-gage/fuel-gage.component.html b/src/app/fuel-gages/fuel-gage/fuel-gage.component.html index a37f185..b1fd4de 100644 --- a/src/app/fuel-gages/fuel-gage/fuel-gage.component.html +++ b/src/app/fuel-gages/fuel-gage/fuel-gage.component.html @@ -5,11 +5,11 @@
diff --git a/src/app/fuel-gages/fuel-gage/fuel-gage.component.ts b/src/app/fuel-gages/fuel-gage/fuel-gage.component.ts index f406297..5a5f126 100644 --- a/src/app/fuel-gages/fuel-gage/fuel-gage.component.ts +++ b/src/app/fuel-gages/fuel-gage/fuel-gage.component.ts @@ -1,9 +1,8 @@ import { ChangeDetectionStrategy, Component, - Input, - OnChanges, - SimpleChanges, + computed, + input, } from '@angular/core'; @Component({ @@ -12,10 +11,14 @@ import { styleUrls: ['./fuel-gage.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FuelGageComponent implements OnChanges { - @Input() level: number | null = 0; - @Input() overlayLevels: { level: number; label: string }[] = []; - @Input() showOverlay: boolean = false; +export class FuelGageComponent { + level = input.required(); + overlayLevels = input.required<{ level: number; label: string }[]>(); + showOverlay = input.required(); + + handRotationDeg = computed(() => { + return `rotate(${this.getDegrees(this.level() ?? 0)}deg`; + }); private static readonly a0 = 5.5; private static readonly a1 = 0.445; @@ -23,15 +26,6 @@ export class FuelGageComponent implements OnChanges { private static readonly a3 = 0.000104; private static readonly a4 = -0.00000101; - handRotationDeg: string = 'none'; - - ngOnChanges(changes: SimpleChanges): void { - if (changes['level']) { - const level = changes['level'].currentValue ?? 0; - this.handRotationDeg = `rotate(${this.getDegrees(level)}deg`; - } - } - private getDegrees(level: number): number { level = Math.max(Math.min(level, 100), 0); return ( diff --git a/src/app/fuel-gages/fuel-gage/overlay/overlay.component.ts b/src/app/fuel-gages/fuel-gage/overlay/overlay.component.ts index 8da5e42..04603ce 100644 --- a/src/app/fuel-gages/fuel-gage/overlay/overlay.component.ts +++ b/src/app/fuel-gages/fuel-gage/overlay/overlay.component.ts @@ -1,10 +1,10 @@ import { - AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, - Input, - ViewChild, + effect, + input, + viewChild, } from '@angular/core'; @Component({ @@ -13,7 +13,7 @@ import { styleUrls: ['./overlay.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class OverlayComponent implements AfterViewInit { +export class OverlayComponent { private static readonly y1 = 60; private static readonly y2 = 25; @@ -25,53 +25,52 @@ export class OverlayComponent implements AfterViewInit { private static readonly labelMargin = 5; - @ViewChild('canvas') - canvasRef: ElementRef | null = null; + canvasRef = viewChild('canvas', { read: ElementRef }); + levels = input.required<{ level: number; label: string }[]>(); - @Input() - levels: { level: number; label: string }[] = []; + constructor() { + effect(() => { + const canvas = this.canvasRef()?.nativeElement; + if (!canvas) { + return; + } - ngAfterViewInit(): void { - const canvas = this.canvasRef?.nativeElement; - if (!canvas) { - return; - } + // This has to match the size defined in the stylesheet. + canvas.height = 130; + canvas.width = 180; - // This has to match the size defined in the stylesheet. - canvas.height = 130; - canvas.width = 180; + const ctx: any = canvas.getContext('2d'); + if (!ctx) { + return; + } - const ctx: any = canvas.getContext('2d'); - if (!ctx) { - return; - } + ctx.reset(); + ctx.strokeStyle = '#102c57'; + ctx.lineWidth = 3; + ctx.translate(canvas.width / 2, 0); - ctx.reset(); - ctx.strokeStyle = '#102c57'; - ctx.lineWidth = 3; - ctx.translate(canvas.width / 2, 0); + for (const { level, label } of this.levels()) { + if (level < 0 || level > 100) { + continue; + } + const angleDeg = this.levelToRotationDeg(level); + const angleRad = (angleDeg / 180) * Math.PI; + ctx.beginPath(); + const y1 = OverlayComponent.y1; + const y2 = OverlayComponent.y2; + const x1 = (canvas.height - y1) / Math.tan(angleRad); + const x2 = (canvas.height - y2) / Math.tan(angleRad); + const x3 = x2 + OverlayComponent.labelMargin * Math.cos(angleRad); + const y3 = y2 - OverlayComponent.labelMargin; + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); - for (const { level, label } of this.levels) { - if (level < 0 || level > 100) { - continue; + ctx.font = '16px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(label, x3, y3); } - const angleDeg = this.levelToRotationDeg(level); - const angleRad = (angleDeg / 180) * Math.PI; - ctx.beginPath(); - const y1 = OverlayComponent.y1; - const y2 = OverlayComponent.y2; - const x1 = (canvas.height - y1) / Math.tan(angleRad); - const x2 = (canvas.height - y2) / Math.tan(angleRad); - const x3 = x2 + OverlayComponent.labelMargin * Math.cos(angleRad); - const y3 = y2 - OverlayComponent.labelMargin; - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.stroke(); - - ctx.font = '16px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(label, x3, y3); - } + }); } private levelToRotationDeg(level: number): number { diff --git a/src/app/fuel-gages/fuel-gages.component.html b/src/app/fuel-gages/fuel-gages.component.html index 3905b54..d7eaa68 100644 --- a/src/app/fuel-gages/fuel-gages.component.html +++ b/src/app/fuel-gages/fuel-gages.component.html @@ -1,10 +1,10 @@
-
+
Capacity: {{ tank.getCapacity() }} l
diff --git a/src/app/fuel-gages/fuel-gages.component.ts b/src/app/fuel-gages/fuel-gages.component.ts index 653c85e..bb6e5cb 100644 --- a/src/app/fuel-gages/fuel-gages.component.ts +++ b/src/app/fuel-gages/fuel-gages.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { FuelTank } from './fuel-tank.model'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FuelTank } from '../fuel-tank.model'; @Component({ selector: 'app-fuel-gages', @@ -8,6 +8,6 @@ import { FuelTank } from './fuel-tank.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FuelGagesComponent { - @Input() tanks: FuelTank[] = []; - @Input() showOverlay: boolean = false; + tanks = input.required(); + showOverlay = input.required(); } diff --git a/src/app/fuel-gages/fuel-tank.model.ts b/src/app/fuel-tank.model.ts similarity index 58% rename from src/app/fuel-gages/fuel-tank.model.ts rename to src/app/fuel-tank.model.ts index 8959d99..c316101 100644 --- a/src/app/fuel-gages/fuel-tank.model.ts +++ b/src/app/fuel-tank.model.ts @@ -1,7 +1,7 @@ -import { BehaviorSubject, Observable, map } from 'rxjs'; +import { Signal, computed, signal } from '@angular/core'; export class FuelTank { - private _level$ = new BehaviorSubject(0); + private _level$ = signal(0); constructor(private readonly capacity: number) {} @@ -11,21 +11,21 @@ export class FuelTank { setLevel(level: number): void { level = Math.max(0, Math.min(100, level)); - this._level$.next(level); + this._level$.set(level); } getLevel(): number { - return this._level$.value; + return this._level$(); } - getLevel$(): Observable { - return this._level$.asObservable(); + getLevel$(): Signal { + return this._level$; } - getQuantity$(): Observable { - return this._level$.pipe( - map((level: number) => Math.round((level / 100) * this.capacity)), - ); + getQuantity$(): Signal { + return computed(() => { + return Math.round((this._level$() / 100) * this.capacity); + }); } getOverlayLevels(): { level: number; label: string }[] { diff --git a/src/app/local-storage-results.service.ts b/src/app/results/local-storage-results.service.ts similarity index 90% rename from src/app/local-storage-results.service.ts rename to src/app/results/local-storage-results.service.ts index 8d8a37b..3aa7024 100644 --- a/src/app/local-storage-results.service.ts +++ b/src/app/results/local-storage-results.service.ts @@ -15,7 +15,10 @@ export class LocalStorageResultsService implements ResultsService, OnInit { this.load(); } - public add(result: Result): void { + public add(result: Result | null): void { + if (!result) { + return; + } this.results.push(result); this.persist(); } diff --git a/src/app/result.model.ts b/src/app/results/result.model.ts similarity index 100% rename from src/app/result.model.ts rename to src/app/results/result.model.ts diff --git a/src/app/results.service.ts b/src/app/results/results.service.ts similarity index 86% rename from src/app/results.service.ts rename to src/app/results/results.service.ts index 1db741a..1863357 100644 --- a/src/app/results.service.ts +++ b/src/app/results/results.service.ts @@ -6,6 +6,6 @@ export const RESULTS_SERVICE = new InjectionToken( ); export interface ResultsService { - add(result: Result): void; + add(result: Result | null): void; get(): Result[]; }