This commit is contained in:
Patrick Haßel 2025-09-18 13:39:23 +02:00
parent 9d9bf75dd6
commit 2087d5e64d
66 changed files with 2610 additions and 290 deletions

View File

@ -14,6 +14,9 @@
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@stomp/ng2-stompjs": "^8.0.0",
"@stomp/stompjs": "^7.2.0",
"chartjs-adapter-date-fns": "^3.0.0",
@ -1397,6 +1400,64 @@
"node": ">=18"
}
},
"node_modules/@fortawesome/angular-fontawesome": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-3.0.0.tgz",
"integrity": "sha512-+8Dd6DoJnqArfrZ5NvjHyRL64IIkTigXclbOOcFdYQ8/WFERQUDaEU6SAV8Q0JBpJhMS1McED7YCOCAE6SIVyA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/core": "^20.0.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.0.1.tgz",
"integrity": "sha512-0VpNtO5cNe1/HQWMkl4OdncYK/mv9hnBte0Ew0n6DMzmo3Q3WzDFABHm6LeNTipt5zAyhQ6Ugjiu8aLaEjh1gg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.0.1.tgz",
"integrity": "sha512-x0cR55ILVqFpUioSMf6ebpRCMXMcheGN743P05W2RB5uCNpJUqWIqW66Lap8PfL/lngvjTbZj0BNSUweIr/fHQ==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.0.1.tgz",
"integrity": "sha512-4V9fHbHjcx9Qu4O99AM5B4zuEDfB4zajk1I77hEzOxPN00f8g3484Aeq6WpfFcmookvjLE3Pr71Dhf/lqw7tbA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.0.1.tgz",
"integrity": "sha512-esKuSrl1WMOTMDLNt38i16VfLe/gRZt2ZAJ3Yw7slfs7sj583MKqNFqO57zmhknk1Sya6f9Wys89aCzIJkcqlg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz",

View File

@ -28,6 +28,9 @@
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@stomp/ng2-stompjs": "^8.0.0",
"@stomp/stompjs": "^7.2.0",
"chartjs-adapter-date-fns": "^3.0.0",

View File

