CENTRALIZED: locationService.location, seriesService.all, dateService.now

This commit is contained in:
Patrick Haßel 2025-10-31 11:30:54 +01:00
parent 6397b74dce
commit 1e676d8e3b
20 changed files with 443 additions and 267 deletions

View File

@ -0,0 +1,19 @@
import {Injectable} from '@angular/core';
import {timer} from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class DateService {
private _now: Date = new Date();
get now(): Date {
return this._now;
}
constructor() {
timer(1000, 1000).subscribe(() => this._now = new Date());
}
}

View File

@ -1,5 +1,6 @@
import {or, validateNumber, validateString} from '../common'; import {or, validateNumber, validateString} from '../common';
import {Series} from '../series/Series'; import {Series} from '../series/Series';
import {Value} from '../series/Value';
export class Location { export class Location {
@ -8,14 +9,43 @@ export class Location {
readonly name: string, readonly name: string,
readonly latitude: number, readonly latitude: number,
readonly longitude: number, readonly longitude: number,
readonly energyPurchase: Series | null, private _energyPurchase: Series | null,
readonly energyDeliver: Series | null, private _energyDeliver: Series | null,
readonly energyProduce: Series | null, private _energyProduce: Series | null,
readonly powerPurchase: Series | null, private _powerPurchase: Series | null,
readonly powerDeliver: Series | null, private _powerDeliver: Series | null,
readonly powerProduce: Series | null, private _powerProduce: Series | null,
private _powerConsume: Value = Value.NULL,
) { ) {
// this.updateConsume();
}
readonly updateSeries = (series: Series) => {
if (series.equals(this._energyPurchase)) {
this._energyPurchase = series;
}
if (series.equals(this._energyDeliver)) {
this._energyDeliver = series;
}
if (series.equals(this._energyProduce)) {
this._energyProduce = series;
}
if (series.equals(this._powerProduce)) {
this._powerProduce = series;
this.updateConsume();
}
if (series.equals(this._powerPurchase)) {
this._powerPurchase = series;
this.updateConsume();
}
if (series.equals(this._powerDeliver)) {
this._powerDeliver = series;
this.updateConsume();
}
};
private updateConsume() {
this._powerConsume = Value.ZERO.plus(this._powerPurchase?.value, true).plus(this._powerProduce?.value, true).minus(this._powerDeliver?.value, true);
} }
static fromJson(json: any): Location { static fromJson(json: any): Location {
@ -33,4 +63,32 @@ export class Location {
); );
} }
get energyPurchase(): Series | null {
return this._energyPurchase;
}
get energyDeliver(): Series | null {
return this._energyDeliver;
}
get energyProduce(): Series | null {
return this._energyProduce;
}
get powerPurchase(): Series | null {
return this._powerPurchase;
}
get powerDeliver(): Series | null {
return this._powerDeliver;
}
get powerProduce(): Series | null {
return this._powerProduce;
}
get powerConsume(): Value | null {
return this._powerConsume;
}
} }

View File

