Compare commits

..

10 Commits

46 changed files with 980 additions and 402 deletions

View File

@ -9,3 +9,5 @@ spring.jpa.hibernate.ddl-auto=update
spring.jackson.serialization.indent_output=true spring.jackson.serialization.indent_output=true
#- #-
de.ph87.data.mqtt.uri=tcp://10.0.0.50:1883 de.ph87.data.mqtt.uri=tcp://10.0.0.50:1883
#
server.port=8084

19
deploy-backend.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
cd "$(dirname "$0")" || exit 1
if ! ( git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ] ); then
echo "git has changes! Aborting..."
exit 1
fi
if ! mvn clean install; then
echo "mvn clean install failed! Aborting..."
exit 1
fi
TAG="DEPLOY---BACKEND---$(date +'%Y-%m-%d---%H-%M-%S')"
ssh root@10.255.0.1 -p 2222 "systemctl stop Data2025.service"
rsync -e 'ssh -p2222' target/Data2025.jar root@10.255.0.1:/srv/Data2025/ --progress
ssh root@10.255.0.1 -p 2222 "systemctl start Data2025.service"
git tag "$TAG"

18
deploy-frontend.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
cd "$(dirname "$0")/src/main/angular/" || exit 1
if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
if ng build; then
TAG="DEPLOY---FRONTEND---$(date +'%Y-%m-%d---%H-%M-%S')"
git rev-parse HEAD > dist/angular/browser/git.hash
echo "$TAG" > dist/angular/browser/git.tag
rsync --archive --delete -e 'ssh -p2222' dist/angular/browser/ root@10.255.0.1:/srv/Data2025/www/ --progress
git tag "$TAG"
else
echo "ng build failed! Aborting..."
fi
else
echo "git has changes! Aborting..."
exit 1
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!--suppress HtmlDeprecatedAttribute -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<circle fill="#4a97b8" cx="256" cy="256" r="256"/>
<path fill="#57b153" d="M412,280l-60-64l-60,127.252L172,280L52,400v10.62C98.752,472.204,172.72,512,256,512c141.384,0,256-114.616,256-256c0-44.752-11.512-86.8-31.696-123.404L412,280z"/>
<g stroke="black" stroke-width="10px">
<path d="M52,408c-2.048,0-4.092-0.78-5.656-2.344c-3.124-3.124-3.124-8.188,0-11.312l120-120c2.48-2.48,6.28-3.064,9.388-1.42l112.6,59.36l56.432-119.688c1.124-2.4,3.368-4.08,5.984-4.492c2.624-0.424,5.268,0.496,7.084,2.428l51.892,55.356L480.74,112.64c1.86-4.012,6.612-5.748,10.62-3.896c4.008,1.856,5.752,6.612,3.896,10.62l-76,164c-1.116,2.416-3.36,4.112-5.984,4.54c-2.616,0.408-5.288-0.492-7.104-2.424l-51.948-55.424L299.24,346.66c-0.924,1.976-2.628,3.484-4.7,4.176c-2.064,0.696-4.332,0.508-6.264-0.508l-114.78-60.504L57.664,405.652C56.096,407.22,54.048,408,52,408z"/>
</g>
<g fill="#F5F5F5">
<path d="M172,264c8.84,0,16,7.16,16,16s-7.16,16-16,16s-16-7.16-16-16S163.16,264,172,264 M172,256c-13.236,0-24,10.764-24,24s10.764,24,24,24s24-10.764,24-24S185.236,256,172,256L172,256z"/>
<path d="M46.964,388c8.84,0,16,7.16,16,16s-7.16,16-16,16s-16-7.16-16-16S38.124,388,46.964,388 M46.964,380c-13.236,0-24,10.764-24,24s10.764,24,24,24s24-10.764,24-24S60.2,380,46.964,380L46.964,380z"/>
<path d="M486.96,100c8.844,0,16,7.16,16,16s-7.156,16-16,16c-8.836,0-16-7.16-16-16S478.124,100,486.96,100M486.96,92c-13.236,0-24,10.764-24,24s10.764,24,24,24c13.24,0,24-10.764,24-24S500.2,92,486.96,92L486.96,92z"/>
<path d="M352,200c8.84,0,16,7.16,16,16s-7.16,16-16,16s-16-7.16-16-16S343.16,200,352,200 M352,192c-13.236,0-24,10.764-24,24s10.764,24,24,24s24-10.764,24-24S365.236,192,352,192L352,192z"/>
</g>
<g fill="black">
<circle cx="172" cy="280" r="16"/>
<circle cx="46.964" cy="404" r="16"/>
<circle cx="486.96" cy="116" r="16"/>
<circle cx="352" cy="216" r="16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -6,12 +6,15 @@
<div class="MainMenuItem MainMenuTitle"> <div class="MainMenuItem MainMenuTitle">
{{ menuService.title }} {{ menuService.title }}
</div> </div>
@if (!ws.connected) {
<div class="MainMenuItem MainMenuNotConnected">NICHT VERBUNDEN</div>
}
</div> </div>
</div> </div>
<div class="MainMenuDrawer NoUserSelect" [hidden]="!showDrawer"> <div class="MainMenuDrawer NoUserSelect" [hidden]="!showDrawer">
@for (location of locationList; track location.id) { @for (location of locationList; track location.id) {
<div class="MainMenuItem" routerLink="Location/{{ location.id }}" routerLinkActive="MainMenuItemActive" (click)="showDrawer = false">{{ location.name }}</div> <div class="MainMenuItem" (click)="navigate(`Location/${location.id}`); showDrawer = false" routerLinkActive="MainMenuItemActive">{{ location.name }}</div>
} }
</div> </div>