@ -5,6 +5,7 @@ import {Injectable} from '@angular/core';
import {RxStompState} from '@stomp/rx-stomp';
export type FromJson<T> = (json: any) => T;
export type FromJsonIndexed<T> = (json: any, index: number) => T;
export type Next<T> = (item: T) => any;
@ -52,6 +53,10 @@ export function validateList<T>(json: any, fromJson: FromJson<T>): T[] {
return json.map(fromJson);
}
export function validateListIndexed<T>(json: any, fromJson: FromJsonIndexed<T>): T[] {
return json.map(fromJson);
}
export function url(protocol: string, path: any[]): string {
const secure = location.protocol.endsWith('s:') ? 's' : '';
return `${protocol}${secure}://${location.hostname}:8080/${path.join('/')}`;
@ -116,19 +121,19 @@ export class ApiService {
return this._websocketError;
}
getSingle<T>(path: any[], fromJson: FromJson<T>, next: Next<T>): void {
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): void {
this.http.get<any>(url('http', path)).pipe(map(fromJson)).subscribe(next);
}
getList<T>(path: any[], fromJson: FromJson<T>, next: Next<T[]>): void {
getList<T>(path: any[], fromJson: FromJson<T>, next?: Next<T[]>): void {
this.http.get<any[]>(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
}
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<T>): void {
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): void {
this.http.post<any>(url('http', path), data).pipe(map(fromJson)).subscribe(next);
}
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<T[]>): void {
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T[]>): void {
this.http.post<any[]>(url('http', path), data).pipe(map(list => list.map(fromJson))).subscribe(next);
}
@ -142,7 +147,7 @@ export class ApiService {
return this.stompService.connectionState$.pipe(filter(state => state !== RxStompState.OPEN)).subscribe(_ => next());
}
subscribe<T>(topic: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
subscribe<T>(topic: any[], fromJson: FromJson<T>, next?: Next<T>): Subscription {
return this.stompService
.subscribe(topic.join("/"))
.pipe(
@ -169,22 +174,36 @@ export abstract class CrudService<T> {
this.getList(["findAll"], next);
}
protected getSingle(path: any[], next: Next<T>) {
protected getSingle(path: any[], next?: Next<T>) {
this.api.getSingle<T>([...this.path, ...path], this.fromJson, next);
}
protected getList(path: any[], next: Next<T[]>) {
protected getList(path: any[], next?: Next<T[]>) {
this.api.getList<T>([...this.path, ...path], this.fromJson, next);
}
protected postSingle(path: any[], data: any, next: Next<T>) {
protected postSingle(path: any[], data: any, next?: Next<T>) {
this.api.postSingle<T>([...this.path, ...path], data, this.fromJson, next);
}
subscribe(next: Next<T>): Subscription {
subscribe(next: Next<T>, path: any[] = []): Subscription {
const subs: Subscription[] = [];
subs.push(this.api.subscribe([...this.path], this.fromJson, next));
subs.push(this.api.subscribe([...this.path, ...path], this.fromJson, next));
return new Subscription(() => subs.forEach(sub => sub.unsubscribe()));
}
}
export function NTU<T>(v: T | null | undefined): T | undefined {
if (v === null) {
return undefined;
}
return v;
}
export function UTN<T>(v: T | null | undefined): T | null {
if (v === undefined) {
return null;
}
return v;
}

View File

@ -5,6 +5,11 @@ import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';
import {stompServiceFactory} from './COMMON';
import {StompService} from '@stomp/ng2-stompjs';
import {registerLocaleData} from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
export const appConfig: ApplicationConfig = {
providers: [

View File

@ -1,3 +1 @@
<app-plot></app-plot>
<router-outlet/>

View File

@ -1,3 +1,8 @@
import {Routes} from '@angular/router';
import {PlotComponent} from './plot/plot.component';
export const routes: Routes = [];
export const routes: Routes = [
{path: 'plot', component: PlotComponent},
{path: 'plot/:id', component: PlotComponent},
{path: '**', redirectTo: 'plot'},
];

View File

@ -1,10 +1,9 @@
import {Component, signal} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {Plot} from './plot/plot';
@Component({
selector: 'app-root',
imports: [RouterOutlet, Plot],
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.less'
})

View File

@ -0,0 +1 @@
<input type="checkbox" [(ngModel)]="model" (change)="apply()">

View File

@ -0,0 +1,33 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-checkbox',
imports: [
FormsModule
],
templateUrl: './checkbox.component.html',
styleUrl: './checkbox.component.less'
})
export class CheckboxComponent {
protected _initial: boolean = false;
protected model: boolean = false;
@Output()
readonly onChange = new EventEmitter<boolean>();
@Input()
set initial(v: boolean) {
this._initial = v;
this.model = this._initial;
}
apply() {
if (this.model !== this._initial) {
this.onChange.emit(this.model);
}
}
}

View File

@ -0,0 +1 @@
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -0,0 +1,53 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-number',
imports: [
FormsModule
],
templateUrl: './number.component.html',
styleUrl: './number.component.less'
})
export class NumberComponent {
protected _initial: number | null = null;
protected edit: boolean = false;
protected model: string = "";
@Output()
readonly onChange = new EventEmitter<number | null>();
@Input()
set initial(v: number | null) {
this._initial = v;
if (!this.edit) {
this.reset();
}
}
focus() {
this.edit = true;
}
apply() {
this.edit = false;
const value = parseFloat(this.model);
const value1 = isNaN(value) ? null : value;
if (value1 !== this._initial) {
this.onChange.emit(value1);
}
}
cancel() {
this.edit = false;
this.reset();
}
private reset() {
this.model = this._initial === null ? "" : this._initial + "";
}
}

View File

@ -0,0 +1 @@
<input type="number" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (change)="!edit && apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -0,0 +1,56 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-number-nn',
imports: [
FormsModule
],
templateUrl: './number-n-n.component.html',
styleUrl: './number-n-n.component.less'
})
export class NumberNNComponent {
protected _initial!: number;
protected edit: boolean = false;
protected model: string = "";
@Output()
readonly onChange = new EventEmitter<number>();
@Input()
set initial(v: number) {
this._initial = v;
if (!this.edit) {
this.reset();
}
}
focus() {
this.edit = true;
}
apply() {
this.edit = false;
const value = parseFloat(this.model);
if (isNaN(value)) {
this.reset();
return
}
if (value != this._initial) {
this.onChange.emit(value);
}
}
cancel() {
this.edit = false;
this.reset();
}
private reset() {
this.model = this._initial === null ? "" : this._initial + "";
}
}

View File

@ -0,0 +1 @@
<input type="text" [(ngModel)]="model" (focus)="focus()" (blur)="apply()" (keydown.enter)="apply()" (keydown.esc)="cancel()">

View File

@ -0,0 +1,51 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-text',
imports: [
FormsModule
],
templateUrl: './text.component.html',
styleUrl: './text.component.less'
})
export class TextComponent {
protected _initial!: string;
private edit: boolean = false;
protected model: string = "";
@Output()
readonly onChange = new EventEmitter<string>();
@Input()
set initial(v: string) {
this._initial = v;
if (!this.edit) {
this.reset();
}
}
focus() {
this.edit = true;
}
apply() {
this.edit = false;
if (this.model !== this._initial) {
this.onChange.emit(this.model);
}
}
cancel() {
this.edit = false;
this.reset();
}
private reset() {
this.model = this._initial;
}
}

View File

@ -0,0 +1,45 @@
import {mapNotNull, validateBoolean, validateList, validateNumber, validateString} from "../COMMON";
import {Graph} from './Graph';
import {Plot} from './Plot';
export class Axis {
readonly graphs: Graph[];
constructor(
readonly plot: Plot,
readonly id: number,
readonly version: number,
readonly index: number,
readonly name: string,
readonly unit: string,
readonly visible: boolean,
readonly right: boolean,
readonly min: number | null,
readonly minHard: boolean,
readonly max: number | null,
readonly maxHard: boolean,
graphs: any[],
) {
this.graphs = validateList(graphs, json => Graph.fromJson(this, json))
}
static fromJson(plot: Plot, index: number, json: any): Axis {
return new Axis(
plot,
validateNumber(json.id),
validateNumber(json.version),
index,
validateString(json.name),
validateString(json.unit),
validateBoolean(json.visible),
validateBoolean(json.right),
mapNotNull(json.min, validateNumber),
validateBoolean(json.minHard),
mapNotNull(json.max, validateNumber),
validateBoolean(json.maxHard),
json.graphs,
);
}
}

View File

@ -0,0 +1,49 @@
import {Series} from "../series/Series";
import {Group} from "./Group";
import {validateBoolean, validateNumber, validateString} from "../COMMON";
import {Axis} from './Axis';
import {SeriesType} from '../series/SeriesType';
export class Graph {
readonly type: string = "line";
readonly mid: boolean;
constructor(
readonly axis: Axis,
readonly id: number,
readonly version: number,
readonly series: Series,
readonly name: string,
readonly visible: boolean,
readonly color: string,
readonly factor: number,
readonly group: Group,
readonly stack: string,
readonly min: boolean,
readonly max: boolean,
readonly avg: boolean,
) {
this.mid = this.avg || this.series.type === SeriesType.BOOL || this.series.type === SeriesType.DELTA;
}
static fromJson(axis: Axis, json: any): Graph {
return new Graph(
axis,
validateNumber(json.id),
validateNumber(json.version),
Series.fromJson(json.series),
validateString(json.name),
validateBoolean(json.visible),
validateString(json.color),
validateNumber(json.factor),
validateString(json.group) as Group,
validateString(json.stack),
validateBoolean(json.min),
validateBoolean(json.max),
validateBoolean(json.avg),
);
}
}

View File

@ -0,0 +1,11 @@
export enum Group {
NONE = "NONE",
FIVE_OF_DAY = "FIVE_OF_DAY",
HOUR_OF_DAY = "HOUR_OF_DAY",
HOUR_OF_WEEK = "HOUR_OF_WEEK",
HOUR_OF_MONTH = "HOUR_OF_MONTH",
DAY_OF_WEEK = "DAY_OF_WEEK",
DAY_OF_MONTH = "DAY_OF_MONTH",
DAY_OF_YEAR = "DAY_OF_YEAR",
WEEK_OF_YEAR = "WEEK_OF_YEAR",
}

View File

@ -0,0 +1,43 @@
import {validateBoolean, validateListIndexed, validateNumber, validateString} from '../COMMON';
import {Interval} from '../series/Interval';
import {Axis} from './Axis';
export class Plot {
readonly axes: Axis[];
constructor(
readonly id: number,
readonly version: number,
readonly deleted: boolean,
readonly name: string,
readonly interval: Interval,
readonly offset: number,
readonly duration: number,
readonly dashboard: boolean,
readonly position: number,
axes: any[],
) {
this.axes = validateListIndexed(axes, ((json, index) => Axis.fromJson(this, index, json)));
}
static fromJson(json: any): Plot {
return new Plot(
validateNumber(json.id),
validateNumber(json.version),
validateBoolean(json.deleted),
validateString(json.name),
Interval.fromJson(json.interval),
validateNumber(json.offset),
validateNumber(json.duration),
validateBoolean(json.dashboard),
validateNumber(json.position),
json.axes,
);
}
static comparePosition(a: Plot, b: Plot) {
return a.position - b.position;
}
}

View File

@ -0,0 +1,220 @@
<div class="layout">
<div class="header">
<select [ngModel]="plot" (ngModelChange)="buildPlot($event)">
@for (p of plotList; track p.id) {
<option [ngValue]="p">#{{ p.position }}: {{ p.name || '---' }}</option>
}
</select>
<button (click)="plotService.plotCreate(updateAndBuild);">
<fa-icon [icon]="faPlus"></fa-icon>
</button>
<button (click)="plot && plotService.plotDuplicate(plot, updateAndBuild)" [disabled]="!plot">
<fa-icon [icon]="faCopy"></fa-icon>
</button>
<button (click)="plot && plotService.plotDelete(plot, updatePlot)" [disabled]="!plot">
<fa-icon [icon]="faTrash"></fa-icon>
</button>
</div>
<div #container class="container">
<canvas #chartCanvas></canvas>
</div>
@if (plot) {
<div class="Section PlotDetails">
<table>
<tr>
<th>Name</th>
<th>Intervall</th>
<th>Verschiebung</th>
<th>Dauer</th>
<th class="vertical">Dash</th>
<th>Position</th>
</tr>
<tr>
<td>
<app-text [initial]="plot.name" (onChange)="plotService.plotName(plot, $event, updatePlot)"></app-text>
</td>
<td>
<select [ngModel]="plot.interval" (ngModelChange)="plotService.plotInterval(plot, $event, updatePlot)">
@for (interval of Interval.values; track interval) {
<option [ngValue]="interval">{{ interval.display }}</option>
}
</select>
</td>
<td>
<app-number-nn [initial]="plot.offset" (onChange)="plotService.plotOffset(plot, $event, updatePlot)"></app-number-nn>
</td>
<td>
<app-number-nn [initial]="plot.duration" (onChange)="plotService.plotDuration(plot, $event, updatePlot)"></app-number-nn>
</td>
<td>
<app-checkbox [initial]="plot.dashboard" (onChange)="plotService.plotDashboard(plot, $event, updatePlot)"></app-checkbox>
</td>
<td>
<app-number-nn [initial]="plot.position" (onChange)="plotService.plotPosition(plot, $event, updatePlot)"></app-number-nn>
</td>
</tr>
</table>
</div>
<div class="Section Axes">
<table>
<tr>
<th>
<fa-icon [icon]="faEye"></fa-icon>
</th>
<th>
<fa-icon [icon]="faListAlt"></fa-icon>
</th>
<th>Name</th>
<th>Einheit</th>
<th class="vertical">rechts</th>
<th>min</th>
<th class="vertical">fest</th>
<th>max</th>
<th class="vertical">fest</th>
<th>&nbsp;</th>
</tr>
@for (axis of plot.axes; track axis.id) {
<tr>
<td>
<app-checkbox [initial]="axis.visible" (onChange)="plotService.axisVisible(axis, $event, updatePlot)"></app-checkbox>
</td>
<td>
Y{{ axis.index + 1 }}
</td>
<td>
<app-text [initial]="axis.name" (onChange)="plotService.axisName(axis, $event, updatePlot)"></app-text>
</td>
<td>
<app-text [initial]="axis.unit" (onChange)="plotService.axisUnit(axis, $event, updatePlot)"></app-text>
</td>
<td>
<app-checkbox [initial]="axis.right" (onChange)="plotService.axisRight(axis, $event, updatePlot)"></app-checkbox>
</td>
<td>
<app-number [initial]="axis.min" (onChange)="plotService.axisMin(axis, $event, updatePlot)"></app-number>
</td>
<td>
<app-checkbox [initial]="axis.minHard" (onChange)="plotService.axisMinHard(axis, $event, updatePlot)"></app-checkbox>
</td>
<td>
<app-number [initial]="axis.max" (onChange)="plotService.axisMax(axis, $event, updatePlot)"></app-number>
</td>
<td>
<app-checkbox [initial]="axis.maxHard" (onChange)="plotService.axisMaxHard(axis, $event, updatePlot)"></app-checkbox>
</td>
<td>
<button (click)="plotService.axisDelete(axis, updatePlot)">
<fa-icon [icon]="faTrash"></fa-icon>
Achse
</button>
&nbsp;
<button (click)="plotService.axisAddGraph(axis, updatePlot)">
<fa-icon [icon]="faPlus"></fa-icon>
Grafen
</button>
</td>
</tr>
}
</table>
<button (click)="plotService.plotAddAxis(plot, updatePlot)">
<fa-icon [icon]="faPlus"></fa-icon>
Achse
</button>
</div>
<div class="Section Graphs">
<table>
<tr>
<th>
<fa-icon [icon]="faEye"></fa-icon>
</th>
<th>Name</th>
<th>Serie</th>
<th>Farbe</th>
<th>Faktor</th>
<th>Aggregat</th>
<th>Stack</th>
<th class="vertical">min</th>
<th>&empty;</th>
<th class="vertical">max</th>
<th>Achse</th>
<th>&nbsp;</th>
</tr>
@for (axis of plot.axes; track axis.id) {
@for (graph of axis.graphs; track graph.id) {
<tr [style.background-color]="graph.color">
<td>
<app-checkbox [initial]="graph.visible" (onChange)="plotService.graphVisible(graph, $event, updatePlot)"></app-checkbox>
</td>
<td>
<app-text [initial]="graph.name" (onChange)="plotService.graphName(graph, $event, updatePlot)"></app-text>
</td>
<td>
<select [ngModel]="graph.series.id" (ngModelChange)="plotService.graphSeries(graph, $event, updatePlot)">
@for (s of seriesList; track s.id) {
<option [ngValue]="s.id">{{ s.name }} [{{ s.valueString }}]</option>
}
</select>
</td>
<td>
<app-text [initial]="graph.color" (onChange)="plotService.graphColor(graph, $event, updatePlot)"></app-text>
</td>
<td>
<app-number-nn [initial]="graph.factor" (onChange)="plotService.graphFactor(graph, $event, updatePlot)"></app-number-nn>
</td>
<td>
<select [ngModel]="graph.group" (ngModelChange)="plotService.graphGroup(graph, $event, updatePlot)">
@for (group of groups(); track group) {
<option [ngValue]="group">{{ group }}</option>
}
</select>
</td>
<td>
<app-text [initial]="graph.stack" (onChange)="plotService.graphStack(graph, $event, updatePlot)"></app-text>
</td>
@if (graph.series.type === SeriesType.VARYING) {
<td class="subSeries">
<app-checkbox [initial]="graph.min" (onChange)="plotService.graphMin(graph, $event, updatePlot)"></app-checkbox>
</td>
<td class="subSeries">
<app-checkbox [initial]="graph.avg" (onChange)="plotService.graphAvg(graph, $event, updatePlot)"></app-checkbox>
</td>
<td class="subSeries">
<app-checkbox [initial]="graph.max" (onChange)="plotService.graphMax(graph, $event, updatePlot)"></app-checkbox>
</td>
} @else {
@if (graph.series.type === SeriesType.BOOL) {
<td colspan="3" class="subSeries">Boolean</td>
} @else {
<td colspan="3" class="subSeries">Delta</td>
}
}
<td>
<select [ngModel]="graph.axis.id" (ngModelChange)="plotService.graphAxis(graph, $event, updatePlot)">
@for (axis of plot.axes; track axis.id) {
<option [ngValue]="axis.id">Y{{ axis.index + 1 }} {{ axis.name }}</option>
}
</select>
</td>
<td>
<button (click)="plotService.graphDelete(graph, updatePlot)">
<fa-icon [icon]="faTrash"></fa-icon>
</button>
</td>
</tr>
}
}
</table>
</div>
}
</div>

View File

@ -0,0 +1,43 @@
.header {
display: flex;
}
.container {
width: 100%;
height: 40vh;
}
.subSeries {
text-align: center;
}
.vertical {
writing-mode: vertical-rl;
rotate: 180deg;
text-align: left;
}
.Section {
border-top: 1px solid gray;
padding: 1em 0.5em;
table {
margin-bottom: 0.5em;
}
}
.PlotDetails {
}
.Axes {
background-color: lightgray;
}
.Graphs {
}
button {
padding: 0.5em;
}

View File

@ -0,0 +1,374 @@
import {AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
import {SeriesService} from "../series/series.service";
import 'chartjs-adapter-date-fns';
import {toAvg, toBool, toDelta, toMax, toMin} from '../series/MinMaxAvg';
import {SeriesType} from '../series/SeriesType';
import {PlotService} from './plot.service';
import {Plot} from './Plot';
import {FormsModule} from '@angular/forms';
import {NTU, UTN} from '../COMMON';
import {Graph} from './Graph';
import {Axis} from './Axis';
import {Series} from '../series/Series';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faCopy, faEye, faListAlt} from '@fortawesome/free-regular-svg-icons';
import {TextComponent} from '../common/text/text.component';
import {NumberComponent} from '../common/number/number.component';
import {CheckboxComponent} from '../common/checkbox/checkbox.component';
import {Group} from './Group';
import {NumberNNComponent} from '../common/numberNN/number-n-n.component';
import {Subscription} from 'rxjs';
import {Delta, DeltaService} from '../series/delta/delta-service';
import {Bool, BoolService} from '../series/bool/bool-service';
import {Varying, VaryingService} from '../series/varying/varying-service';
import {Interval} from '../series/Interval';
import {faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
import {ActivatedRoute} from '@angular/router';
import {Location} from '@angular/common';
type Dataset = ChartDataset<any, any>[][number];
Chart.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
TimeScale,
Filler,
);
export function unitInBrackets(unit: string): string {
if (!unit) {
return '';
}
return ` [${unit}]`;
}
@Component({
selector: 'app-plot',
imports: [
FormsModule,
FaIconComponent,
TextComponent,
NumberComponent,
CheckboxComponent,
NumberNNComponent
],
templateUrl: './plot.component.html',
styleUrl: './plot.component.less'
})
export class PlotComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly SeriesType = SeriesType;
protected readonly Interval = Interval;
protected readonly faEye = faEye;
protected readonly faListAlt = faListAlt;
protected readonly faCopy = faCopy;
protected readonly faPlus = faPlus;
protected readonly faTrash = faTrash;
@ViewChild('chartCanvas')
canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('container')
chartContainer!: ElementRef<HTMLDivElement>;
private chart!: Chart;
protected seriesList: Series[] = [];
protected plotList: Plot[] = [];
protected plot: Plot | null = null;
private readonly subscriptions: Subscription[] = [];
private readonly graphSubscriptions: Subscription[] = [];
constructor(
readonly serieService: SeriesService,
readonly plotService: PlotService,
readonly boolService: BoolService,
readonly deltaService: DeltaService,
readonly varyingService: VaryingService,
readonly activatedRoute: ActivatedRoute,
readonly location: Location
) {
//
}
ngOnInit(): void {
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
this.chart.destroy();
}
ngAfterViewInit(): void {
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: true,
labels: {
usePointStyle: true,
},
},
tooltip: {
position: 'nearest',
mode: 'x',
usePointStyle: true,
},
},
scales: {
x: {
type: 'time',
},
}
}
});
this.serieService.findAll(series => {
this.seriesList = series.sort(Series.compareName);
this.plotService.findAll(plots => {
this.plotList = plots.sort(Plot.comparePosition);
this.subscriptions.push(this.plotService.subscribe(this.updatePlot));
this.activatedRoute.params.subscribe(params => {
const id = parseInt(params["id"]);
if (isNaN(id)) {
this.buildPlot(this.plotList[0]);
} else {
this.buildPlot(this.plotList.filter(plot => plot.id === id)[0]);
}
});
});
this.subscriptions.push(this.serieService.subscribe(this.updateSeries));
});
}
readonly buildPlot = (plot: Plot | null | undefined): void => {
this.plot = UTN(plot);
this.graphSubscriptions.forEach(s => s.unsubscribe());
this.graphSubscriptions.length = 0;
for (const key in this.chart.options.scales) {
if (key.startsWith("y")) {
delete this.chart.options.scales[key];
}
}
this.chart.data.labels = [];
this.chart.data.datasets = [];
this.chart.update();
if (!this.plot) {
this.location.go('/plot');
return;
}
this.location.go('/plot/' + this.plot.id);
if (!this.chart.options.plugins) {
return;
}
this.chart.options.plugins.title = {
display: !!this.plot.name,
text: this.plot.name,
};
for (const axis of this.plot.axes) {
this.buildAxis(axis);
}
};
private buildAxis(axis: Axis): void {
if (!this.chart.options.scales) {
return;
}
const name = axis.name + unitInBrackets(axis.unit);
this.chart.options.scales["y" + axis.id] = {
display: axis.visible,
title: {
display: !!name,
text: name,
},
suggestedMin: NTU(axis.min),
suggestedMax: NTU(axis.max),
min: axis.minHard ? NTU(axis.min) : undefined,
max: axis.maxHard ? NTU(axis.max) : undefined,
position: axis.right ? 'right' : 'left',
};
for (const graph of axis.graphs) {
this.buildGraph(graph);
}
}
private buildGraph(graph: Graph): void {
if (!graph.visible) {
return;
}
const midSuffix = graph.series.type === SeriesType.DELTA || graph.series.type === SeriesType.BOOL ? "" : " Ø"
const min: Dataset = graph.min ? this.newDataset(graph, " min", false) : null;
const mid: Dataset = graph.mid ? this.newDataset(graph, midSuffix, min ? '-1' : graph.series.type === SeriesType.BOOL) : null;
const max: Dataset = graph.max ? this.newDataset(graph, " max", min || mid ? '-1' : false) : null;
switch (graph.series.type) {
case SeriesType.BOOL:
this.graphSubscriptions.push(this.boolService.subscribe(bool => this.updateBool(bool, mid), [graph.series.id]));
break;
case SeriesType.DELTA:
this.graphSubscriptions.push(this.deltaService.subscribe(delta => this.updateDelta(delta, mid), [graph.series.id, graph.axis.plot.interval.name]));
break;
case SeriesType.VARYING:
this.graphSubscriptions.push(this.varyingService.subscribe(varying => this.updateVarying(varying, min, mid, max), [graph.series.id, graph.axis.plot.interval.name]));
break;
}
this.points(graph, min, mid, max);
}
private newDataset(graph: Graph, suffix: string, fill: string | number | boolean): Dataset {
this.chart.data.datasets.push({
data: [],
label: `${graph.name || graph.series.name}${suffix}${unitInBrackets(graph.series.unit)}`,
type: graph.type as "line", // TODO
fill: fill || (graph.stack ? 'stack' : false),
stack: graph.stack || undefined,
borderWidth: 1,
// barThickness: 'flex', // TODO
borderColor: graph.color,
pointRadius: graph.series.type === SeriesType.BOOL ? 0 : 4,
pointBackgroundColor: graph.color,
backgroundColor: graph.color + "33",
yAxisID: "y" + graph.axis.id,
pointStyle: graph.type === 'bar' || graph.series.type === SeriesType.BOOL ? 'rect' : 'crossRot',
spanGaps: graph.series.type === SeriesType.BOOL ? Infinity : graph.axis.plot.interval.spanGaps,
});
return this.chart.data.datasets[this.chart.data.datasets.length - 1];
}
private points(graph: Graph, min: Dataset, mid: Dataset, max: Dataset): void {
this.serieService.points(
graph.series,
graph.axis.plot.interval,
graph.axis.plot.offset,
graph.axis.plot.duration,
points => {
if (graph.series.type === SeriesType.BOOL) {
mid.data = toBool(points, graph.factor);
} else if (graph.series.type === SeriesType.DELTA) {
mid.data = toDelta(points, graph.factor);
} else if (graph.series.type === SeriesType.VARYING) {
if (min) {
min.data = toMin(points, graph.factor);
}
if (max) {
max.data = toMax(points, graph.factor);
}
if (mid) {
mid.data = toAvg(points, graph.factor);
}
}
this.chart.update();
},
);
}
protected readonly updatePlot = (fresh: Plot): void => {
const index = this.plotList.findIndex(plot => plot.id === fresh.id);
if (fresh.deleted) {
if (index >= 0) {
this.plotList.splice(index, 1);
}
} else {
if (index < 0) {
this.plotList.push(fresh);
} else {
this.plotList[index] = fresh;
}
}
this.plotList = this.plotList.sort(Plot.comparePosition);
const selected = this.plot?.id === fresh.id;
if (fresh.deleted) {
if (selected) {
const previousIndex = Math.max(0, index - 1);
const previousPlot = this.plotList[previousIndex];
this.buildPlot(previousPlot);
}
} else if (!this.plot || selected) {
this.buildPlot(fresh);
}
};
protected readonly updateSeries = (fresh: Series): void => {
const index = this.seriesList.findIndex(series => series.id === fresh.id);
if (index < 0) {
this.seriesList.push(fresh);
} else if (fresh.deleted) {
this.seriesList.splice(index, 1);
} else {
this.seriesList[index] = fresh;
}
this.seriesList = this.seriesList.sort(Series.compareName);
};
protected groups(): string[] {
return Object.keys(Group);
}
private updateBool(bool: Bool, mid: Dataset): void {
this.updatePoint(mid, bool.date, bool.state ? 1 : 0);
}
private updateDelta(delta: Delta, mid: Dataset): void {
this.updatePoint(mid, delta.date, delta.last - delta.first);
}
private updateVarying(varying: Varying, min: Dataset | null, avg: Dataset | null, max: Dataset | null): void {
if (min) {
this.updatePoint(min, varying.date, varying.min);
}
if (avg) {
this.updatePoint(avg, varying.date, varying.avg);
}
if (max) {
this.updatePoint(max, varying.date, varying.max);
}
}
private updatePoint(dataset: Dataset, date: Date, y: number): void {
const x = date.getTime();
const point = dataset.data.filter((p: PointElement) => p.x === x)[0];
if (point) {
if (point.y !== y) {
point.y = y;
this.chart.update();
}
} else {
dataset.data.push({x: x, y: y}); // TODO check if this is a LIVE/SCROLLING plot (right end of plot is 'now')
this.chart.update();
}
}
protected updateAndBuild = (plot: Plot): void => {
this.updatePlot(plot);
this.buildPlot(plot);
}
}