@ -1,10 +1,10 @@
@if (location) { @if (location) {
<app-series-history [now]="now" [location]="location" [interval]="null" heading="Aktuell"></app-series-history> <app-location-power [location]="location"></app-location-power>
<app-series-history [now]="now" [location]="location" [interval]="Interval.DAY" heading="Heute"></app-series-history> <app-series-history [location]="location" [interval]="Interval.DAY" heading="Heute"></app-series-history>
<app-series-history [now]="now" [location]="location" [interval]="Interval.DAY" [offset]="offset"> <app-series-history [location]="location" [interval]="Interval.DAY" [offset]="offset">
<ng-content #SeriesHistoryHeading> <ng-content #SeriesHistoryHeading>
<div style="display: flex; width: 100%"> <div style="display: flex; width: 100%">
&nbsp; &nbsp;
@ -31,17 +31,17 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-text [initial]="location.name" (onChange)="locationService.name(location, $event, updateLocation)"></app-text> <app-text [initial]="location.name" (onChange)="locationService.name(location, $event)"></app-text>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Breitegrad Breitengrad
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-number [initial]="location.latitude" (onChange)="locationService.latitude(location, $event, updateLocation)" unit="°"></app-number> <app-number [initial]="location.latitude" (onChange)="locationService.latitude(location, $event)" unit="°"></app-number>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
@ -51,7 +51,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-number [initial]="location.longitude" (onChange)="locationService.longitude(location, $event, updateLocation)" unit="°"></app-number> <app-number [initial]="location.longitude" (onChange)="locationService.longitude(location, $event)" unit="°"></app-number>
</div> </div>
</div> </div>
</div> </div>
@ -71,7 +71,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.energyPurchase" (onChange)="locationService.energyPurchase(location, $event, updateLocation)" [series]="filterEnergy()"></app-series-select> <app-series-select [initial]="location.energyPurchase" (onChange)="locationService.energyPurchase(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
@ -81,7 +81,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.energyDeliver" (onChange)="locationService.energyDeliver(location, $event, updateLocation)" [series]="filterEnergy()"></app-series-select> <app-series-select [initial]="location.energyDeliver" (onChange)="locationService.energyDeliver(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
@ -91,7 +91,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.energyProduce" (onChange)="locationService.energyProduce(location, $event, updateLocation)" [series]="filterEnergy()"></app-series-select> <app-series-select [initial]="location.energyProduce" (onChange)="locationService.energyProduce(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
</div> </div>
@ -111,7 +111,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.powerPurchase" (onChange)="locationService.powerPurchase(location, $event, updateLocation)" [series]="filterPower()"></app-series-select> <app-series-select [initial]="location.powerPurchase" (onChange)="locationService.powerPurchase(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
@ -121,7 +121,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.powerDeliver" (onChange)="locationService.powerDeliver(location, $event, updateLocation)" [series]="filterPower()"></app-series-select> <app-series-select [initial]="location.powerDeliver" (onChange)="locationService.powerDeliver(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="Section2">
@ -131,7 +131,7 @@
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
<app-series-select [now]="now" [initial]="location.powerProduce" (onChange)="locationService.powerProduce(location, $event, updateLocation)" [series]="filterPower()"></app-series-select> <app-series-select [initial]="location.powerProduce" (onChange)="locationService.powerProduce(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,21 +5,15 @@ import {Location} from '../Location';
import {Text} from '../../shared/text/text'; import {Text} from '../../shared/text/text';
import {Number} from '../../shared/number/number'; import {Number} from '../../shared/number/number';
import {SeriesSelect} from '../../series/select/series-select'; import {SeriesSelect} from '../../series/select/series-select';
import {Series} from '../../series/Series'; import {Subscription} from 'rxjs';
import {SeriesService} from '../../series/series-service'; import {LocationEnergy} from '../electricity/location-energy';
import {SeriesType} from '../../series/SeriesType';
import {Subscription, timer} from 'rxjs';
import {LocationElectricity} from '../electricity/location-electricity';
import {Interval} from '../../series/Interval'; import {Interval} from '../../series/Interval';
import {MenuService} from '../../menu-service'; import {MenuService} from '../../menu-service';
import {DatePipe} from '@angular/common'; import {DatePipe} from '@angular/common';
import {WebsocketService} from '../../common'; import {Series} from '../../series/Series';
import {SeriesType} from '../../series/SeriesType';
function yesterday(now: any) { import {DateService} from '../../date.service';
const yesterday = new Date(now.getTime()); import {LocationPower} from '../power/location-power';
yesterday.setDate(yesterday.getDate() - 1);
return yesterday;
}
@Component({ @Component({
selector: 'app-location-detail', selector: 'app-location-detail',
@ -27,13 +21,18 @@ function yesterday(now: any) {
Text, Text,
Number, Number,
SeriesSelect, SeriesSelect,
LocationElectricity LocationEnergy,
LocationPower
], ],
templateUrl: './location-detail.html', templateUrl: './location-detail.html',
styleUrl: './location-detail.less', styleUrl: './location-detail.less',
}) })
export class LocationDetail implements OnInit, OnDestroy { export class LocationDetail implements OnInit, OnDestroy {
protected readonly filterEnergy = (series: Series) => series.type === SeriesType.DELTA && series.unit === 'kWh';
protected readonly filterPower = (series: Series) => series.type === SeriesType.VARYING && series.unit === 'W';
protected readonly Interval = Interval; protected readonly Interval = Interval;
protected readonly Math = Math; protected readonly Math = Math;
@ -42,85 +41,46 @@ export class LocationDetail implements OnInit, OnDestroy {
private readonly subs: Subscription [] = []; private readonly subs: Subscription [] = [];
private series: Series[] = [];
protected now: Date = new Date();
protected yesterday: Date = yesterday(this.now);
protected offset: number = 1; protected offset: number = 1;
private readonly datePipe: DatePipe; private readonly datePipe: DatePipe;
constructor( constructor(
readonly locationService: LocationService, readonly locationService: LocationService,
readonly seriesService: SeriesService,
readonly activatedRoute: ActivatedRoute, readonly activatedRoute: ActivatedRoute,
readonly menuService: MenuService, readonly menuService: MenuService,
readonly websocketService: WebsocketService, readonly dateService: DateService,
@Inject(LOCALE_ID) readonly locale: string, @Inject(LOCALE_ID) readonly locale: string,
) { ) {
this.datePipe = new DatePipe(locale); this.datePipe = new DatePipe(locale);
} }
ngOnInit(): void { ngOnInit(): void {
this.subs.push(this.websocketService.onChange((connected) => { this.locationService.id = null;
if (connected) { this.subs.push(this.activatedRoute.params.subscribe(params => this.locationService.id = params['id'] || null));
this.seriesService.findAll(list => this.series = list); this.subs.push(this.locationService.location$.subscribe(this.onLocationChange));
} else {
this.location = null;
} }
}));
this.subs.push(this.activatedRoute.params.subscribe(params => { private readonly onLocationChange = (location: Location | null): void => {
this.locationService.getById(params['id'], location => {
this.location = location; this.location = location;
if (this.location) {
this.menuService.title = this.location.name; this.menuService.title = this.location.name;
});
}));
this.subs.push(this.seriesService.subscribe(this.updateSeries));
this.subs.push(timer(1000, 1000).subscribe(() => {
this.now = new Date();
this.yesterday = yesterday(this.now);
}));
} }
};
ngOnDestroy(): void { ngOnDestroy(): void {
this.location = null; this.location = null;
this.menuService.title = ""; this.menuService.title = "";
this.subs.forEach(sub => sub.unsubscribe()); this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
} }
protected readonly updateLocation = (location: Location): void => {
if (this.location?.id === location.id) {
this.location = location;
}
};
protected readonly updateSeries = (fresh: Series): void => {
const index = this.series.findIndex(series => series.id === fresh.id);
if (index >= 0) {
this.series.splice(index, 1, fresh);
} else {
this.series.push(fresh);
}
};
protected readonly filterEnergy = (): Series[] => {
return this.series.filter(series => series.type === SeriesType.DELTA && series.unit === 'kWh');
};
protected readonly filterPower = (): Series[] => {
return this.series.filter(series => series.type === SeriesType.VARYING && series.unit === 'W');
};
protected offsetDayTitle(): string { protected offsetDayTitle(): string {
if (this.offset === 1) { if (this.offset === 1) {
return 'Gestern'; return 'Gestern';
} else if (this.offset === 2) {
return 'Vorgestern';
} else { } else {
if (this.offset < 7) { if (this.offset < 7) {
const d = new Date(this.now); const d = new Date(this.dateService.now);
d.setDate(d.getDate() - this.offset); d.setDate(d.getDate() - this.offset);
return this.datePipe.transform(d, 'EEEE') || ''; return this.datePipe.transform(d, 'EEEE') || '';
} }

View File

@ -1,119 +0,0 @@
import {AfterViewInit, Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Location} from '../Location';
import {Series} from '../../series/Series';
import {Next} from '../../common';
import {Interval} from '../../series/Interval';
import {PointService} from '../../point/point-service';
import {SeriesService} from '../../series/series-service';
import {Subscription} from 'rxjs';
import {Value} from '../../series/Value';
@Component({
selector: 'app-series-history',
imports: [],
templateUrl: './location-electricity.html',
styleUrl: './location-electricity.less',
})
export class LocationElectricity implements OnInit, AfterViewInit, OnDestroy {
protected readonly Interval = Interval;
private readonly subs: Subscription[] = [];
protected purchase: Value = Value.NONE;
protected deliver: Value = Value.NONE;
protected produce: Value = Value.NONE;
protected consume: Value = Value.NONE;
@Input()
heading: string = "";
private _o_: number = 0;
@Input()
set offset(value: number) {
this._o_ = value;
this.ngAfterViewInit();
}
get offset(): number {
return this._o_;
}
@Input()
interval: Interval | null = null;
@Input()
location!: Location;
@Input()
now: Date = new Date();
constructor(
readonly pointService: PointService,
readonly serieService: SeriesService,
) {
//
}
ngOnInit(): void {
this.subs.push(this.serieService.subscribe(this.update));
}
ngAfterViewInit(): void {
if (this.interval) {
this.history(null, this.location?.energyPurchase, history => this.purchase = history);
this.history(null, this.location?.energyDeliver, history => this.deliver = history);
this.history(null, this.location?.energyProduce, history => this.produce = history);
} else {
this.history(null, this.location?.powerPurchase, history => this.purchase = history);
this.history(null, this.location?.powerDeliver, history => this.deliver = history);
this.history(null, this.location?.powerProduce, history => this.produce = history);
}
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
protected readonly update = (fresh: Series): void => {
if (this.interval) {
if (this.offset > 0) {
return;
}
this.history(fresh, this.location?.energyPurchase, value => this.purchase = value);
this.history(fresh, this.location?.energyDeliver, value => this.deliver = value);
this.history(fresh, this.location?.energyProduce, value => this.produce = value);
} else {
this.history(fresh, this.location?.powerPurchase, value => this.purchase = value);
this.history(fresh, this.location?.powerDeliver, value => this.deliver = value);
this.history(fresh, this.location?.powerProduce, value => this.produce = value);
}
};
private history(fresh: Series | null | undefined, series: Series | null | undefined, next: Next<Value>): void {
const callNextAndUpdateConsume = (value: Value) => {
next(value);
this.consume = this.purchase.plus(this.produce).minus(this.deliver);
};
if (fresh !== null && fresh !== undefined) {
if (fresh.id !== series?.id) {
return;
}
series = fresh;
}
if (!series) {
callNextAndUpdateConsume(Value.NONE);
return
}
if (this.interval) {
this.pointService.relative([series], this.interval, this.offset, 1, response => callNextAndUpdateConsume(Value.ofPoint(response, 0, 0, 1)));
} else {
callNextAndUpdateConsume(series.value);
}
}
}

View File

@ -12,7 +12,7 @@
Bezug Bezug
</div> </div>
<div class="SectionBody purchase"> <div class="SectionBody purchase">
{{ purchase.toValueString(true, interval ? null : now) }} {{ purchase.toValueString(interval ? null : now) }}
</div> </div>
</div> </div>
@ -21,7 +21,7 @@
Solar Solar
</div> </div>
<div class="SectionBody produce"> <div class="SectionBody produce">
{{ produce.toValueString(true, interval ? null : now) }} {{ produce.toValueString(interval ? null : now) }}
</div> </div>
</div> </div>
@ -30,7 +30,7 @@
Verbrauch Verbrauch
</div> </div>
<div class="SectionBody consume"> <div class="SectionBody consume">
{{ consume.toValueString(true, interval ? null : now) }} {{ consume.toValueString(interval ? null : now) }}
</div> </div>
</div> </div>
@ -39,7 +39,7 @@
Einspeisung Einspeisung
</div> </div>
<div class="SectionBody deliver"> <div class="SectionBody deliver">
{{ deliver.toValueString(true, interval ? null : now) }} {{ deliver.toValueString(interval ? null : now) }}
</div> </div>
</div> </div>

View File

@ -0,0 +1,107 @@
import {AfterViewInit, Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Location} from '../Location';
import {Series} from '../../series/Series';
import {Next} from '../../common';
import {Interval} from '../../series/Interval';
import {PointService} from '../../point/point-service';
import {SeriesService} from '../../series/series-service';
import {Subscription} from 'rxjs';
import {Value} from '../../series/Value';
@Component({
selector: 'app-series-history',
imports: [],
templateUrl: './location-energy.html',
styleUrl: './location-energy.less',
})
export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
protected readonly Interval = Interval;
private readonly subs: Subscription[] = [];
protected purchase: Value = Value.NULL;
protected deliver: Value = Value.NULL;
protected produce: Value = Value.NULL;
protected consume: Value = Value.NULL;
@Input()
heading!: string;
private _o_: number = 0;
@Input()
set offset(value: number) {
this._o_ = value;
this.ngAfterViewInit();
}
get offset(): number {
return this._o_;
}
@Input()
interval!: Interval;
@Input()
location!: Location;
@Input()
now: Date = new Date();
constructor(
readonly pointService: PointService,
readonly serieService: SeriesService,
) {
//
}
ngOnInit(): void {
this.subs.push(this.serieService.subscribe(this.update));
}
ngAfterViewInit(): void {
this.fetch(null, this.location?.energyPurchase, history => this.purchase = history);
this.fetch(null, this.location?.energyDeliver, history => this.deliver = history);
this.fetch(null, this.location?.energyProduce, history => this.produce = history);
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
protected readonly update = (fresh: Series): void => {
if (this.offset > 0) {
return;
}
this.fetch(fresh, this.location?.energyPurchase, value => this.purchase = value);
this.fetch(fresh, this.location?.energyDeliver, value => this.deliver = value);
this.fetch(fresh, this.location?.energyProduce, value => this.produce = value);
};
private fetch(fresh: Series | null | undefined, series: Series | null | undefined, next: Next<Value>): void {
const callNextAndUpdateConsume = (value: Value) => {
next(value);
this.consume = this.purchase.plus(this.produce, true).minus(this.deliver, true);
};
if (fresh !== null && fresh !== undefined) {
if (fresh.id !== series?.id) {
return;
}
series = fresh;
}
if (!series) {
callNextAndUpdateConsume(Value.NULL);
return
}
if (this.interval) {
this.pointService.relative([series], this.interval, this.offset, 1, response => callNextAndUpdateConsume(Value.ofPoint(response, 0, 0, 1)));
} else {
callNextAndUpdateConsume(series.value);
}
}
}

View File

@ -1,7 +1,7 @@
import {AfterViewInit, Component, Input} from '@angular/core'; import {AfterViewInit, Component, Input} from '@angular/core';
import {Location} from '../../Location'; import {Location} from '../Location';
import {Interval} from '../../../series/Interval'; import {Interval} from '../../series/Interval';
import {PointService} from '../../../point/point-service'; import {PointService} from '../../point/point-service';
@Component({ @Component({
selector: 'app-series-history-graph', selector: 'app-series-history-graph',

View File

@ -1,14 +1,44 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common'; import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {Location} from './Location' import {Location} from './Location'
import {SeriesService} from '../series/series-service';
import {BehaviorSubject, Observable} from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class LocationService extends CrudService<Location> { export class LocationService extends CrudService<Location> {
constructor(api: ApiService, ws: WebsocketService) { private readonly _location = new BehaviorSubject<Location | null>(null);
private _id: number | null = null;
constructor(
api: ApiService,
ws: WebsocketService,
readonly seriesService: SeriesService,
) {
super(api, ws, ['Location'], Location.fromJson); super(api, ws, ['Location'], Location.fromJson);
this.seriesService.subscribe(series => this._location.value?.updateSeries(series));
this.ws.onConnect(this.fetch);
this.ws.onDisconnect(() => this._location.next(null));
}
set id(id: number | null) {
this._id = id;
this.fetch();
}
private readonly fetch = () => {
if (this._id === null) {
this._location.next(null);
} else {
this.getById(this._id, location => this._location.next(location));
}
};
get location$(): Observable<Location | null> {
return this._location.asObservable();
} }
findAll(next: Next<Location[]>) { findAll(next: Next<Location[]>) {

View File

@ -0,0 +1,47 @@
<div class="Section3">
<div class="SectionHeading">
<div class="SectionHeadingText">
Aktuelle Leistung
</div>
</div>
<div class="SectionBody">
<div class="Section4">
<div class="SectionHeadingText">
Bezug
</div>
<div class="SectionBody purchase">
{{ location.powerPurchase?.value?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Solar
</div>
<div class="SectionBody produce">
{{ location.powerProduce?.value?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Verbrauch
</div>
<div class="SectionBody consume">
{{ location.powerConsume?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Einspeisung
</div>
<div class="SectionBody deliver">
{{ location.powerDeliver?.value?.toValueString(dateService.now) }}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1 @@
@import "../../../colors";

View File

@ -0,0 +1,22 @@
import {Component, Input} from '@angular/core';
import {Location} from '../Location';
import {DateService} from '../../date.service';
@Component({
selector: 'app-location-power',
imports: [],
templateUrl: './location-power.html',
styleUrl: './location-power.less',
})
export class LocationPower {
@Input()
location!: Location;
constructor(
readonly dateService: DateService,
) {
//
}
}

View File

@ -33,4 +33,8 @@ export class Series {
); );
} }
equals(other: Series | null | undefined): boolean {
return this.id === other?.id;
}
} }

View File

@ -4,10 +4,12 @@ import {Series} from './Series';
export class Value { export class Value {
static readonly NONE: Value = new Value(null, 0, 0, "", new Date()); static readonly NULL: Value = new Value(NaN, 0, 0, "", new Date());
private constructor( static readonly ZERO: Value = new Value(0, 0, Infinity, "", new Date());
readonly value: number | null,
protected constructor(
readonly value: number,
readonly precision: number, readonly precision: number,
readonly seconds: number, readonly seconds: number,
readonly unit: string, readonly unit: string,
@ -16,57 +18,43 @@ export class Value {
// //
} }
toValueString(zeroToDash: boolean, now_ageCheckToDash: Date | null): string { toValueString(now: Date | null): string {
if (this.value === null) { if (isNaN(this.value)) {
return "-"; return "-";
} }
if (this.value === 0) { if (this.value === 0) {
return zeroToDash ? "-" : `0 ${this.unit}`; return `0 ${this.unit}`;
} }
if (now_ageCheckToDash !== null) { if (now !== null && this.isOld(now)) {
const ageSeconds = (now_ageCheckToDash.getTime() - this.date.getTime()) / 1000;
if (ageSeconds > this.seconds * 2.1) {
return `--- ${this.unit}` return `--- ${this.unit}`
} }
}
const scale = Math.floor(Math.log10(this.value)); const scale = Math.floor(Math.log10(this.value));
const rest = scale - this.precision + 1; const rest = scale - this.precision + 1;
if (isNaN(rest)) {
console.log(this);
}
if (rest >= 0) { if (rest >= 0) {
return `${Math.round(this.value)} ${this.unit}`; return `${Math.round(this.value)} ${this.unit}`;
} }
return formatNumber(this.value, "de-DE", `0.${-rest}-${-rest}`) + ' ' + this.unit; return formatNumber(this.value, "de-DE", `0.${-rest}-${-rest}`) + ' ' + this.unit;
} }
plus(other: Value): Value { plus(other: Value | null | undefined, nullToZero: boolean): Value {
return this.operateSameUnit("plus", other, (a, b) => a + b); if (!nullToZero && (other === null || other === undefined)) {
return Value.NULL;
}
return new BiValue(this, other || Value.ZERO, (a, b) => a + b);
} }
minus(other: Value): Value { minus(other: Value | null | undefined, nullToZero: boolean): Value {
return this.operateSameUnit("minus", other, (a, b) => a - b); if (!nullToZero && (other === null || other === undefined)) {
return Value.NULL;
} }
return new BiValue(this, other || Value.ZERO, (a, b) => a - b);
operateSameUnit(name: string, other: Value, operation: (a: number, b: number) => number): Value {
if (this.value === null || other.value === null) {
return Value.NONE;
}
if (this.unit !== other.unit) {
throw new Error(`Operation '${name} needs units to be the same: this=${this}, other=${other}`);
}
const decimals = Math.max(this.precision, other.precision);
const seconds = Math.max(this.seconds, other.seconds);
const date = this.date.getTime() < other.date.getTime() ? this.date : other.date;
return new Value(operation(this.value, other.value), decimals, seconds, this.unit, date);
} }
static of(series: Series, value: number | null | undefined, date: Date | null | undefined): Value { static of(series: Series, value: number | null | undefined, date: Date | null | undefined): Value {
value = value === undefined ? null : value; value = value === undefined ? null : value;
date = date === undefined ? null : date; date = date === undefined ? null : date;
if (value === null) { if (value === null) {
return this.NONE; return this.NULL;
} }
if (date === null) { if (date === null) {
throw new Error("When 'value' is set, 'last' must be set too, but isn't!") throw new Error("When 'value' is set, 'last' must be set too, but isn't!")
@ -77,12 +65,12 @@ export class Value {
static ofPoint(response: PointResponse, seriesIndex: number, pointIndex: number, valueIndex: number): Value { static ofPoint(response: PointResponse, seriesIndex: number, pointIndex: number, valueIndex: number): Value {
const series = response.series[seriesIndex]; const series = response.series[seriesIndex];
if (!series) { if (!series) {
return this.NONE; return this.NULL;
} }
const point = series.points[pointIndex]; const point = series.points[pointIndex];
if (!point) { if (!point) {
return this.NONE; return this.NULL;
} }
const date = new Date(point[0] * 1000); const date = new Date(point[0] * 1000);
@ -90,4 +78,31 @@ export class Value {
return Value.of(series.series, value, date); return Value.of(series.series, value, date);
} }
isOld(now: Date) {
const ageSeconds = (now.getTime() - this.date.getTime()) / 1000;
return ageSeconds > this.seconds * 2.1;
}
}
export class BiValue extends Value {
constructor(
readonly a: Value,
readonly b: Value,
readonly operation: (a: number, b: number) => number,
) {
if (a.unit !== "" && b.unit !== "" && a.unit !== b.unit) {
throw new Error(`Operation needs units to be equal or empty: this=${a}, other=${b}`);
}
const precision = Math.max(a.precision, b.precision);
const unit = a.unit || b.unit;
const date = a.date.getTime() < b.date.getTime() ? a.date : b.date;
super(operation(a.value, b.value), precision, 0, unit, date);
}
override isOld(now: Date): boolean {
return this.a.isOld(now) || this.b.isOld(now);
}
} }

View File

@ -8,7 +8,7 @@
<option [ngValue]="null">-</option> <option [ngValue]="null">-</option>
@for (series of series; track series.id) { @for (series of series; track series.id) {
<option [ngValue]="series.id"> <option [ngValue]="series.id">
{{ series.name }}: {{ series.value.toValueString(false, now) }} {{ series.name }}: {{ series.value.toValueString(dateService.now) }}
</option> </option>
} }
</select> </select>

View File

@ -1,10 +1,13 @@
import {Component, EventEmitter, Input, Output} from '@angular/core'; import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core';
import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {NgClass} from '@angular/common'; import {NgClass} from '@angular/common';
import {faPen} from '@fortawesome/free-solid-svg-icons'; import {faPen} from '@fortawesome/free-solid-svg-icons';
import {Series} from '../Series'; import {Series} from '../Series';
import {or} from '../../common'; import {or} from '../../common';
import {SeriesService} from '../series-service';
import {map, Subscription} from 'rxjs';
import {DateService} from '../../date.service';
@Component({ @Component({
selector: 'app-series-select', selector: 'app-series-select',
@ -16,21 +19,21 @@ import {or} from '../../common';
templateUrl: './series-select.html', templateUrl: './series-select.html',
styleUrl: './series-select.less', styleUrl: './series-select.less',
}) })
export class SeriesSelect { export class SeriesSelect implements OnInit, OnDestroy {
protected readonly faPen = faPen; protected readonly faPen = faPen;
private _initial: Series | null = null; private _initial: Series | null = null;
@Input()
now!: Date;
@Input() @Input()
series!: Series[]; series!: Series[];
@Input() @Input()
allowEmpty: boolean = true; allowEmpty: boolean = true;
@Input()
filter: (series: Series) => boolean = () => true;
@Output() @Output()
readonly onChange = new EventEmitter<number | null>(); readonly onChange = new EventEmitter<number | null>();
@ -40,24 +43,43 @@ export class SeriesSelect {
protected readonly Series = Series; protected readonly Series = Series;
private readonly subs: Subscription[] = [];
constructor(
readonly seriesService: SeriesService,
readonly dateService: DateService,
) {
//
}
ngOnInit(): void {
this.subs.push(
this.seriesService
.all$
.pipe(map(series => series.filter(this.filter)))
.subscribe(list => this.series = list)
);
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
}
@Input() @Input()
set initial(value: Series | null) { set initial(value: Series | null) {
this._initial = value; this._initial = value;
this.reset(); this.reset();
} }
private reset() { private readonly reset = (): void => {
this.model = or(this.initial, i => i.id, null); this.model = or(this._initial, i => i.id, null);
} };
get initial(): Series | null {
return this._initial;
}
protected classes(): {} { protected classes(): {} {
return { return {
"unchanged": this.model === this.initial, "unchanged": this.model === this._initial,
"changed": this.model !== or(this.initial, i => i.id, null), "changed": this.model !== or(this._initial, i => i.id, null),
"invalid": !this.allowEmpty && this.model === null, "invalid": !this.allowEmpty && this.model === null,
}; };
} }

View File

@ -1,14 +1,14 @@
import {Inject, Injectable, LOCALE_ID} from '@angular/core'; import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common'; import {ApiService, CrudService, WebsocketService} from '../common';
import {Series} from './Series'; import {Series} from './Series';
import {DatePipe} from '@angular/common'; import {BehaviorSubject, Observable} from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class SeriesService extends CrudService<Series> { export class SeriesService extends CrudService<Series> {
private readonly datePipe: DatePipe; private readonly allSubject: BehaviorSubject<Series[]> = new BehaviorSubject<Series[]>([]);
constructor( constructor(
api: ApiService, api: ApiService,
@ -16,11 +16,21 @@ export class SeriesService extends CrudService<Series> {
@Inject(LOCALE_ID) readonly locale: string, @Inject(LOCALE_ID) readonly locale: string,
) { ) {
super(api, ws, ['Series'], Series.fromJson); super(api, ws, ['Series'], Series.fromJson);
this.datePipe = new DatePipe(locale); this.subscribe(fresh => {
const index = this.allSubject.value.findIndex(series => series.id === fresh.id);
if (index >= 0) {
const list = [...this.allSubject.value];
list.splice(index, 1, fresh);
this.allSubject.next(list);
} else {
this.allSubject.next([...this.allSubject.value, fresh]);
}
});
this.getList(['findAll'], list => this.allSubject.next(list));
} }
findAll(next: Next<Series[]>) { get all$(): Observable<Series[]> {
this.getList(['findAll'], next); return this.allSubject.asObservable();
} }
} }