View File

@ -21,6 +21,14 @@
background-color: lightskyblue; background-color: lightskyblue;
} }
.MainMenuTitle {
flex: 1;
}
.MainMenuNotConnected {
color: red;
}
} }
} }

View File

@ -5,6 +5,5 @@ import {LocationDetail} from './location/detail/location-detail';
export const routes: Routes = [ export const routes: Routes = [
{path: 'Location/:id', component: LocationDetail}, {path: 'Location/:id', component: LocationDetail},
{path: 'Location', component: LocationList}, {path: 'Location', component: LocationList},
{path: '**', redirectTo: 'Location'}, {path: '**', redirectTo: '/Location'},
{path: '**', redirectTo: 'Location'},
]; ];

View File

@ -1,14 +1,15 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; import {Router, RouterLinkActive, RouterOutlet} from '@angular/router';
import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faBars} from '@fortawesome/free-solid-svg-icons'; import {faBars} from '@fortawesome/free-solid-svg-icons';
import {MenuService} from './menu-service'; import {MenuService} from './menu-service';
import {Location} from './location/Location'; import {Location} from './location/Location';
import {LocationService} from './location/location-service'; import {LocationService} from './location/location-service';
import {WebsocketService} from './common';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, FaIconComponent, RouterLink, RouterLinkActive], imports: [RouterOutlet, FaIconComponent, RouterLinkActive],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.less' styleUrl: './app.less'
}) })
@ -23,6 +24,8 @@ export class App implements OnInit, OnDestroy {
constructor( constructor(
readonly locationService: LocationService, readonly locationService: LocationService,
readonly menuService: MenuService, readonly menuService: MenuService,
readonly router: Router,
readonly ws: WebsocketService,
) { ) {
// //
} }
@ -36,4 +39,10 @@ export class App implements OnInit, OnDestroy {
this.menuService.title = ""; this.menuService.title = "";
} }
navigate(url: string): void {
this.router.navigateByUrl('/', {skipLocationChange: true}).then(() => {
this.router.navigate([url]);
})
}
} }

View File