View File

@ -1,3 +0,0 @@
<div #container class="container">
<canvas #chartCanvas></canvas>
</div>

View File

@ -1,4 +0,0 @@
.container {
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,148 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next} from '../COMMON';
import {Plot} from './Plot';
import {Axis} from './Axis';
import {Graph} from './Graph';
import {Group} from './Group';
import {Interval} from '../series/Interval';
@Injectable({
providedIn: 'root'
})
export class PlotService extends CrudService<Plot> {
constructor(
api: ApiService,
) {
super(api, ["Plot"], Plot.fromJson);
}
plotCreate(next: Next<Plot>): void {
this.getSingle(["create"], next);
}
plotDuplicate(plot: Plot, next: Next<Plot>): void {
this.getSingle([plot.id, "duplicate"], next);
}
plotDelete(plot: Plot, next: Next<Plot>): void {
this.getSingle([plot.id, "delete"], next);
}
plotAddAxis(plot: Plot, next: Next<Plot>): void {
this.getSingle([plot.id, 'addAxis'], next);
}
plotName(plot: Plot, value: string, next?: Next<Plot>): void {
this.postSingle([plot.id, 'name'], value, next);
}
plotInterval(plot: Plot, value: Interval, next?: Next<Plot>): void {
this.postSingle([plot.id, 'interval'], value.name, next);
}
plotOffset(plot: Plot, value: number, next?: Next<Plot>): void {
this.postSingle([plot.id, 'offset'], value, next);
}
plotDuration(plot: Plot, value: number, next?: Next<Plot>): void {
this.postSingle([plot.id, 'duration'], value, next);
}
plotDashboard(plot: Plot, value: boolean, next?: Next<Plot>): void {
this.postSingle([plot.id, 'dashboard'], value, next);
}
plotPosition(plot: Plot, value: number, next?: Next<Plot>): void {
this.postSingle([plot.id, 'position'], value, next);
}
axisAddGraph(axis: Axis, next: Next<Plot>): void {
this.getSingle(['Axis', axis.id, 'addGraph'], next);
}
axisDelete(axis: Axis, next?: Next<Plot>): void {
this.getSingle(['Axis', axis.id, 'delete'], next);
}
axisVisible(axis: Axis, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'visible'], value, next);
}
axisName(axis: Axis, value: string, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'name'], value, next);
}
axisUnit(axis: Axis, value: string, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'unit'], value, next);
}
axisRight(axis: Axis, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'right'], value, next);
}
axisMin(axis: Axis, value: number | null, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'min'], value, next);
}
axisMinHard(axis: Axis, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'minHard'], value, next);
}
axisMax(axis: Axis, value: number | null, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'max'], value, next);
}
axisMaxHard(axis: Axis, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Axis', axis.id, 'maxHard'], value, next);
}
graphDelete(graph: Graph, next?: Next<Plot>): void {
this.getSingle(['Graph', graph.id, 'delete'], next);
}
graphVisible(graph: Graph, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'visible'], value, next);
}
graphName(graph: Graph, value: string, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'name'], value, next);
}
graphSeries(graph: Graph, value: number, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'series'], value, next);
}
graphColor(graph: Graph, value: string, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'color'], value, next);
}
graphFactor(graph: Graph, value: number, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'factor'], value, next);
}
graphGroup(graph: Graph, value: Group, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'group'], value, next);
}
graphStack(graph: Graph, value: string, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'stack'], value, next);
}
graphMin(graph: Graph, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'min'], value, next);
}
graphMax(graph: Graph, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'max'], value, next);
}
graphAvg(graph: Graph, value: boolean, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'avg'], value, next);
}
graphAxis(graph: Graph, value: number, next?: Next<Plot>): void {
this.postSingle(['Graph', graph.id, 'axis'], value, next);
}
}