@ -31,7 +31,8 @@ export function validateString(value: any): string {
} }
export function validateDate(value: any): Date { export function validateDate(value: any): Date {
return new Date(Date.parse(validateString(value))); const parsed = Date.parse(validateString(value));
return new Date(parsed);
} }
export function validateList<T>(value: any, fromJson: FromJson<T>): T[] { export function validateList<T>(value: any, fromJson: FromJson<T>): T[] {
@ -51,7 +52,7 @@ export function or<T, R, E>(t: T | null | undefined, map: (t: T) => R, orElse: E
} }
export function url(protocol: string, path: any[]): string { export function url(protocol: string, path: any[]): string {
return `${protocol}${location.protocol === 'https://' ? 's' : ''}://localhost:8080/${path.join('/')}`; return `${protocol}${location.protocol === 'https://' ? 's' : ''}://${location.hostname}:8084/${path.join('/')}`;
} }
export function stompServiceFactory() { export function stompServiceFactory() {
@ -71,24 +72,28 @@ export function stompServiceFactory() {
providedIn: 'root' providedIn: 'root'
}) })
export class WebsocketService { export class WebsocketService {
private _connected: boolean = false;
get connected(): boolean {
return this._connected;
}
constructor( constructor(
readonly stompService: StompService, readonly stompService: StompService,
) { ) {
this.websocketConnected(() => this._websocketError = false); this.onChange(connected => this._connected = connected);
this.websocketDisconnected(() => this._websocketError = true);
} }
private _websocketError: boolean = false; onChange(next: Next<boolean>): Subscription {
return this.stompService.connectionState$.pipe(map(state => state === RxStompState.OPEN)).subscribe(next);
get websocketError(): boolean {
return this._websocketError;
} }
websocketConnected(next: Next<void>): Subscription { onConnect(next: Next<void>): Subscription {
return this.stompService.connectionState$.pipe(filter(state => state === RxStompState.OPEN)).subscribe(_ => next()); return this.stompService.connectionState$.pipe(filter(state => state === RxStompState.OPEN)).subscribe(_ => next());
} }
websocketDisconnected(next: Next<void>): Subscription { onDisconnect(next: Next<void>): Subscription {
return this.stompService.connectionState$.pipe(filter(state => state !== RxStompState.OPEN)).subscribe(_ => next()); return this.stompService.connectionState$.pipe(filter(state => state !== RxStompState.OPEN)).subscribe(_ => next());
} }

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,9 +0,0 @@
<div class="segments">
@for (segment of segments; track segment) {
<div class="segment">
<!-- @for (graph of graphs; track graph) {-->
<!-- <div class="" [style.background-color]="graph[0]" [style.height.%]="graph[1][segment] / total(segment) * 100"></div>-->
<!-- }-->
</div>
}
</div>

View File

@ -1,9 +0,0 @@
.segments {
display: flex;
height: 4em;
.segment {
flex: 1;
height: 100%;
}
}

View File

@ -1,70 +0,0 @@
import {AfterViewInit, Component, Input} from '@angular/core';
import {Location} from '../../../Location';
import {Interval} from '../../../../series/Interval';
import {PointService} from '../../../../point/point-service';
@Component({
selector: 'app-series-history-graph',
imports: [],
templateUrl: './simple-plot.component.html',
styleUrl: './simple-plot.component.less',
})
export class SeriesHistoryGraph implements AfterViewInit {
protected segments = Array.from(Array(288).keys());
protected totals: number[] = [];
protected historyEnergyPurchase: number[] | null = null;
protected historyEnergyDeliver: number[] | null = null;
protected historyEnergyProduce: number[] | null = null;
protected readonly Interval = Interval;
@Input()
heading!: string;
@Input()
date!: Date;
@Input()
interval!: Interval;
@Input()
location!: Location;
constructor(
readonly pointService: PointService,
) {
//
}
ngAfterViewInit(): void {
// this.history(this.location?.energyPurchase, history => this.historyEnergyPurchase = history);
// this.history(this.location?.energyDeliver, history => this.historyEnergyDeliver = history);
// this.history(this.location?.energyProduce, history => this.historyEnergyProduce = history);
}
// public readonly updateSeries = (fresh: Series): void => {
// if (fresh.id === this.location?.energyPurchase?.id) {
// this.history(this.location?.energyPurchase, history => this.historyEnergyPurchase = history);
// }
// if (fresh.id === this.location?.energyDeliver?.id) {
// this.history(this.location?.energyDeliver, history => this.historyEnergyDeliver = history);
// }
// if (fresh.id === this.location?.energyProduce?.id) {
// this.history(this.location?.energyProduce, history => this.historyEnergyProduce = history);
// }
// };
//
// private history(series: Series | null | undefined, next: Next<number[] | null>) {
// if (!series || !this.interval) {
// next(null);
// return
// }
// this.pointService.points(series, this.date, this.interval, next);
// }
}

View File

@ -1,114 +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: './series-history.html',
styleUrl: './series-history.less',
})
export class SeriesHistory implements OnInit, AfterViewInit, OnDestroy {
protected readonly Interval = Interval;
private readonly subs: Subscription[] = [];
protected purchase: Value | null = null;
protected deliver: Value | null = null;
protected produce: Value | null = null;
protected consume: Value | null = null;
@Input()
heading!: string;
@Input()
offset: number = 0;
@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 | null>) {
const n = (value: Value | null) => {
next(value);
this.consume = this.purchase?.plus(this.produce)?.minus(this.deliver) || null;
}
if (fresh !== null && fresh !== undefined) {
if (fresh.id !== series?.id) {
return;
}
series = fresh;
}
if (!series) {
n(null);
return
}
if (this.interval) {
this.pointService.relative([series], this.interval, this.offset, 1, response => n(Value.ofPoint(response, 0, 0, 1)));
} else {
n(series.value);
}
}
protected nullOrZero(value: Value | null | undefined): boolean {
return value === null || value === undefined || value.value === 0;
}
}

View File

@ -1,10 +1,21 @@
@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-location-energy [location]="location" [interval]="Interval.DAY" heading="Heute"></app-location-energy>
<app-series-history [now]="now" [location]="location" [interval]="Interval.DAY" [offset]="1" heading="Gestern"></app-series-history> <app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offset">
<ng-content #SeriesHistoryHeading>
<div style="display: flex; width: 100%">
&nbsp;
<div (click)="offset += 1">&larr;</div>
&nbsp;
<div (click)="offset = Math.max(1, offset -1)">&rarr;</div>
&nbsp;
<div style="flex: 1">{{ offsetDayTitle() }}</div>
</div>
</ng-content>
</app-location-energy>
<div class="Section"> <div class="Section">
<div class="SectionHeading"> <div class="SectionHeading">
@ -20,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">
@ -40,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>
@ -60,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">
@ -70,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">
@ -80,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>
@ -100,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">
@ -110,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">
@ -120,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

@ -1,23 +1,19 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core';
import {LocationService} from '../location-service'; import {LocationService} from '../location-service';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute} from '@angular/router';
import {Location} from '../Location'; 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 '../energy/location-energy';
import {SeriesType} from '../../series/SeriesType';
import {Subscription, timer} from 'rxjs';
import {SeriesHistory} from './history/series-history';
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';
function yesterday(now: any) { import {Series} from '../../series/Series';
const yesterday = new Date(now.getTime()); import {SeriesType} from '../../series/SeriesType';
yesterday.setDate(yesterday.getDate() - 1); import {DateService} from '../../date.service';
return yesterday; import {LocationPower} from '../power/location-power';
}
@Component({ @Component({
selector: 'app-location-detail', selector: 'app-location-detail',
@ -25,75 +21,71 @@ function yesterday(now: any) {
Text, Text,
Number, Number,
SeriesSelect, SeriesSelect,
SeriesHistory 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 location: Location | null = null; protected location: Location | null = null;
private readonly subs: Subscription [] = []; private readonly subs: Subscription [] = [];
private series: Series[] = []; protected offset: number = 1;
protected now: Date = new Date(); private readonly datePipe: DatePipe;
protected yesterday: Date = yesterday(this.now);
constructor( constructor(
readonly locationService: LocationService, readonly locationService: LocationService,
readonly seriesService: SeriesService,
readonly activatedRoute: ActivatedRoute, readonly activatedRoute: ActivatedRoute,
readonly menuService: MenuService, readonly menuService: MenuService,
readonly dateService: DateService,
@Inject(LOCALE_ID) readonly locale: string,
) { ) {
// this.datePipe = new DatePipe(locale);
} }
ngOnInit(): void { ngOnInit(): void {
this.activatedRoute.params.subscribe(params => { this.locationService.id = null;
this.locationService.getById(params['id'], location => { this.subs.push(this.activatedRoute.params.subscribe(params => this.locationService.id = params['id'] || null));
this.location = location; this.subs.push(this.locationService.location$.subscribe(this.onLocationChange));
this.menuService.title = this.location.name;
});
});
this.seriesService.findAll(list => this.series = list);
this.subs.push(this.seriesService.subscribe(this.updateSeries));
this.subs.push(timer(1000, 1000).subscribe(() => {
this.now = new Date();
this.yesterday = yesterday(this.now);
}));
} }
private readonly onLocationChange = (location: Location | null): void => {
this.location = location;
if (this.location) {
this.menuService.title = this.location.name;
}
};
ngOnDestroy(): void { ngOnDestroy(): void {
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 => { protected offsetDayTitle(): string {
if (this.location?.id === location.id) { if (this.offset === 1) {
this.location = location; return 'Gestern';
}
};
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 { } else {
this.series.push(fresh); if (this.offset < 7) {
const d = new Date(this.dateService.now);
d.setDate(d.getDate() - this.offset);
return this.datePipe.transform(d, 'EEEE') || '';
}
return `Vor ${this.offset} Tagen`;
} }
}; }
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');
};
} }

View File

@ -2,6 +2,7 @@
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
{{ heading }} {{ heading }}
<ng-content #SeriesHistoryHeading></ng-content>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
@ -10,8 +11,8 @@
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Bezug Bezug
</div> </div>
<div class="SectionBody purchase"> <div class="SectionBody COLOR_FONT_PURCHASE">
{{ purchase?.toValueString(true, interval ? null : now) }} {{ purchase.toValueString(null) }}
</div> </div>
</div> </div>
@ -19,8 +20,8 @@
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Solar Solar
</div> </div>
<div class="SectionBody produce"> <div class="SectionBody COLOR_FONT_PRODUCE">
{{ produce?.toValueString(true, interval ? null : now) }} {{ produce.toValueString(null) }}
</div> </div>
</div> </div>
@ -28,8 +29,8 @@
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Verbrauch Verbrauch
</div> </div>
<div class="SectionBody consume"> <div class="SectionBody COLOR_FONT_CONSUME">
{{ consume?.toValueString(true, interval ? null : now) }} {{ consume.toValueString(null) }}
</div> </div>
</div> </div>
@ -37,15 +38,13 @@
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Einspeisung Einspeisung
</div> </div>
<div class="SectionBody deliver"> <div class="SectionBody COLOR_FONT_DELIVER">
{{ deliver?.toValueString(true, interval ? null : now) }} {{ deliver.toValueString(null) }}
</div> </div>
</div> </div>
</div> </div>
@if (interval) { <app-energy-plot [location]="location" [interval]="interval" [offset]="offset"></app-energy-plot>
<!-- <app-series-history-graph></app-series-history-graph>-->
}
</div> </div>

View File

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

View File

@ -0,0 +1,110 @@
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';
import {EnergyPlot} from './plot/energy-plot';
@Component({
selector: 'app-location-energy',
imports: [
EnergyPlot
],
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, this.interval, response => callNextAndUpdateConsume(Value.ofPoint(response, 0, 0, 1)));
} else {
callNextAndUpdateConsume(series.value);
}
}
}

View File

@ -0,0 +1,98 @@
export class EnergyPoint {
readonly epochSeconds: number;
private _purchase: number | null = null;
private _produce: number | null = null;
private _deliver: number | null = null;
private _self: number | null = null;
private _consume: number | null = null;
private _purchaseY: number | null = null;
private _selfY: number | null = null;
private _deliverY: number | null = null;
getPurchaseY(yFactor: number): number {
return this.getPurchaseH(yFactor) + this.getSelfH(yFactor);
}
getPurchaseH(yFactor: number): number {
if (this._purchaseY === null) {
this._purchaseY = (this.purchase || 0) * yFactor;
}
return this._purchaseY;
}
getSelfY(yFactor: number): number {
return this.getSelfH(yFactor);
}
getSelfH(yFactor: number): number {
if (this._selfY === null) {
this._selfY = (this.self || 0) * yFactor;
}
return this._selfY;
}
getDeliverH(yFactor: number): number {
if (this._deliverY === null) {
this._deliverY = (this.deliver || 0) * yFactor;
}
return this._deliverY;
}
constructor(
point: number[],
) {
this.epochSeconds = point[0];
}
set deliver(value: number | null) {
this._deliver = value;
this.update();
}
set produce(value: number | null) {
this._produce = value;
this.update();
}
set purchase(value: number | null) {
this._purchase = value;
this.update();
}
private update() {
if (this._purchase !== null && this._produce !== null && this._deliver !== null) {
this._self = Math.max(0, this._produce - this._deliver);
this._consume = Math.max(0, this._purchase + this._self);
}
}
get consume(): number | null {
return this._consume;
}
get self(): number | null {
return this._self;
}
get deliver(): number | null {
return this._deliver;
}
get produce(): number | null {
return this._produce;
}
get purchase(): number | null {
return this._purchase;
}
}

View File

@ -0,0 +1,33 @@
<svg [attr.viewBox]="`0 0 ${widthPx} ${heightPx}`" [style.background-color]="'#eee'">
@for (point of points; track point.epochSeconds) {
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx - 1 + yMinPx - point.getPurchaseY(yFactor)"
[attr.width]="xWidthPx"
[attr.height]="point.getPurchaseH(yFactor)"
class="COLOR_BACK_PURCHASE"
></rect>
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx - 1 + yMinPx - point.getSelfY(yFactor)"
[attr.width]="xWidthPx"
[attr.height]="point.getSelfH(yFactor)"
class="COLOR_BACK_SELF"
></rect>
<rect
[attr.x]="(point.epochSeconds - xMin) * xFactor"
[attr.y]="heightPx + yMinPx"
[attr.width]="xWidthPx"
[attr.height]="point.getDeliverH(yFactor)"
class="COLOR_BACK_DELIVER"
></rect>
<line
x1="0"
[attr.y1]="heightPx - 1 + yMinPx"
[attr.x2]="widthPx"
[attr.y2]="heightPx - 1 + yMinPx"
stroke="#aaaaaa"
stroke-width="1"
></line>
}
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,147 @@
import {AfterViewInit, Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Location} from '../../Location';
import {Interval} from '../../../series/Interval';
import {PointService} from '../../../point/point-service';
import {PointResponse} from '../../../point/PointResponse';
import {PointSeries} from '../../../point/PointSeries';
import {EnergyPoint} from './EnergyPoint';
import {Subscription, timer} from 'rxjs';
@Component({
selector: 'app-energy-plot',
imports: [],
templateUrl: './energy-plot.html',
styleUrl: './energy-plot.less',
})
export class EnergyPlot implements OnInit, OnDestroy, AfterViewInit {
readonly widthPx = 800;
readonly heightPx = 100;
private _location!: Location;
private _interval: Interval = Interval.FIVE;
private _offset: number = 0;
private _count: number = 1;
protected points: EnergyPoint[] = [];
protected yMin: number = 0;
protected yMinPx: number = 0;
protected yMax: number = 0;
protected yFactor: number = 0;
protected xMin: number = 0;
protected xMax: number = 0;
protected xFactor: number = 0;
protected xWidthPx: number = 0;
private readonly subs: Subscription[] = [];
constructor(
readonly pointService: PointService,
) {
//
}
ngOnInit(): void {
this.subs.push(timer(60000, 60000).subscribe(() => this.ngAfterViewInit()));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
ngAfterViewInit(): void {
if (!this._location.energyPurchase) {
return;
}
if (!this._location.energyProduce) {
return;
}
if (!this._location.energyDeliver) {
return;
}
const series = [this._location.energyPurchase, this._location.energyProduce, this._location.energyDeliver];
this.pointService.relative(series, this._interval, this._offset, this._count, this._interval.inner, this.update);
}
public readonly update = (response: PointResponse): void => {
this.points.length = 0;
this.add(response.series[0], (p, v) => p.purchase = v);
this.add(response.series[1], (p, v) => p.produce = v);
this.add(response.series[2], (p, v) => p.deliver = v);
this.yMax = -Infinity;
this.yMin = Infinity;
for (let point of this.points) {
this.yMax = Math.max(this.yMax, point.consume || 0);
this.yMin = Math.min(this.yMin, -(point.deliver || 0));
}
this.yMinPx = this.yMin * this.yFactor;
this.yFactor = this.heightPx / (this.yMax - this.yMin);
this.xMin = response.begin.getTime() / 1000;
this.xMax = response.end.getTime() / 1000;
this.xFactor = this.widthPx / (this.xMax - this.xMin);
this.xWidthPx = this.widthPx / response.expectedCount;
};
private add(series: PointSeries, setter: (p: EnergyPoint, v: number) => void): void {
for (const point of series.points) {
const index = this.insert(point, setter);
if (index >= 0) {
const fresh = new EnergyPoint(point);
setter(fresh, point[1])
this.points.splice(index, 0, fresh);
}
}
}
private insert(point: number[], setter: (p: EnergyPoint, v: number) => any): number {
let index = 0;
for (let old of this.points) {
const age = old.epochSeconds - point[0];
if (age === 0) {
setter(old, point[1])
return -1;
} else if (age < 0) {
return index;
}
index++;
}
return index;
}
@Input()
set location(value: Location) {
this._location = value;
this.ngAfterViewInit();
}
@Input()
set interval(value: Interval) {
this._interval = value;
this.ngAfterViewInit();
}
@Input()
set offset(value: number) {
this._offset = value;
this.ngAfterViewInit();
}
@Input()
set count(value: number) {
this._count = value;
this.ngAfterViewInit();
}
}

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 COLOR_FONT_PURCHASE">
{{ location.powerPurchase?.value?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Solar
</div>
<div class="SectionBody COLOR_FONT_PRODUCE">
{{ location.powerProduce?.value?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Verbrauch
</div>
<div class="SectionBody COLOR_FONT_CONSUME">
{{ location.powerConsume?.toValueString(dateService.now) }}
</div>
</div>
<div class="Section4">
<div class="SectionHeadingText">
Einspeisung
</div>
<div class="SectionBody COLOR_FONT_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

@ -1,4 +1,4 @@
import {validateDate, validateList} from "../common"; import {validateDate, validateList, validateNumber} from "../common";
import {PointSeries} from './PointSeries'; import {PointSeries} from './PointSeries';
@ -8,6 +8,7 @@ export class PointResponse {
readonly begin: Date, readonly begin: Date,
readonly end: Date, readonly end: Date,
readonly series: PointSeries[], readonly series: PointSeries[],
readonly expectedCount: number,
) { ) {
// //
} }
@ -17,6 +18,7 @@ export class PointResponse {
validateDate(json.begin), validateDate(json.begin),
validateDate(json.end), validateDate(json.end),
validateList(json.series, PointSeries.fromJson), validateList(json.series, PointSeries.fromJson),
validateNumber(json.expectedCount),
); );
} }

View File

@ -16,12 +16,13 @@ export class PointService extends CrudService<PointResponse> {
super(api, ws, ['Point'], PointResponse.fromJson); super(api, ws, ['Point'], PointResponse.fromJson);
} }
relative(series: Series[], interval: Interval, offset: number, count: number, next: Next<PointResponse>): void { relative(series: Series[], outer: Interval, offset: number, count: number, interval: Interval, next: Next<PointResponse>): void {
const request = { const request = {
ids: series.map(s => s.id), ids: series.map(s => s.id),
interval: interval, outerInterval: outer.name,
offset: offset, offset: offset,
count: count, count: count,
interval: interval.name,
}; };
this.postSingle(['relative'], request, next); this.postSingle(['relative'], request, next);
} }

View File

@ -1,8 +1,22 @@
export enum Interval { export class Interval {
FIVE = 'FIVE',
HOUR = 'HOUR', static readonly FIVE = new Interval('FIVE');
DAY = 'DAY',
WEEK = 'WEEK', static readonly HOUR = new Interval('HOUR', this.FIVE);
MONTH = 'MONTH',
YEAR = 'YEAR', static readonly DAY = new Interval('DAY', this.FIVE);
static readonly WEEK = new Interval('WEEK', this.HOUR);
static readonly MONTH = new Interval('MONTH', this.HOUR);
static readonly YEAR = new Interval('YEAR', this.DAY);
constructor(
readonly name: string,
readonly inner: Interval = this,
) {
//
}
} }

View File

@ -5,7 +5,7 @@ import {Value} from './Value';
export class Series { export class Series {
readonly value: Value | null = null; readonly value: Value;
constructor( constructor(
readonly id: number, readonly id: number,
@ -33,4 +33,8 @@ export class Series {
); );
} }
equals(other: Series | null | undefined): boolean {
return this.id === other?.id;
}
} }