View File

@ -1,202 +0,0 @@
import {AfterViewInit, Component, ElementRef, OnDestroy, ViewChild} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, ChartType, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
import {SeriesService} from "../series/series.service";
import 'chartjs-adapter-date-fns';
import {Series} from '../series/Series';
import {Interval} from '../series/Interval';
import {MinMaxAvg, PointMapper, toAvg, toBool, toDelta, toMax, toMin} from '../series/MinMaxAvg';
import {SeriesType} from '../series/SeriesType';
Chart.register(
CategoryScale,
LinearScale,
BarController,
BarElement,
LineController,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
TimeScale,
Filler,
);
const ENERGY_PRODUCE = 1;
const POWER_PRODUCE = 1 + 1;
const ENERGY_DELIVER = 5;
const POWER_DELIVER = 5 + 1;
const ENERGY_PURCHASE = 3;
const POWER_PURCHASE = 3 + 1;
const GARDEN_TEMPERATURE = 8;
const BEDROOM_TEMPERATURE = 12;
const BUFFER_TEMPERATURE = 24;
const CIRCUIT_SUPPLY_TEMPERATURE = 20;
const HEATER = 28;
@Component({
selector: 'app-plot',
imports: [],
templateUrl: './plot.html',
styleUrl: './plot.less'
})
export class Plot implements AfterViewInit, OnDestroy {
@ViewChild('chartCanvas')
canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('container')
chartContainer!: ElementRef<HTMLDivElement>;
private chart!: Chart;
constructor(
readonly seriesService: SeriesService,
) {
//
}
private interval = Interval.FIVE;
private FACTOR = 0.5;
private duration = (this.interval === Interval.FIVE ? 24 * 60 / 5 : this.interval === Interval.HOUR ? 7 * 24 : this.interval === Interval.DAY ? 31 : this.interval === Interval.WEEK ? 52 : 99) * this.FACTOR;
ngAfterViewInit() {
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: true,
labels: {
usePointStyle: true,
},
},
tooltip: {
position: 'nearest',
mode: 'x',
usePointStyle: true,
},
},
scales: {
x: {
type: 'time',
},
}
}
});
this.scaleRight = false;
this.fetch(HEATER, "line", '#ff7373', 1, undefined, true, MinMaxAvg.AVG);
this.fetch(ENERGY_PRODUCE, "bar", '#53d32b', 1, 'a', false, MinMaxAvg.AVG);
this.fetch(ENERGY_PURCHASE, "bar", '#ff8000', 1, 'a', false, MinMaxAvg.AVG);
this.fetch(ENERGY_DELIVER, "bar", '#ff5bfc', -1, 'a', false, MinMaxAvg.AVG);
// this.fetch(POWER_PRODUCE, "bar", '#53d32b', 1, undefined, false);
// this.fetch(POWER_PURCHASE, "bar", '#ff8000', 1, undefined, false);
// this.fetch(POWER_DELIVER, "bar", '#ff5bfc', -1, undefined, false);
this.fetch(GARDEN_TEMPERATURE, "line", '#00aa00', 1, undefined, false, MinMaxAvg.AVG);
this.fetch(BEDROOM_TEMPERATURE, "line", '#0000FF', 1, undefined, false, MinMaxAvg.AVG);
this.fetch(BUFFER_TEMPERATURE, "line", '#AA00AA', 1, undefined, false, MinMaxAvg.AVG);
this.fetch(CIRCUIT_SUPPLY_TEMPERATURE, "line", '#FF0000', 1, undefined, false, MinMaxAvg.AVG);
// this.fetch(POWER_PRODUCE, "line", '#ffc400', 1, undefined, false, MinMaxAvg.MAX);
}
scaleRight: boolean = false;
private fetch(id: number, type: ChartType, color: string, factor: number, stack: string | undefined, fill: any, minMaxAvg: MinMaxAvg) {
this.chart.data.datasets.push({
data: [],
type: type,
fill: fill,
stack: stack,
pointRadius: 4,
borderWidth: 1,
barThickness: 'flex',
borderColor: color,
pointBackgroundColor: color,
backgroundColor: color + "33",
});
const dataset: ChartDataset<any, any> = this.chart.data.datasets[this.chart.data.datasets.length - 1];
this.seriesService.getById(id, series => {
if (this.chart.options.scales) {
if (!this.chart.options.scales["y-" + series.unit]) {
this.chart.options.scales["y-" + series.unit] = {
display: series.unit !== "",
min: series.unit !== "" ? undefined : 0,
max: series.unit !== "" ? undefined : 4,
beginAtZero: true,
position: this.scaleRight ? 'right' : 'left',
title: {
display: true,
text: series.unit,
},
}
this.scaleRight = !this.scaleRight;
}
}
dataset.label = series.name;
dataset.yAxisID = "y-" + series.unit;
dataset.pointStyle = type === 'bar' || series.type === SeriesType.BOOL ? 'rect' : 'crossRot';
dataset.spanGaps = series.type === SeriesType.BOOL ? Infinity : this.interval.spanGaps;
if (series.type === SeriesType.BOOL) {
dataset.pointRadius = 0;
}
this.points(series, this.interval, 0, this.duration, minMaxAvg, factor, dataset);
});
}
ngOnDestroy(): void {
this.chart.destroy();
}
private points(
series: Series,
interval: Interval,
offset: number,
duration: number,
type: MinMaxAvg,
factor: number,
dataset: ChartDataset<any, any>[][number],
) {
let mapper: PointMapper;
switch (series.type) {
case SeriesType.BOOL:
mapper = toBool;
break;
case SeriesType.DELTA:
mapper = toDelta;
break;
case SeriesType.VARYING:
mapper = type === MinMaxAvg.MIN ? toMin : type === MinMaxAvg.MAX ? toMax : toAvg;
break;
}
this.seriesService.points(
series,
interval,
offset,
duration,
points => {
dataset.data = mapper(points, factor);
this.chart.update();
},
);
}
}

View File

@ -2,35 +2,40 @@ import {validateString} from "../COMMON";
export class Interval {
private static values: Interval[] = [];
protected static _values: Interval[] = [];
static readonly FIVE = new Interval("FIVE", 5 * 60 * 1000);
static readonly FIVE = new Interval("FIVE", "5 Minuten", 5 * 60 * 1000);
static readonly HOUR = new Interval("HOUR", 60 * 60 * 1000);
static readonly HOUR = new Interval("HOUR", "Stunden", 60 * 60 * 1000);
static readonly DAY = new Interval("DAY", 24 * 60 * 60 * 1000);
static readonly DAY = new Interval("DAY", "Tage", 24 * 60 * 60 * 1000);
static readonly WEEK = new Interval("WEEK", 7 * 24 * 60 * 60 * 1000);
static readonly WEEK = new Interval("WEEK", "Wochen", 7 * 24 * 60 * 60 * 1000);
static readonly MONTH = new Interval("MONTH", 31 * 24 * 60 * 60 * 1000);
static readonly MONTH = new Interval("MONTH", "Monate", 31 * 24 * 60 * 60 * 1000);
static readonly YEAR = new Interval("YEAR", 366 * 24 * 60 * 60 * 1000);
static readonly YEAR = new Interval("YEAR", "Jahre", 366 * 24 * 60 * 60 * 1000);
private constructor(
readonly name: string,
readonly display: string,
readonly spanGaps: number,
) {
Interval.values.push(this);
Interval._values.push(this);
}
static fromJson(json: any): Interval {
const name = validateString(json)
const interval = Interval.values.filter(i => i.name === name)[0];
const interval = Interval._values.filter(i => i.name === name)[0];
if (!interval) {
throw new Error(`Not an Interval: ${JSON.stringify(json)}`);
}
return interval;
}
static get values(): Interval[] {
return Interval._values;
}
}