View File

@ -4,7 +4,11 @@ import {Series} from './Series';
export class Value { export class Value {
constructor( static readonly NULL: Value = new Value(NaN, 0, 0, "", new Date());
static readonly ZERO: Value = new Value(0, 0, Infinity, "", new Date());
protected constructor(
readonly value: number, readonly value: number,
readonly precision: number, readonly precision: number,
readonly seconds: number, readonly seconds: number,
@ -14,71 +18,59 @@ export class Value {
// //
} }
toValueString(zeroToDash: boolean, now_ageCheckToDash: Date | null): string { toValueString(now: Date | null): string {
if (this.value === null || this.value === undefined) { 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; return `--- ${this.unit}`
if (ageSeconds > this.seconds * 2.1) {
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 | null | undefined): Value | null { 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 | null | undefined): Value | null { 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(operationName: string, other: Value | null | undefined, operation: (a: number, b: number) => number): Value | null { static of(series: Series, value: number | null | undefined, date: Date | null | undefined): Value {
if (!other) { value = value === undefined ? null : value;
return null; date = date === undefined ? null : date;
if (value === null) {
return this.NULL;
} }
if (this.unit !== other.unit) { if (date === null) {
throw new Error(`Operation '${operationName} 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 | null {
if (value === null || value === undefined) {
return null;
}
if (date === null || date === undefined) {
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!")
} }
return new Value(value, series.precision, series.seconds, series.unit, date); return new Value(value, series.precision, series.seconds, series.unit, date);
} }
static ofPoint(response: PointResponse, seriesIndex: number, pointIndex: number, valueIndex: number): Value | null { 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 null; return this.NULL;
} }
const point = series.points[pointIndex]; const point = series.points[pointIndex];
if (!point) { if (!point) {
return null; return this.NULL;
} }
const date = new Date(point[0] * 1000); const date = new Date(point[0] * 1000);
@ -86,4 +78,38 @@ 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;
}
onlyPositive(): Value {
if (this.value < 0) {
return Value.ZERO;
}
return this;
}
}
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();
} }
} }

View File

@ -1,23 +1,60 @@
@empty: gray; @empty: gray;
@purchase: red;
@deliver: magenta;
@produce: #0095ff;
@consume: #ff8800;
.purchase { @COLOR_FONT_PURCHASE: red;
color: @purchase; @COLOR_FONT_DELIVER: magenta;
@COLOR_FONT_PRODUCE: #0095ff;
@COLOR_FONT_SELF: #0095ff;
@COLOR_FONT_CONSUME: #ff8800;
@COLOR_BACK_PURCHASE: #ffa7a7;
@COLOR_BACK_DELIVER: #ff00ff;
@COLOR_BACK_PRODUCE: #5cbcff;
@COLOR_BACK_SELF: #00ff69;
@COLOR_BACK_CONSUME: #ffc07a;
.COLOR_FONT_PURCHASE {
color: @COLOR_FONT_PURCHASE;
} }
.deliver { .COLOR_FONT_DELIVER {
color: @deliver; color: @COLOR_FONT_DELIVER;
} }
.produce { .COLOR_FONT_PRODUCE {
color: @produce; color: @COLOR_FONT_PRODUCE;
} }
.consume { .COLOR_FONT_SELF {
color: @consume; color: @COLOR_FONT_SELF;
}
.COLOR_FONT_CONSUME {
color: @COLOR_FONT_CONSUME;
}
.COLOR_BACK_PURCHASE {
color: @COLOR_BACK_PURCHASE;
fill: @COLOR_BACK_PURCHASE;
}
.COLOR_BACK_DELIVER {
color: @COLOR_BACK_DELIVER;
fill: @COLOR_BACK_DELIVER;
}
.COLOR_BACK_PRODUCE {
color: @COLOR_BACK_PRODUCE;
fill: @COLOR_BACK_PRODUCE;
}
.COLOR_BACK_SELF {
color: @COLOR_BACK_SELF;
fill: @COLOR_BACK_SELF;
}
.COLOR_BACK_CONSUME {
color: @COLOR_BACK_CONSUME;
fill: @COLOR_BACK_CONSUME;
} }
.empty { .empty {

View File

@ -2,11 +2,11 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Angular</title> <title>Data2025</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!--suppress HtmlUnknownTarget --> <!--suppress HtmlUnknownTarget -->
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/svg" href="favicon.svg">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>

View File

@ -4,6 +4,7 @@ body {
margin: 0; margin: 0;
font-family: sans-serif; font-family: sans-serif;
font-size: 4vw; font-size: 4vw;
user-select: none;
} }
div { div {
@ -46,6 +47,7 @@ div {
> .SectionHeading { > .SectionHeading {
color: dimgray; color: dimgray;
> .SectionHeadingText { > .SectionHeadingText {
font-size: 70%; font-size: 70%;
font-style: italic; font-style: italic;

View File

@ -51,7 +51,7 @@ public class Plot {
@Setter @Setter
@Column(nullable = false) @Column(nullable = false)
private long duration = 288; private long duration = 1;
@Setter @Setter
@Column(nullable = false) @Column(nullable = false)

View File

@ -15,13 +15,15 @@ public interface IPointRequest {
@NonNull @NonNull
List<Long> getIds(); List<Long> getIds();
@NonNull
Interval getInterval();
@NonNull @NonNull
ZonedDateTime getBegin(); ZonedDateTime getBegin();
@NonNull @NonNull
ZonedDateTime getEnd(); ZonedDateTime getEnd();
@NonNull
Interval getInterval();
long getExpectedCount();
} }

View File

@ -5,34 +5,40 @@ import lombok.Data;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
@Data @Data
public class PointRequestRelative implements IPointRequest { public class PointRequestRelative implements IPointRequest {
public final List<Long> ids; public final List<Long> ids;
public final Interval interval; public final Interval outerInterval;
public final long offset; public final long offset;
public final long count; public final long count;
public final Interval interval;
public final ZonedDateTime begin; public final ZonedDateTime begin;
public final ZonedDateTime end; public final ZonedDateTime end;
public PointRequestRelative( public final long expectedCount;
final List<Long> ids,
final Interval interval, public PointRequestRelative(final List<Long> ids, final Interval outerInterval, final long offset, final long count, final Interval interval) {
final long offset,
final long count
) {
this.ids = ids; this.ids = ids;
this.interval = interval; this.outerInterval = outerInterval;
this.offset = offset; this.offset = offset;
this.count = count; this.count = count;
this.end = interval.align.apply(ZonedDateTime.now()).minus(interval.amount * (offset - 1), interval.unit); this.interval = interval;
this.begin = this.end.minus(interval.amount * count, interval.unit); this.end = outerInterval.align.apply(ZonedDateTime.now()).minus(outerInterval.amount * (offset - 1), outerInterval.unit);
this.begin = this.end.minus(outerInterval.amount * count, outerInterval.unit);
this.expectedCount = calculateExpectedCount();
}
private long calculateExpectedCount() {
return Stream.iterate(begin, d -> d.isBefore(end), d -> d.plus(interval.amount, interval.unit)).count();
} }
} }

View File

@ -14,4 +14,6 @@ public class PointResponse {
public final List<PointSeries> series; public final List<PointSeries> series;
public final long expectedCount;
} }

View File

@ -30,7 +30,7 @@ public class PointService {
@NonNull @NonNull
public PointResponse points(@NonNull final IPointRequest request) { public PointResponse points(@NonNull final IPointRequest request) {
final List<PointSeries> series = request.getIds().stream().map(s -> points(s, request)).toList(); final List<PointSeries> series = request.getIds().stream().map(s -> points(s, request)).toList();
return new PointResponse(request.getBegin(), request.getEnd(), series); return new PointResponse(request.getBegin(), request.getEnd(), series, request.getExpectedCount());
} }
@NonNull @NonNull

View File

@ -40,9 +40,9 @@ public class SeriesController {
} }
@NonNull @NonNull
@PostMapping("{id}/decimals") @PostMapping("{id}/precision")
public SeriesDto decimals(@PathVariable final long id, @RequestBody final int decimals) { public SeriesDto precision(@PathVariable final long id, @RequestBody final int precision) {
return seriesService.modify(id, series -> series.setPrecision(decimals)); return seriesService.modify(id, series -> series.setPrecision(precision));
} }
@NonNull @NonNull