View File

@ -1,13 +1,18 @@
import {validateNumber, validateString} from "../COMMON";
import {mapNotNull, validateBoolean, validateNumber, validateString} from "../COMMON";
import {SeriesType} from './SeriesType';
import {formatNumber} from '@angular/common';
export class Series {
constructor(
readonly id: number,
readonly version: number,
readonly deleted: boolean,
readonly name: string,
readonly unit: string,
readonly type: SeriesType,
readonly decimals: number,
readonly value: number | null,
) {
//
}
@ -15,10 +20,30 @@ export class Series {
static fromJson(json: any): Series {
return new Series(
validateNumber(json.id),
validateNumber(json.version),
validateBoolean(json.deleted),
validateString(json.name),
validateString(json.unit),
validateString(json.type) as SeriesType,
validateNumber(json.decimals),
mapNotNull(json.value, validateNumber),
);
}
get digitString(): string {
return `0.${this.decimals}-${this.decimals}`;
}
get valueString(): string {
const result = (this.value === null ? "-" : this.type === SeriesType.BOOL ? this.value > 0 ? "EIN" : "AUS" : formatNumber(this.value, "de-DE", this.digitString)) + "";
if (this.unit) {
return result + " " + this.unit;
}
return result;
}
static compareName(a: Series, b: Series) {
return a.name.localeCompare(b.name);
}
}

View File

@ -0,0 +1,40 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, validateBoolean, validateDate} from '../../COMMON';
import {Series} from '../Series';
export class Bool {
constructor(
readonly series: Series,
readonly date: Date,
readonly end: Date,
readonly state: boolean,
readonly terminated: boolean,
) {
//
}
static fromJson(json: any): Bool {
return new Bool(
Series.fromJson(json.series),
validateDate(json.date),
validateDate(json.end),
validateBoolean(json.state),
validateBoolean(json.terminated),
);
}
}
@Injectable({
providedIn: 'root'
})
export class BoolService extends CrudService<Bool> {
constructor(
api: ApiService,
) {
super(api, ["Bool"], Bool.fromJson);
}
}

View File

@ -0,0 +1,38 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, validateDate, validateNumber} from '../../COMMON';
import {Series} from '../Series';
export class Delta {
constructor(
readonly series: Series,
readonly date: Date,
readonly first: number,
readonly last: number,
) {
//
}
static fromJson(json: any): Delta {
return new Delta(
Series.fromJson(json.series),
validateDate(json.date),
validateNumber(json.first),
validateNumber(json.last),
);
}
}
@Injectable({
providedIn: 'root'
})
export class DeltaService extends CrudService<Delta> {
constructor(
api: ApiService,
) {
super(api, ["Delta"], Delta.fromJson);
}
}

View File

@ -0,0 +1,40 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, validateDate, validateNumber} from '../../COMMON';
import {Series} from '../Series';
export class Varying {
constructor(
readonly series: Series,
readonly date: Date,
readonly min: number,
readonly max: number,
readonly avg: number,
) {
//
}
static fromJson(json: any): Varying {
return new Varying(
Series.fromJson(json.series),
validateDate(json.date),
validateNumber(json.min),
validateNumber(json.max),
validateNumber(json.avg),
);
}
}
@Injectable({
providedIn: 'root'
})
export class VaryingService extends CrudService<Varying> {
constructor(
api: ApiService,
) {
super(api, ["Varying"], Varying.fromJson);
}
}

View File

@ -2,3 +2,28 @@ html, body {
height: 100%;
margin: 0;
}
table {
width: 100%;
border-collapse: collapse;
td, th {
padding: 0.25em;
vertical-align: bottom;
}
}
input, select, textarea {
font-family: inherit;
font-size: inherit;
}
input, select {
width: 100%;
margin: 0;
outline: none;
background-color: rgba(255, 255, 255, 0.8);
border: 1px solid black;
padding: 0.125em;
}

View File

@ -1,5 +1,11 @@
package de.ph87.data;
import de.ph87.data.plot.Plot;
import de.ph87.data.plot.PlotRepository;
import de.ph87.data.plot.axis.Axis;
import de.ph87.data.plot.axis.AxisRepository;
import de.ph87.data.plot.axis.graph.Graph;
import de.ph87.data.plot.axis.graph.GraphRepository;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesRepository;
import de.ph87.data.series.SeriesType;
@ -26,9 +32,27 @@ public class DemoService {
private final TopicRepository topicRepository;
private final PlotRepository plotRepository;
private final AxisRepository axisRepository;
private final GraphRepository graphRepository;
@Transactional
@EventListener(ApplicationReadyEvent.class)
public void init() {
topics();
// plots();
}
private void topics() {
final Series fallbackRelay0 = series("fallback/relay0", "", SeriesType.BOOL, 5);
topic(
"fallback/relay0",
"$.timestamp",
new TopicQuery(fallbackRelay0, "$.state", "$.stateEpoch", "true")
);
final Series infraredHeater = series("infraredHeater/state", "", SeriesType.BOOL, 5);
topic(
"Infrarotheizung",
@ -108,6 +132,69 @@ public class DemoService {
topic("cistern/volume/PatrixJson", "$.date", new TopicQuery(cisternVolume, "$.value"));
}
private void plots() {
plotRepository.deleteAll();
final Plot plot = plotRepository.save(new Plot());
plot.setName("Test");
final Axis energy = axisRepository.save(new Axis(plot));
plot.addAxis(energy);
energy.setRight(true);
energy.setName("Energie");
energy.setUnit("kWh");
final Series electricityEnergyPurchase = seriesRepository.findByName("electricity/energy/purchase").orElseThrow();
final Graph electricityEnergyPurchaseGraph = graphRepository.save(new Graph(energy, electricityEnergyPurchase));
electricityEnergyPurchaseGraph.setName("Bezug");
electricityEnergyPurchaseGraph.setColor("#FF8800");
energy.addGraph(electricityEnergyPurchaseGraph);
final Axis temperature = axisRepository.save(new Axis(plot));
plot.addAxis(temperature);
temperature.setRight(true);
temperature.setName("Temperatur");
temperature.setUnit("°C");
final Series bedroomTemperature = seriesRepository.findByName("bedroom/temperature").orElseThrow();
final Graph bedroomTemperatureGraph = graphRepository.save(new Graph(temperature, bedroomTemperature));
bedroomTemperatureGraph.setName("Schlafzimmer");
bedroomTemperatureGraph.setColor("#0000FF");
bedroomTemperatureGraph.setMin(true);
bedroomTemperatureGraph.setAvg(false);
bedroomTemperatureGraph.setMax(true);
temperature.addGraph(bedroomTemperatureGraph);
final Axis status = axisRepository.save(new Axis(plot));
plot.addAxis(status);
status.setVisible(false);
status.setRight(true);
status.setName("Status");
final Series fallbackRelay0 = seriesRepository.findByName("fallback/relay0").orElseThrow();
final Graph fallbackRelay0Graph = graphRepository.save(new Graph(status, fallbackRelay0));
fallbackRelay0Graph.setName("FallbackRelay0");
fallbackRelay0Graph.setColor("#00FF00");
status.addGraph(fallbackRelay0Graph);
final Series infrared = seriesRepository.findByName("infraredHeater/state").orElseThrow();
final Graph infraredGraph = graphRepository.save(new Graph(status, infrared));
infraredGraph.setName("Infrarotheizung");
infraredGraph.setColor("#FF00FF");
status.addGraph(infraredGraph);
plotRepository.save(plot);
axisRepository.save(energy);
axisRepository.save(temperature);
axisRepository.save(status);
graphRepository.save(electricityEnergyPurchaseGraph);
graphRepository.save(bedroomTemperatureGraph);
graphRepository.save(fallbackRelay0Graph);
graphRepository.save(infraredGraph);
}
@NonNull
private Series series(@NonNull final String name, @NonNull final String unit, @NonNull final SeriesType type, final int expectedEverySeconds) {
return seriesRepository

View File

@ -15,4 +15,12 @@ public class Helpers {
return mapper.apply(t);
}
@NonNull
public static <T> T or(@Nullable final T t, @NonNull final T r) {
if (t == null) {
return r;
}
return t;
}
}

View File

@ -2,10 +2,8 @@ package de.ph87.data.log;
import jakarta.annotation.Nullable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.OrderColumn;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import org.slf4j.Logger;
@ -16,9 +14,6 @@ import java.util.ArrayList;
import java.util.List;
@Getter
@ToString
@MappedSuperclass
@NoArgsConstructor
public abstract class AbstractEntityLog {
@NonNull

View File

@ -0,0 +1,86 @@
package de.ph87.data.plot;
import de.ph87.data.plot.axis.Axis;
import de.ph87.data.series.data.Interval;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Plot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@Setter
@NonNull
@Column(nullable = false)
private String name = "";
@Setter
@NonNull
@Enumerated(EnumType.STRING)
@Column(nullable = false, name = "`interval`")
private Interval interval = Interval.FIVE;
@Setter
@Column(nullable = false, name = "`offset`")
private long offset = 0;
@Setter
@Column(nullable = false)
private long duration = 288;
@Setter
@Column(nullable = false)
private boolean dashboard = false;
@Setter
@Column(nullable = false)
private long position;
@NonNull
@ToString.Exclude
@OneToMany(mappedBy = "plot", orphanRemoval = true, fetch = FetchType.EAGER)
private List<Axis> axes = new ArrayList<>();
public Plot(final long position) {
this.position = position;
}
public Plot(@NonNull final Plot plot, final long position) {
this.name = plot.getName();
this.interval = plot.getInterval();
this.offset = plot.getOffset();
this.duration = plot.getDuration();
this.dashboard = plot.isDashboard();
this.position = position;
}
public void addAxis(@NonNull final Axis axis) {
axes.add(axis);
}
}

View File

@ -0,0 +1,83 @@
package de.ph87.data.plot;
import de.ph87.data.series.data.Interval;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static de.ph87.data.Helpers.or;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Plot")
public class PlotController {
private final PlotRepository plotRepository;
private final PlotService plotService;
@GetMapping("findAll")
public List<PlotDto> findAllDto() {
return plotRepository.findAllDto();
}
@GetMapping("create")
public PlotDto create() {
return plotService.create();
}
@GetMapping("{id}/duplicate")
public PlotDto duplicate(@PathVariable final long id) {
return plotService.duplicate(id);
}
@GetMapping("{id}/delete")
public PlotDto delete(@PathVariable final long id) {
return plotService.delete(id);
}
@GetMapping("{id}/addAxis")
public PlotDto addAxis(@PathVariable final long id) {
return plotService.addAxis(id);
}
@PostMapping("{id}/name")
public PlotDto name(@PathVariable final long id, @RequestBody(required = false) @Nullable final String value) {
return plotService.set(id, plot -> plot.setName(or(value, "")));
}
@PostMapping("{id}/interval")
public PlotDto interval(@PathVariable final long id, @RequestBody(required = false) @Nullable final String value) {
return plotService.set(id, plot -> plot.setInterval(Interval.valueOf(value)));
}
@PostMapping("{id}/offset")
public PlotDto offset(@PathVariable final long id, @RequestBody final long value) {
return plotService.set(id, plot -> plot.setOffset(value));
}
@PostMapping("{id}/duration")
public PlotDto duration(@PathVariable final long id, @RequestBody final long value) {
return plotService.set(id, plot -> plot.setDuration(value));
}
@PostMapping("{id}/dashboard")
public PlotDto dashboard(@PathVariable final long id, @RequestBody final boolean value) {
return plotService.set(id, plot -> plot.setDashboard(value));
}
@PostMapping("{id}/position")
public PlotDto position(@PathVariable final long id, @RequestBody final long value) {
return plotService.setPosition(id, value);
}
}

View File

@ -0,0 +1,50 @@
package de.ph87.data.plot;
import de.ph87.data.plot.axis.AxisDto;
import de.ph87.data.series.data.Interval;
import de.ph87.data.websocket.IWebsocketMessage;
import lombok.Data;
import lombok.NonNull;
import java.util.List;
@Data
public class PlotDto implements IWebsocketMessage {
public final long id;
public final long version;
private final boolean deleted;
@NonNull
public final String name;
@NonNull
public final Interval interval;
public final long offset;
public final long duration;
public final boolean dashboard;
public final long position;
@NonNull
public final List<AxisDto> axes;
public PlotDto(@NonNull final Plot plot, final boolean deleted) {
this.id = plot.getId();
this.version = plot.getVersion();
this.deleted = deleted;
this.name = plot.getName();
this.interval = plot.getInterval();
this.offset = plot.getOffset();
this.duration = plot.getDuration();
this.dashboard = plot.isDashboard();
this.position = plot.getPosition();
this.axes = plot.getAxes().stream().map(AxisDto::new).toList();
}
}

View File

@ -0,0 +1,23 @@
package de.ph87.data.plot;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
import java.util.Optional;
public interface PlotRepository extends ListCrudRepository<Plot, Long> {
@Query("select new de.ph87.data.plot.PlotDto(p, false) from Plot p")
List<PlotDto> findAllDto();
@Query("select max(p.position) from Plot p")
Optional<Integer> findMaxPosition();
List<Plot> findAllByPositionGreaterThanAndPositionLessThanEqual(long first, long last);
List<Plot> findAllByPositionGreaterThanEqualAndPositionLessThan(long first, long last);
List<Plot> findAllByPositionGreaterThan(long position);
}

View File

@ -0,0 +1,126 @@
package de.ph87.data.plot;
import de.ph87.data.plot.axis.Axis;
import de.ph87.data.plot.axis.AxisRepository;
import de.ph87.data.plot.axis.graph.Graph;
import de.ph87.data.plot.axis.graph.GraphRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.function.Consumer;
@Slf4j
@Service
@RequiredArgsConstructor
public class PlotService {
private final PlotRepository plotRepository;
private final AxisRepository axisRepository;
private final ApplicationEventPublisher applicationEventPublisher;
private final GraphRepository graphRepository;
@NonNull
@Transactional
public PlotDto create() {
return publish(plotRepository.save(new Plot(getFreePosition())), false);
}
@NonNull
@Transactional
public PlotDto duplicate(final long id) {
final Plot plotOriginal = getById(id);
final Plot plotCopy = plotRepository.save(new Plot(plotOriginal, getFreePosition()));
plotOriginal.getAxes().stream().map(axisOriginal -> duplicateAxis(plotCopy, axisOriginal)).forEach(plotCopy::addAxis);
return publish(plotCopy, false);
}
@NonNull
private Axis duplicateAxis(@NonNull final Plot plotCopy, @NonNull final Axis axisOriginal) {
final Axis axisCopy = axisRepository.save(new Axis(plotCopy, axisOriginal));
axisOriginal.getGraphs().stream().map(graphOriginal -> duplicateGraph(graphOriginal, axisCopy)).forEach(axisCopy::addGraph);
return axisCopy;
}
@NonNull
private Graph duplicateGraph(@NonNull final Graph graphOriginal, @NonNull final Axis axisCopy) {
return graphRepository.save(new Graph(axisCopy, graphOriginal));
}
private int getFreePosition() {
return plotRepository.findMaxPosition().orElse(-1) + 1;
}
@Transactional
public PlotDto delete(final long id) {
final Plot plot = getById(id);
plotRepository.delete(plot);
plotRepository.findAllByPositionGreaterThan(plot.getPosition()).forEach(this::decrementPosition);
return publish(plot, true);
}
@NonNull
@Transactional
public PlotDto addAxis(final long id) {
return set(id, plot -> plot.addAxis(axisRepository.save(new Axis(plot))));
}
@NonNull
@Transactional
public PlotDto set(final long id, @NonNull final Consumer<Plot> modifier) {
final Plot plot = getById(id);
modifier.accept(plot);
return publish(plot, false);
}
@NonNull
public PlotDto publish(@NonNull final Plot plot, final boolean deleted) {
if (deleted) {
log.info("Deleted: plot={}", plot);
} else {
log.info("Updated: plot={}", plot);
}
final PlotDto dto = new PlotDto(plot, deleted);
applicationEventPublisher.publishEvent(dto);
return dto;
}
@NonNull
private Plot getById(final long id) {
return plotRepository.findById(id).orElseThrow();
}
@NonNull
@Transactional
public PlotDto setPosition(final long id, long newPosition) {
final long newPositionBound = Math.max(0, Math.min(newPosition, plotRepository.count() - 1));
return set(id, plot -> {
if (newPositionBound > plot.getPosition()) {
plotRepository.findAllByPositionGreaterThanAndPositionLessThanEqual(plot.getPosition(), newPositionBound).forEach(this::decrementPosition);
} else if (newPositionBound < plot.getPosition()) {
plotRepository.findAllByPositionGreaterThanEqualAndPositionLessThan(newPositionBound, plot.getPosition()).forEach(this::incrementPosition);
}
plot.setPosition(newPositionBound);
});
}
private void incrementPosition(@NonNull final Plot plot) {
setPosition(plot, +1);
}
private void decrementPosition(@NonNull final Plot plot) {
setPosition(plot, -1);
}
private void setPosition(@NonNull final Plot plot, final int delta) {
plot.setPosition(plot.getPosition() + delta);
publish(plot, false);
}
}

View File

@ -0,0 +1,104 @@
package de.ph87.data.plot.axis;
import de.ph87.data.plot.Plot;
import de.ph87.data.plot.axis.graph.Graph;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Axis {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@Setter
@NonNull
@ToString.Exclude
@ManyToOne(optional = false)
private Plot plot;
@Setter
@NonNull
@Column(nullable = false)
private String name = "";
@Setter
@NonNull
@Column(nullable = false)
private String unit = "";
@Setter
@Column(nullable = false)
private boolean visible = true;
@Setter
@Column(nullable = false, name = "`right`")
private boolean right = false;
@Setter
@Column
@Nullable
private Double min = null;
@Setter
@Column(nullable = false)
private boolean minHard = false;
@Setter
@Column
@Nullable
private Double max = null;
@Setter
@Column(nullable = false)
private boolean maxHard = false;
@NonNull
@ToString.Exclude
@OneToMany(mappedBy = "axis", orphanRemoval = true, fetch = FetchType.EAGER)
private List<Graph> graphs = new ArrayList<>();
public Axis(@NonNull final Plot plot) {
this.plot = plot;
}
public Axis(@NonNull final Plot plot, @NonNull final Axis axis) {
this.plot = plot;
this.name = axis.name;
this.unit = axis.unit;
this.visible = axis.visible;
this.right = axis.right;
this.min = axis.min;
this.max = axis.max;
this.maxHard = axis.maxHard;
this.minHard = axis.minHard;
}
public void addGraph(@NonNull final Graph graph) {
graphs.add(graph);
}
}

View File

@ -0,0 +1,87 @@
package de.ph87.data.plot.axis;
import de.ph87.data.plot.PlotDto;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static de.ph87.data.Helpers.or;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Plot/Axis")
public class AxisController {
private final AxisService axisService;
@NonNull
@GetMapping("{id}/delete")
public PlotDto delete(@PathVariable final long id) {
return axisService.delete(id);
}
@GetMapping("{id}/addGraph")
public PlotDto addGraph(@PathVariable final long id) {
return axisService.addGraph(id);
}
@PostMapping("{id}/visible")
public PlotDto visible(@PathVariable long id, @RequestBody final boolean value) {
return axisService.set(id, axis -> axis.setVisible(value));
}
@PostMapping("{id}/name")
public PlotDto name(@PathVariable long id, @RequestBody(required = false) @Nullable final String value) {
return axisService.set(id, axis -> axis.setName(or(value, "")));
}
@PostMapping("{id}/unit")
public PlotDto unit(@PathVariable long id, @RequestBody(required = false) @Nullable final String value) {
return axisService.set(id, axis -> axis.setUnit(or(value, "")));
}
@PostMapping("{id}/right")
public PlotDto right(@PathVariable long id, @RequestBody final boolean value) {
return axisService.set(id, axis -> axis.setRight(value));
}
@PostMapping("{id}/min")
public PlotDto min(@PathVariable long id, @RequestBody(required = false) @Nullable final Double value) {
return axisService.set(id, axis -> axis.setMin(value));
}
@PostMapping("{id}/minHard")
public PlotDto minHard(@PathVariable long id, @RequestBody final boolean value) {
return axisService.set(id, axis -> axis.setMinHard(value));
}
@PostMapping("{id}/max")
public PlotDto max(@PathVariable long id, @RequestBody(required = false) @Nullable final Double value) {
return axisService.set(id, axis -> axis.setMax(value));
}
@PostMapping("{id}/maxHard")
public PlotDto maxHard(@PathVariable long id, @RequestBody final boolean value) {
return axisService.set(id, axis -> axis.setMaxHard(value));
}
@PostMapping("{id}/position")
public PlotDto position(@PathVariable final long id, @RequestBody final int value) {
return axisService.set(id, axis -> {
final List<Axis> list = axis.getPlot().getAxes();
list.remove(axis);
list.add(Math.max(0, Math.min(value, list.size())), axis);
});
}
}

View File

@ -0,0 +1,54 @@
package de.ph87.data.plot.axis;
import de.ph87.data.plot.axis.graph.GraphDto;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import java.util.List;
@Data
public class AxisDto {
public final long id;
public final long version;
@NonNull
public final String name;
@NonNull
public final String unit;
public final boolean visible;
public final boolean right;
@Nullable
public final Double min;
public final boolean minHard;
@Nullable
public final Double max;
public final boolean maxHard;
@NonNull
public final List<GraphDto> graphs;
public AxisDto(@NonNull final Axis axis) {
this.id = axis.getId();
this.version = axis.getVersion();
this.name = axis.getName();
this.unit = axis.getUnit();
this.visible = axis.isVisible();
this.right = axis.isRight();
this.min = axis.getMin();
this.minHard = axis.isMinHard();
this.max = axis.getMax();
this.maxHard = axis.isMaxHard();
this.graphs = axis.getGraphs().stream().map(GraphDto::new).toList();
}
}

View File

@ -0,0 +1,7 @@
package de.ph87.data.plot.axis;
import org.springframework.data.repository.ListCrudRepository;
public interface AxisRepository extends ListCrudRepository<Axis, Long> {
}

View File

@ -0,0 +1,60 @@
package de.ph87.data.plot.axis;
import de.ph87.data.plot.PlotDto;
import de.ph87.data.plot.PlotService;
import de.ph87.data.plot.axis.graph.Graph;
import de.ph87.data.plot.axis.graph.GraphRepository;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.function.Consumer;
@Slf4j
@Service
@RequiredArgsConstructor
public class AxisService {
private final AxisRepository axisRepository;
private final GraphRepository graphRepository;
private final SeriesRepository seriesRepository;
private final PlotService plotService;
@NonNull
@Transactional
public PlotDto delete(final long id) {
final Axis axis = getById(id);
axisRepository.delete(axis);
axis.getPlot().getAxes().remove(axis);
log.info("Deleted: axis={}", axis);
return plotService.publish(axis.getPlot(), false);
}
@NonNull
@Transactional
public PlotDto addGraph(final long axisId) {
final Series series = seriesRepository.findFirstByOrderByNameAsc().orElseThrow();
return set(axisId, axis -> axis.addGraph(graphRepository.save(new Graph(axis, series))));
}
@NonNull
@Transactional
public PlotDto set(final long id, @NonNull final Consumer<Axis> modifier) {
final Axis axis = getById(id);
modifier.accept(axis);
return plotService.publish(axis.getPlot(), false);
}
@NonNull
private Axis getById(final long id) {
return axisRepository.findById(id).orElseThrow();
}
}

View File

@ -0,0 +1,104 @@
package de.ph87.data.plot.axis.graph;
import de.ph87.data.plot.axis.Axis;
import de.ph87.data.series.Series;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Graph {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@Setter
@NonNull
@ToString.Exclude
@ManyToOne(optional = false)
private Axis axis;
@Setter
@NonNull
@ManyToOne(optional = false)
private Series series;
@Setter
@NonNull
@Column(nullable = false)
private String name = "";
@Setter
@Column(nullable = false)
private boolean visible = true;
@Setter
@NonNull
@Column(nullable = false)
private String color = "#FF0000";
@Setter
@Column(nullable = false)
private double factor = 1;
@Setter
@NonNull
@Enumerated(EnumType.STRING)
@Column(nullable = false, name = "`group`")
private Group group = Group.NONE;
@Setter
@NonNull
@Column(nullable = false)
private String stack = "";
@Setter
@Column(nullable = false)
private boolean min = false;
@Setter
@Column(nullable = false)
private boolean max = false;
@Setter
@Column(nullable = false)
private boolean avg = true;
public Graph(@NonNull final Axis axis, @NonNull final Series series) {
this.axis = axis;
this.series = series;
}
public Graph(@NonNull final Axis axis, @NonNull final Graph graph) {
this.axis = axis;
this.series = graph.series;
this.name = graph.name;
this.visible = graph.visible;
this.color = graph.color;
this.factor = graph.factor;
this.group = graph.group;
this.stack = graph.stack;
this.min = graph.min;
this.max = graph.max;
this.avg = graph.avg;
}
}

View File

@ -0,0 +1,103 @@
package de.ph87.data.plot.axis.graph;
import de.ph87.data.plot.PlotDto;
import de.ph87.data.plot.axis.AxisRepository;
import de.ph87.data.series.SeriesRepository;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static de.ph87.data.Helpers.or;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Plot/Graph")
public class GraphController {
private final GraphService graphService;
private final SeriesRepository seriesRepository;
private final AxisRepository axisRepository;
@NonNull
@GetMapping("{id}/delete")
public PlotDto delete(@PathVariable final long id) {
return graphService.delete(id);
}
@PostMapping("{id}/visible")
public PlotDto visible(@PathVariable final long id, @RequestBody final boolean value) {
return graphService.set(id, graph -> graph.setVisible(value));
}
@PostMapping("{id}/name")
public PlotDto name(@PathVariable final long id, @RequestBody(required = false) @Nullable final String value) {
return graphService.set(id, graph -> graph.setName(or(value, "")));
}
@PostMapping("{id}/series")
public PlotDto series(@PathVariable final long id, @RequestBody final long value) {
return graphService.set(id, graph -> graph.setSeries(seriesRepository.findById(value).orElseThrow()));
}
@PostMapping("{id}/color")
public PlotDto color(@PathVariable final long id, @RequestBody @NonNull final String value) {
return graphService.set(id, graph -> graph.setColor(value));
}
@PostMapping("{id}/factor")
public PlotDto factor(@PathVariable final long id, @RequestBody final double value) {
return graphService.set(id, graph -> graph.setFactor(value));
}
@PostMapping("{id}/group")
public PlotDto group(@PathVariable final long id, @RequestBody @NonNull final String value) {
return graphService.set(id, graph -> graph.setGroup(Group.valueOf(value)));
}
@PostMapping("{id}/stack")
public PlotDto stack(@PathVariable final long id, @RequestBody(required = false) @Nullable final String value) {
return graphService.set(id, graph -> graph.setStack(or(value, "")));
}
@PostMapping("{id}/min")
public PlotDto min(@PathVariable final long id, @RequestBody final boolean value) {
return graphService.set(id, graph -> graph.setMin(value));
}
@PostMapping("{id}/max")
public PlotDto max(@PathVariable final long id, @RequestBody final boolean value) {
return graphService.set(id, graph -> graph.setMax(value));
}
@PostMapping("{id}/avg")
public PlotDto avg(@PathVariable final long id, @RequestBody final boolean value) {
return graphService.set(id, graph -> graph.setAvg(value));
}
@PostMapping("{id}/axis")
public PlotDto axis(@PathVariable final long id, @RequestBody final long value) {
return graphService.set(id, graph -> graph.setAxis(axisRepository.findById(value).orElseThrow()));
}
@PostMapping("{id}/position")
public PlotDto position(@PathVariable final long id, @RequestBody final int value) {
return graphService.set(id, graph -> {
final List<Graph> list = graph.getAxis().getGraphs();
list.remove(graph);
list.add(Math.max(0, Math.min(value, list.size())), graph);
});
}
}

View File

@ -0,0 +1,54 @@
package de.ph87.data.plot.axis.graph;
import de.ph87.data.series.SeriesDto;
import lombok.Data;
import lombok.NonNull;
@Data
public class GraphDto {
public final long id;
public final long version;
@NonNull
public final SeriesDto series;
@NonNull
public final String name;
public final boolean visible;
@NonNull
public final String color;
public final double factor;
@NonNull
public final Group group;
@NonNull
public final String stack;
public final boolean min;
public final boolean max;
public final boolean avg;
public GraphDto(@NonNull final Graph graph) {
this.id = graph.getId();
this.version = graph.getVersion();
this.series = new SeriesDto(graph.getSeries(), false);
this.name = graph.getName();
this.visible = graph.isVisible();
this.color = graph.getColor();
this.factor = graph.getFactor();
this.group = graph.getGroup();
this.stack = graph.getStack();
this.min = graph.isMin();
this.max = graph.isMax();
this.avg = graph.isAvg();
}
}

View File

@ -0,0 +1,7 @@
package de.ph87.data.plot.axis.graph;
import org.springframework.data.repository.ListCrudRepository;
public interface GraphRepository extends ListCrudRepository<Graph, Long> {
}

View File

@ -0,0 +1,45 @@
package de.ph87.data.plot.axis.graph;
import de.ph87.data.plot.PlotDto;
import de.ph87.data.plot.PlotService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.function.Consumer;
@Slf4j
@Service
@RequiredArgsConstructor
public class GraphService {
private final GraphRepository graphRepository;
private final PlotService plotService;
@NonNull
@Transactional
public PlotDto delete(final long id) {
final Graph graph = getById(id);
graphRepository.delete(graph);
graph.getAxis().getGraphs().remove(graph);
log.info("Deleted: graph={}", graph);
return plotService.publish(graph.getAxis().getPlot(), false);
}
@NonNull
@Transactional
public PlotDto set(final long id, @NonNull final Consumer<Graph> modifier) {
final Graph graph = getById(id);
modifier.accept(graph);
return plotService.publish(graph.getAxis().getPlot(), false);
}
@NonNull
private Graph getById(final long id) {
return graphRepository.findById(id).orElseThrow();
}
}

View File

@ -0,0 +1,13 @@
package de.ph87.data.plot.axis.graph;
public enum Group {
NONE,
FIVE_OF_DAY,
HOUR_OF_DAY,
HOUR_OF_WEEK,
HOUR_OF_MONTH,
DAY_OF_WEEK,
DAY_OF_MONTH,
DAY_OF_YEAR,
WEEK_OF_YEAR,
}

View File

@ -20,7 +20,7 @@ public class SeriesController {
private final SeriesRepository seriesRepository;
private final SeriesService seriesService;
private final SeriesService SeriesService;
@GetMapping("findAll")
public List<SeriesDto> findAll() {
@ -34,7 +34,7 @@ public class SeriesController {
@PostMapping("points")
public List<? extends SeriesPoint> points(@NonNull @RequestBody final SeriesPointsRequest request) {
return seriesService.points(request);
return SeriesService.points(request);
}
}

View File

@ -12,6 +12,10 @@ public class SeriesDto implements IWebsocketMessage {
public final long id;
public final long version;
public final boolean deleted;
public final String name;
@NonNull
@ -33,8 +37,10 @@ public class SeriesDto implements IWebsocketMessage {
@NonNull
public final SeriesType type;
public SeriesDto(@NonNull final Series series) {
public SeriesDto(@NonNull final Series series, final boolean deleted) {
this.id = series.getId();
this.version = series.getVersion();
this.deleted = deleted;
this.name = series.getName();
this.unit = series.getUnit();
this.decimals = series.getDecimals();

View File

@ -13,11 +13,13 @@ public interface SeriesRepository extends ListCrudRepository<Series, Long> {
Optional<Series> findByName(@NonNull String seriesName);
@NonNull
@Query("select new de.ph87.data.series.SeriesDto(s) from Series s where s.id = :id")
@Query("select new de.ph87.data.series.SeriesDto(s, false) from Series s where s.id = :id")
SeriesDto getDtoById(long id);
@NonNull
@Query("select new de.ph87.data.series.SeriesDto(t) from Series t")
@Query("select new de.ph87.data.series.SeriesDto(t, false) from Series t")
List<SeriesDto> findAllDto();
Optional<Series> findFirstByOrderByNameAsc();
}

View File

@ -24,11 +24,17 @@ public class BoolDto implements IWebsocketMessage {
public final boolean terminated;
public BoolDto(@NonNull final Bool bool) {
this.series = new SeriesDto(bool.getId().getSeries());
this.series = new SeriesDto(bool.getId().getSeries(), false);
this.date = bool.getId().getDate();
this.end = bool.getEnd();
this.state = bool.isState();
this.terminated = bool.isTerminated();
}
@NonNull
@Override
public String getWebsocketTopic() {
return "Bool/%d".formatted(series.id);
}
}

View File

@ -1,35 +1,52 @@
package de.ph87.data.series.data.delta;
import de.ph87.data.series.data.DataId;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.data.Interval;
import de.ph87.data.websocket.IWebsocketMessage;
import lombok.Data;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Data
public abstract class DeltaDto implements IWebsocketMessage {
@NonNull
public final DataId id;
private SeriesDto series;
@NonNull
private ZonedDateTime date;
public final double first;
@NonNull
public final double last;
protected DeltaDto(@NonNull final Delta delta) {
this.id = delta.getId();
@NonNull
public final Interval interval;
protected DeltaDto(@NonNull final Delta delta, @NonNull final Interval interval) {
this.series = new SeriesDto(delta.getId().getSeries(), false);
this.date = delta.getId().getDate();
this.first = delta.getFirst();
this.last = delta.getLast();
this.interval = interval;
}
@NonNull
@Override
public String getWebsocketTopic() {
return "Delta/%d/%s".formatted(series.id, interval);
}
@Getter
@ToString
public static class Five extends DeltaDto {
public Five(@NonNull final Delta.Five delta) {
super(delta);
public Five(@NonNull final Delta.Five delta, @NonNull final Interval interval) {
super(delta, interval);
}
}
@ -38,8 +55,8 @@ public abstract class DeltaDto implements IWebsocketMessage {
@ToString
public static class Hour extends DeltaDto {
public Hour(@NonNull final Delta.Hour delta) {
super(delta);
public Hour(@NonNull final Delta.Hour delta, @NonNull final Interval interval) {
super(delta, interval);
}
}
@ -48,8 +65,8 @@ public abstract class DeltaDto implements IWebsocketMessage {
@ToString
public static class Day extends DeltaDto {
public Day(@NonNull final Delta.Day delta) {
super(delta);
public Day(@NonNull final Delta.Day delta, @NonNull final Interval interval) {
super(delta, interval);
}
}
@ -58,8 +75,8 @@ public abstract class DeltaDto implements IWebsocketMessage {
@ToString
public static class Week extends DeltaDto {
public Week(@NonNull final Delta.Week delta) {
super(delta);
public Week(@NonNull final Delta.Week delta, @NonNull final Interval interval) {
super(delta, interval);
}
}
@ -68,8 +85,8 @@ public abstract class DeltaDto implements IWebsocketMessage {
@ToString
public static class Month extends DeltaDto {
public Month(@NonNull final Delta.Month delta) {
super(delta);
public Month(@NonNull final Delta.Month delta, @NonNull final Interval interval) {
super(delta, interval);
}
}
@ -78,8 +95,8 @@ public abstract class DeltaDto implements IWebsocketMessage {
@ToString
public static class Year extends DeltaDto {
public Year(@NonNull final Delta.Year delta) {
super(delta);
public Year(@NonNull final Delta.Year delta, @NonNull final Interval interval) {
super(delta, interval);
}
}

View File

@ -14,7 +14,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
@Slf4j
@Service
@ -45,11 +44,11 @@ public class DeltaService {
write(series, year, Interval.YEAR, date, value, Delta.Year::new, DeltaDto.Year::new);
}
private <DELTA extends Delta, DTO extends DeltaDto> void write(@NonNull final Series series, @NonNull final DeltaRepo<DELTA> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, DELTA> create, @NonNull final Function<DELTA, DTO> toDto) {
private <DELTA extends Delta, DTO extends DeltaDto> void write(@NonNull final Series series, @NonNull final DeltaRepo<DELTA> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, DELTA> create, @NonNull final BiFunction<DELTA, Interval, DTO> toDto) {
final DataId id = new DataId(series, date, interval);
final DELTA delta = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value)));
log.debug("Delta written: {}", delta);
applicationEventPublisher.publishEvent(toDto.apply(delta));
applicationEventPublisher.publishEvent(toDto.apply(delta, interval));
}
@NonNull

View File

@ -1,6 +1,7 @@
package de.ph87.data.series.data.varying;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.data.Interval;
import de.ph87.data.websocket.IWebsocketMessage;
import lombok.Data;
import lombok.Getter;
@ -13,7 +14,7 @@ import java.time.ZonedDateTime;
public abstract class VaryingDto implements IWebsocketMessage {
@NonNull
public final SeriesDto seriesDto;
public final SeriesDto series;
@NonNull
public final ZonedDateTime date;
@ -26,21 +27,31 @@ public abstract class VaryingDto implements IWebsocketMessage {
public final int count;
protected VaryingDto(@NonNull final Varying varying) {
this.seriesDto = new SeriesDto(varying.getId().getSeries());
@NonNull
public final Interval interval;
protected VaryingDto(@NonNull final Varying varying, @NonNull final Interval interval) {
this.series = new SeriesDto(varying.getId().getSeries(), false);
this.date = varying.getId().getDate();
this.min = varying.getMin();
this.max = varying.getMax();
this.avg = varying.getAvg();
this.count = varying.getCount();
this.interval = interval;
}
@NonNull
@Override
public String getWebsocketTopic() {
return "Varying/%d/%s".formatted(series.id, interval);
}
@Getter
@ToString
public static class Five extends VaryingDto {
public Five(@NonNull final Varying.Five varying) {
super(varying);
public Five(@NonNull final Varying.Five varying, @NonNull final Interval interval) {
super(varying, interval);
}
}
@ -49,8 +60,8 @@ public abstract class VaryingDto implements IWebsocketMessage {
@ToString
public static class Hour extends VaryingDto {
public Hour(@NonNull final Varying.Hour varying) {
super(varying);
public Hour(@NonNull final Varying.Hour varying, @NonNull final Interval interval) {
super(varying, interval);
}
}
@ -59,8 +70,8 @@ public abstract class VaryingDto implements IWebsocketMessage {
@ToString
public static class Day extends VaryingDto {
public Day(@NonNull final Varying.Day varying) {
super(varying);
public Day(@NonNull final Varying.Day varying, @NonNull final Interval interval) {
super(varying, interval);
}
}
@ -69,8 +80,8 @@ public abstract class VaryingDto implements IWebsocketMessage {
@ToString
public static class Week extends VaryingDto {
public Week(@NonNull final Varying.Week varying) {
super(varying);
public Week(@NonNull final Varying.Week varying, @NonNull final Interval interval) {
super(varying, interval);
}
}
@ -79,8 +90,8 @@ public abstract class VaryingDto implements IWebsocketMessage {
@ToString
public static class Month extends VaryingDto {
public Month(@NonNull final Varying.Month varying) {
super(varying);
public Month(@NonNull final Varying.Month varying, @NonNull final Interval interval) {
super(varying, interval);
}
}
@ -89,8 +100,8 @@ public abstract class VaryingDto implements IWebsocketMessage {
@ToString
public static class Year extends VaryingDto {
public Year(@NonNull final Varying.Year varying) {
super(varying);
public Year(@NonNull final Varying.Year varying, @NonNull final Interval interval) {
super(varying, interval);
}
}

View File

@ -14,7 +14,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
@Slf4j
@Service
@ -45,11 +44,11 @@ public class VaryingService {
write(series, year, Interval.YEAR, date, value, Varying.Year::new, VaryingDto.Year::new);
}
private <VARYING extends Varying, DTO extends VaryingDto> void write(@NonNull final Series series, @NonNull final VaryingRepo<VARYING> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, VARYING> create, @NonNull final Function<VARYING, DTO> toDto) {
private <VARYING extends Varying, DTO extends VaryingDto> void write(@NonNull final Series series, @NonNull final VaryingRepo<VARYING> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, VARYING> create, @NonNull final BiFunction<VARYING, Interval, DTO> toDto) {
final DataId id = new DataId(series, date, interval);
final VARYING varying = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value)));
log.debug("Varying written: {}", varying);
applicationEventPublisher.publishEvent(toDto.apply(varying));
applicationEventPublisher.publishEvent(toDto.apply(varying, interval));
}
@NonNull

View File

@ -102,7 +102,7 @@ public class TopicReceiver {
v -> {
final double value = query.getFunction().apply(v) * query.getFactor();
series.update(date, value);
applicationEventPublisher.publishEvent(new SeriesDto(series));
applicationEventPublisher.publishEvent(new SeriesDto(series, false));
switch (series.getType()) {
case BOOL -> {

View File

@ -30,7 +30,7 @@ public class TopicQueryDto {
public final double factor;
public TopicQueryDto(@NonNull final TopicQuery topicQuery) {
this.series = map(topicQuery.getSeries(), SeriesDto::new);
this.series = map(topicQuery.getSeries(), series -> new SeriesDto(series, false));
this.valueQuery = topicQuery.getValueQuery();
this.beginQuery = topicQuery.getBeginQuery();
this.terminatedQuery = topicQuery.getTerminatedQuery();