diff --git a/src/main/angular/package-lock.json b/src/main/angular/package-lock.json index a63330e..3d964f8 100644 --- a/src/main/angular/package-lock.json +++ b/src/main/angular/package-lock.json @@ -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", diff --git a/src/main/angular/package.json b/src/main/angular/package.json index faf8128..697b121 100644 --- a/src/main/angular/package.json +++ b/src/main/angular/package.json @@ -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", diff --git a/src/main/angular/src/app/COMMON.ts b/src/main/angular/src/app/COMMON.ts index a74d62a..e52a064 100644 --- a/src/main/angular/src/app/COMMON.ts +++ b/src/main/angular/src/app/COMMON.ts @@ -5,6 +5,7 @@ import {Injectable} from '@angular/core'; import {RxStompState} from '@stomp/rx-stomp'; export type FromJson = (json: any) => T; +export type FromJsonIndexed = (json: any, index: number) => T; export type Next = (item: T) => any; @@ -52,6 +53,10 @@ export function validateList(json: any, fromJson: FromJson): T[] { return json.map(fromJson); } +export function validateListIndexed(json: any, fromJson: FromJsonIndexed): 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(path: any[], fromJson: FromJson, next: Next): void { + getSingle(path: any[], fromJson: FromJson, next?: Next): void { this.http.get(url('http', path)).pipe(map(fromJson)).subscribe(next); } - getList(path: any[], fromJson: FromJson, next: Next): void { + getList(path: any[], fromJson: FromJson, next?: Next): void { this.http.get(url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next); } - postSingle(path: any[], data: any, fromJson: FromJson, next: Next): void { + postSingle(path: any[], data: any, fromJson: FromJson, next?: Next): void { this.http.post(url('http', path), data).pipe(map(fromJson)).subscribe(next); } - postList(path: any[], data: any, fromJson: FromJson, next: Next): void { + postList(path: any[], data: any, fromJson: FromJson, next?: Next): void { this.http.post(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(topic: any[], fromJson: FromJson, next: Next): Subscription { + subscribe(topic: any[], fromJson: FromJson, next?: Next): Subscription { return this.stompService .subscribe(topic.join("/")) .pipe( @@ -169,22 +174,36 @@ export abstract class CrudService { this.getList(["findAll"], next); } - protected getSingle(path: any[], next: Next) { + protected getSingle(path: any[], next?: Next) { this.api.getSingle([...this.path, ...path], this.fromJson, next); } - protected getList(path: any[], next: Next) { + protected getList(path: any[], next?: Next) { this.api.getList([...this.path, ...path], this.fromJson, next); } - protected postSingle(path: any[], data: any, next: Next) { + protected postSingle(path: any[], data: any, next?: Next) { this.api.postSingle([...this.path, ...path], data, this.fromJson, next); } - subscribe(next: Next): Subscription { + subscribe(next: Next, 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(v: T | null | undefined): T | undefined { + if (v === null) { + return undefined; + } + return v; +} + +export function UTN(v: T | null | undefined): T | null { + if (v === undefined) { + return null; + } + return v; +} diff --git a/src/main/angular/src/app/app.config.ts b/src/main/angular/src/app/app.config.ts index 68b011c..aebfddd 100644 --- a/src/main/angular/src/app/app.config.ts +++ b/src/main/angular/src/app/app.config.ts @@ -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: [ diff --git a/src/main/angular/src/app/app.html b/src/main/angular/src/app/app.html index 88d6a9b..7dd570e 100644 --- a/src/main/angular/src/app/app.html +++ b/src/main/angular/src/app/app.html @@ -1,3 +1 @@ - - diff --git a/src/main/angular/src/app/app.routes.ts b/src/main/angular/src/app/app.routes.ts index dc39edb..07df427 100644 --- a/src/main/angular/src/app/app.routes.ts +++ b/src/main/angular/src/app/app.routes.ts @@ -1,3 +1,8 @@ -import { Routes } from '@angular/router'; +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'}, +]; diff --git a/src/main/angular/src/app/app.ts b/src/main/angular/src/app/app.ts index 0e20261..9b04721 100644 --- a/src/main/angular/src/app/app.ts +++ b/src/main/angular/src/app/app.ts @@ -1,10 +1,9 @@ -import { Component, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import {Plot} from './plot/plot'; +import {Component, signal} from '@angular/core'; +import {RouterOutlet} from '@angular/router'; @Component({ selector: 'app-root', - imports: [RouterOutlet, Plot], + imports: [RouterOutlet], templateUrl: './app.html', styleUrl: './app.less' }) diff --git a/src/main/angular/src/app/common/checkbox/checkbox.component.html b/src/main/angular/src/app/common/checkbox/checkbox.component.html new file mode 100644 index 0000000..6dada88 --- /dev/null +++ b/src/main/angular/src/app/common/checkbox/checkbox.component.html @@ -0,0 +1 @@ + diff --git a/src/main/angular/src/app/common/checkbox/checkbox.component.less b/src/main/angular/src/app/common/checkbox/checkbox.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/common/checkbox/checkbox.component.ts b/src/main/angular/src/app/common/checkbox/checkbox.component.ts new file mode 100644 index 0000000..25aee19 --- /dev/null +++ b/src/main/angular/src/app/common/checkbox/checkbox.component.ts @@ -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(); + + @Input() + set initial(v: boolean) { + this._initial = v; + this.model = this._initial; + } + + apply() { + if (this.model !== this._initial) { + this.onChange.emit(this.model); + } + } + +} diff --git a/src/main/angular/src/app/common/number/number.component.html b/src/main/angular/src/app/common/number/number.component.html new file mode 100644 index 0000000..edc699c --- /dev/null +++ b/src/main/angular/src/app/common/number/number.component.html @@ -0,0 +1 @@ + diff --git a/src/main/angular/src/app/common/number/number.component.less b/src/main/angular/src/app/common/number/number.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/common/number/number.component.ts b/src/main/angular/src/app/common/number/number.component.ts new file mode 100644 index 0000000..29f1b07 --- /dev/null +++ b/src/main/angular/src/app/common/number/number.component.ts @@ -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(); + + @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 + ""; + } + +} diff --git a/src/main/angular/src/app/common/numberNN/number-n-n.component.html b/src/main/angular/src/app/common/numberNN/number-n-n.component.html new file mode 100644 index 0000000..edc699c --- /dev/null +++ b/src/main/angular/src/app/common/numberNN/number-n-n.component.html @@ -0,0 +1 @@ + diff --git a/src/main/angular/src/app/common/numberNN/number-n-n.component.less b/src/main/angular/src/app/common/numberNN/number-n-n.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/common/numberNN/number-n-n.component.ts b/src/main/angular/src/app/common/numberNN/number-n-n.component.ts new file mode 100644 index 0000000..6174529 --- /dev/null +++ b/src/main/angular/src/app/common/numberNN/number-n-n.component.ts @@ -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(); + + @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 + ""; + } + +} diff --git a/src/main/angular/src/app/common/text/text.component.html b/src/main/angular/src/app/common/text/text.component.html new file mode 100644 index 0000000..7b8e1d5 --- /dev/null +++ b/src/main/angular/src/app/common/text/text.component.html @@ -0,0 +1 @@ + diff --git a/src/main/angular/src/app/common/text/text.component.less b/src/main/angular/src/app/common/text/text.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/common/text/text.component.ts b/src/main/angular/src/app/common/text/text.component.ts new file mode 100644 index 0000000..aa73c8a --- /dev/null +++ b/src/main/angular/src/app/common/text/text.component.ts @@ -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(); + + @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; + } + +} diff --git a/src/main/angular/src/app/plot/Axis.ts b/src/main/angular/src/app/plot/Axis.ts new file mode 100644 index 0000000..6f5e247 --- /dev/null +++ b/src/main/angular/src/app/plot/Axis.ts @@ -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, + ); + } + +} diff --git a/src/main/angular/src/app/plot/Graph.ts b/src/main/angular/src/app/plot/Graph.ts new file mode 100644 index 0000000..1d2242c --- /dev/null +++ b/src/main/angular/src/app/plot/Graph.ts @@ -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), + ); + } + +} diff --git a/src/main/angular/src/app/plot/Group.ts b/src/main/angular/src/app/plot/Group.ts new file mode 100644 index 0000000..65093ab --- /dev/null +++ b/src/main/angular/src/app/plot/Group.ts @@ -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", +} diff --git a/src/main/angular/src/app/plot/Plot.ts b/src/main/angular/src/app/plot/Plot.ts new file mode 100644 index 0000000..33ac00a --- /dev/null +++ b/src/main/angular/src/app/plot/Plot.ts @@ -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; + } + +} diff --git a/src/main/angular/src/app/plot/plot.component.html b/src/main/angular/src/app/plot/plot.component.html new file mode 100644 index 0000000..3bca068 --- /dev/null +++ b/src/main/angular/src/app/plot/plot.component.html @@ -0,0 +1,220 @@ +
+
+ + + + +
+ +
+ +
+ + @if (plot) { + +
+ + + + + + + + + + + + + + + + + +
NameIntervallVerschiebungDauerDashPosition
+ + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + @for (axis of plot.axes; track axis.id) { + + + + + + + + + + + + + } +
+ + + + NameEinheitrechtsminfestmaxfest 
+ + + Y{{ axis.index + 1 }} + + + + + + + + + + + + + + + + +   + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + @for (axis of plot.axes; track axis.id) { + @for (graph of axis.graphs; track graph.id) { + + + + + + + + + @if (graph.series.type === SeriesType.VARYING) { + + + + } @else { + @if (graph.series.type === SeriesType.BOOL) { + + } @else { + + } + } + + + + } + } +
+ + NameSerieFarbeFaktorAggregatStackminmaxAchse 
+ + + + + + + + + + + + + + + + + + + + BooleanDelta + + + +
+
+ + } + +
diff --git a/src/main/angular/src/app/plot/plot.component.less b/src/main/angular/src/app/plot/plot.component.less new file mode 100644 index 0000000..a3609fa --- /dev/null +++ b/src/main/angular/src/app/plot/plot.component.less @@ -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; +} diff --git a/src/main/angular/src/app/plot/plot.component.ts b/src/main/angular/src/app/plot/plot.component.ts new file mode 100644 index 0000000..e18139e --- /dev/null +++ b/src/main/angular/src/app/plot/plot.component.ts @@ -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[][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; + + @ViewChild('container') + chartContainer!: ElementRef; + + 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); + } + +} diff --git a/src/main/angular/src/app/plot/plot.html b/src/main/angular/src/app/plot/plot.html deleted file mode 100644 index 4585a0f..0000000 --- a/src/main/angular/src/app/plot/plot.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/src/main/angular/src/app/plot/plot.less b/src/main/angular/src/app/plot/plot.less deleted file mode 100644 index cbe226c..0000000 --- a/src/main/angular/src/app/plot/plot.less +++ /dev/null @@ -1,4 +0,0 @@ -.container { - width: 100%; - height: 100%; -} diff --git a/src/main/angular/src/app/plot/plot.service.ts b/src/main/angular/src/app/plot/plot.service.ts new file mode 100644 index 0000000..943eefe --- /dev/null +++ b/src/main/angular/src/app/plot/plot.service.ts @@ -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 { + + constructor( + api: ApiService, + ) { + super(api, ["Plot"], Plot.fromJson); + } + + plotCreate(next: Next): void { + this.getSingle(["create"], next); + } + + plotDuplicate(plot: Plot, next: Next): void { + this.getSingle([plot.id, "duplicate"], next); + } + + plotDelete(plot: Plot, next: Next): void { + this.getSingle([plot.id, "delete"], next); + } + + plotAddAxis(plot: Plot, next: Next): void { + this.getSingle([plot.id, 'addAxis'], next); + } + + plotName(plot: Plot, value: string, next?: Next): void { + this.postSingle([plot.id, 'name'], value, next); + } + + plotInterval(plot: Plot, value: Interval, next?: Next): void { + this.postSingle([plot.id, 'interval'], value.name, next); + } + + plotOffset(plot: Plot, value: number, next?: Next): void { + this.postSingle([plot.id, 'offset'], value, next); + } + + plotDuration(plot: Plot, value: number, next?: Next): void { + this.postSingle([plot.id, 'duration'], value, next); + } + + plotDashboard(plot: Plot, value: boolean, next?: Next): void { + this.postSingle([plot.id, 'dashboard'], value, next); + } + + plotPosition(plot: Plot, value: number, next?: Next): void { + this.postSingle([plot.id, 'position'], value, next); + } + + axisAddGraph(axis: Axis, next: Next): void { + this.getSingle(['Axis', axis.id, 'addGraph'], next); + } + + axisDelete(axis: Axis, next?: Next): void { + this.getSingle(['Axis', axis.id, 'delete'], next); + } + + axisVisible(axis: Axis, value: boolean, next?: Next): void { + this.postSingle(['Axis', axis.id, 'visible'], value, next); + } + + axisName(axis: Axis, value: string, next?: Next): void { + this.postSingle(['Axis', axis.id, 'name'], value, next); + } + + axisUnit(axis: Axis, value: string, next?: Next): void { + this.postSingle(['Axis', axis.id, 'unit'], value, next); + } + + axisRight(axis: Axis, value: boolean, next?: Next): void { + this.postSingle(['Axis', axis.id, 'right'], value, next); + } + + axisMin(axis: Axis, value: number | null, next?: Next): void { + this.postSingle(['Axis', axis.id, 'min'], value, next); + } + + axisMinHard(axis: Axis, value: boolean, next?: Next): void { + this.postSingle(['Axis', axis.id, 'minHard'], value, next); + } + + axisMax(axis: Axis, value: number | null, next?: Next): void { + this.postSingle(['Axis', axis.id, 'max'], value, next); + } + + axisMaxHard(axis: Axis, value: boolean, next?: Next): void { + this.postSingle(['Axis', axis.id, 'maxHard'], value, next); + } + + graphDelete(graph: Graph, next?: Next): void { + this.getSingle(['Graph', graph.id, 'delete'], next); + } + + graphVisible(graph: Graph, value: boolean, next?: Next): void { + this.postSingle(['Graph', graph.id, 'visible'], value, next); + } + + graphName(graph: Graph, value: string, next?: Next): void { + this.postSingle(['Graph', graph.id, 'name'], value, next); + } + + graphSeries(graph: Graph, value: number, next?: Next): void { + this.postSingle(['Graph', graph.id, 'series'], value, next); + } + + graphColor(graph: Graph, value: string, next?: Next): void { + this.postSingle(['Graph', graph.id, 'color'], value, next); + } + + graphFactor(graph: Graph, value: number, next?: Next): void { + this.postSingle(['Graph', graph.id, 'factor'], value, next); + } + + graphGroup(graph: Graph, value: Group, next?: Next): void { + this.postSingle(['Graph', graph.id, 'group'], value, next); + } + + graphStack(graph: Graph, value: string, next?: Next): void { + this.postSingle(['Graph', graph.id, 'stack'], value, next); + } + + graphMin(graph: Graph, value: boolean, next?: Next): void { + this.postSingle(['Graph', graph.id, 'min'], value, next); + } + + graphMax(graph: Graph, value: boolean, next?: Next): void { + this.postSingle(['Graph', graph.id, 'max'], value, next); + } + + graphAvg(graph: Graph, value: boolean, next?: Next): void { + this.postSingle(['Graph', graph.id, 'avg'], value, next); + } + + graphAxis(graph: Graph, value: number, next?: Next): void { + this.postSingle(['Graph', graph.id, 'axis'], value, next); + } + +} diff --git a/src/main/angular/src/app/plot/plot.ts b/src/main/angular/src/app/plot/plot.ts deleted file mode 100644 index 865bac1..0000000 --- a/src/main/angular/src/app/plot/plot.ts +++ /dev/null @@ -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; - - @ViewChild('container') - chartContainer!: ElementRef; - - 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 = 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[][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(); - }, - ); - } - -} diff --git a/src/main/angular/src/app/series/Interval.ts b/src/main/angular/src/app/series/Interval.ts index 321ab2d..383a915 100644 --- a/src/main/angular/src/app/series/Interval.ts +++ b/src/main/angular/src/app/series/Interval.ts @@ -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; + } + } diff --git a/src/main/angular/src/app/series/Series.ts b/src/main/angular/src/app/series/Series.ts index 86ae470..9df8c27 100644 --- a/src/main/angular/src/app/series/Series.ts +++ b/src/main/angular/src/app/series/Series.ts @@ -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); + } + } diff --git a/src/main/angular/src/app/series/bool/bool-service.ts b/src/main/angular/src/app/series/bool/bool-service.ts new file mode 100644 index 0000000..371dba1 --- /dev/null +++ b/src/main/angular/src/app/series/bool/bool-service.ts @@ -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 { + + constructor( + api: ApiService, + ) { + super(api, ["Bool"], Bool.fromJson); + } + +} diff --git a/src/main/angular/src/app/series/delta/delta-service.ts b/src/main/angular/src/app/series/delta/delta-service.ts new file mode 100644 index 0000000..7a44717 --- /dev/null +++ b/src/main/angular/src/app/series/delta/delta-service.ts @@ -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 { + + constructor( + api: ApiService, + ) { + super(api, ["Delta"], Delta.fromJson); + } + +} diff --git a/src/main/angular/src/app/series/varying/varying-service.ts b/src/main/angular/src/app/series/varying/varying-service.ts new file mode 100644 index 0000000..6cae71f --- /dev/null +++ b/src/main/angular/src/app/series/varying/varying-service.ts @@ -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 { + + constructor( + api: ApiService, + ) { + super(api, ["Varying"], Varying.fromJson); + } + +} diff --git a/src/main/angular/src/main.ts b/src/main/angular/src/main.ts index 5df75f9..9c2613b 100644 --- a/src/main/angular/src/main.ts +++ b/src/main/angular/src/main.ts @@ -1,6 +1,6 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { App } from './app/app'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {appConfig} from './app/app.config'; +import {App} from './app/app'; bootstrapApplication(App, appConfig) .catch((err) => console.error(err)); diff --git a/src/main/angular/src/styles.less b/src/main/angular/src/styles.less index 9fe8a39..1fa18d7 100644 --- a/src/main/angular/src/styles.less +++ b/src/main/angular/src/styles.less @@ -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; +} diff --git a/src/main/java/de/ph87/data/DemoService.java b/src/main/java/de/ph87/data/DemoService.java index 89a1785..40bf903 100644 --- a/src/main/java/de/ph87/data/DemoService.java +++ b/src/main/java/de/ph87/data/DemoService.java @@ -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 diff --git a/src/main/java/de/ph87/data/Helpers.java b/src/main/java/de/ph87/data/Helpers.java index 30e1c5e..899c894 100644 --- a/src/main/java/de/ph87/data/Helpers.java +++ b/src/main/java/de/ph87/data/Helpers.java @@ -15,4 +15,12 @@ public class Helpers { return mapper.apply(t); } + @NonNull + public static T or(@Nullable final T t, @NonNull final T r) { + if (t == null) { + return r; + } + return t; + } + } diff --git a/src/main/java/de/ph87/data/log/AbstractEntityLog.java b/src/main/java/de/ph87/data/log/AbstractEntityLog.java index 1e3e5f4..d2aff23 100644 --- a/src/main/java/de/ph87/data/log/AbstractEntityLog.java +++ b/src/main/java/de/ph87/data/log/AbstractEntityLog.java @@ -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 diff --git a/src/main/java/de/ph87/data/plot/Plot.java b/src/main/java/de/ph87/data/plot/Plot.java new file mode 100644 index 0000000..2a0c3d6 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/Plot.java @@ -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 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); + } + +} diff --git a/src/main/java/de/ph87/data/plot/PlotController.java b/src/main/java/de/ph87/data/plot/PlotController.java new file mode 100644 index 0000000..f62e3c8 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/PlotController.java @@ -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 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); + } + +} diff --git a/src/main/java/de/ph87/data/plot/PlotDto.java b/src/main/java/de/ph87/data/plot/PlotDto.java new file mode 100644 index 0000000..9042e31 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/PlotDto.java @@ -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 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(); + } + +} diff --git a/src/main/java/de/ph87/data/plot/PlotRepository.java b/src/main/java/de/ph87/data/plot/PlotRepository.java new file mode 100644 index 0000000..1eb5492 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/PlotRepository.java @@ -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 { + + @Query("select new de.ph87.data.plot.PlotDto(p, false) from Plot p") + List findAllDto(); + + @Query("select max(p.position) from Plot p") + Optional findMaxPosition(); + + List findAllByPositionGreaterThanAndPositionLessThanEqual(long first, long last); + + List findAllByPositionGreaterThanEqualAndPositionLessThan(long first, long last); + + List findAllByPositionGreaterThan(long position); + +} diff --git a/src/main/java/de/ph87/data/plot/PlotService.java b/src/main/java/de/ph87/data/plot/PlotService.java new file mode 100644 index 0000000..ba1f257 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/PlotService.java @@ -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 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); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/Axis.java b/src/main/java/de/ph87/data/plot/axis/Axis.java new file mode 100644 index 0000000..d7a2eda --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/Axis.java @@ -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 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); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/AxisController.java b/src/main/java/de/ph87/data/plot/axis/AxisController.java new file mode 100644 index 0000000..3d1a14b --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/AxisController.java @@ -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 list = axis.getPlot().getAxes(); + list.remove(axis); + list.add(Math.max(0, Math.min(value, list.size())), axis); + }); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/AxisDto.java b/src/main/java/de/ph87/data/plot/axis/AxisDto.java new file mode 100644 index 0000000..09f3495 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/AxisDto.java @@ -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 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(); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/AxisRepository.java b/src/main/java/de/ph87/data/plot/axis/AxisRepository.java new file mode 100644 index 0000000..2fbf50b --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/AxisRepository.java @@ -0,0 +1,7 @@ +package de.ph87.data.plot.axis; + +import org.springframework.data.repository.ListCrudRepository; + +public interface AxisRepository extends ListCrudRepository { + +} diff --git a/src/main/java/de/ph87/data/plot/axis/AxisService.java b/src/main/java/de/ph87/data/plot/axis/AxisService.java new file mode 100644 index 0000000..40cf438 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/AxisService.java @@ -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 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(); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/Graph.java b/src/main/java/de/ph87/data/plot/axis/graph/Graph.java new file mode 100644 index 0000000..b180515 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/Graph.java @@ -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; + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/GraphController.java b/src/main/java/de/ph87/data/plot/axis/graph/GraphController.java new file mode 100644 index 0000000..fe98ebe --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/GraphController.java @@ -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 list = graph.getAxis().getGraphs(); + list.remove(graph); + list.add(Math.max(0, Math.min(value, list.size())), graph); + }); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/GraphDto.java b/src/main/java/de/ph87/data/plot/axis/graph/GraphDto.java new file mode 100644 index 0000000..5666fcc --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/GraphDto.java @@ -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(); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/GraphRepository.java b/src/main/java/de/ph87/data/plot/axis/graph/GraphRepository.java new file mode 100644 index 0000000..9f4d2a1 --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/GraphRepository.java @@ -0,0 +1,7 @@ +package de.ph87.data.plot.axis.graph; + +import org.springframework.data.repository.ListCrudRepository; + +public interface GraphRepository extends ListCrudRepository { + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/GraphService.java b/src/main/java/de/ph87/data/plot/axis/graph/GraphService.java new file mode 100644 index 0000000..c1b143d --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/GraphService.java @@ -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 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(); + } + +} diff --git a/src/main/java/de/ph87/data/plot/axis/graph/Group.java b/src/main/java/de/ph87/data/plot/axis/graph/Group.java new file mode 100644 index 0000000..9b2961c --- /dev/null +++ b/src/main/java/de/ph87/data/plot/axis/graph/Group.java @@ -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, +} diff --git a/src/main/java/de/ph87/data/series/SeriesController.java b/src/main/java/de/ph87/data/series/SeriesController.java index 53e94cb..28f9183 100644 --- a/src/main/java/de/ph87/data/series/SeriesController.java +++ b/src/main/java/de/ph87/data/series/SeriesController.java @@ -20,7 +20,7 @@ public class SeriesController { private final SeriesRepository seriesRepository; - private final SeriesService seriesService; + private final SeriesService SeriesService; @GetMapping("findAll") public List findAll() { @@ -34,7 +34,7 @@ public class SeriesController { @PostMapping("points") public List points(@NonNull @RequestBody final SeriesPointsRequest request) { - return seriesService.points(request); + return SeriesService.points(request); } } diff --git a/src/main/java/de/ph87/data/series/SeriesDto.java b/src/main/java/de/ph87/data/series/SeriesDto.java index 2a9c2f4..1d2f8e8 100644 --- a/src/main/java/de/ph87/data/series/SeriesDto.java +++ b/src/main/java/de/ph87/data/series/SeriesDto.java @@ -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(); diff --git a/src/main/java/de/ph87/data/series/SeriesRepository.java b/src/main/java/de/ph87/data/series/SeriesRepository.java index cc01f60..5cef47c 100644 --- a/src/main/java/de/ph87/data/series/SeriesRepository.java +++ b/src/main/java/de/ph87/data/series/SeriesRepository.java @@ -13,11 +13,13 @@ public interface SeriesRepository extends ListCrudRepository { Optional 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 findAllDto(); + Optional findFirstByOrderByNameAsc(); + } diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolDto.java b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java index 7a39b08..5526f7f 100644 --- a/src/main/java/de/ph87/data/series/data/bool/BoolDto.java +++ b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java @@ -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); + } + } diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java index 0fa4706..175d57a 100644 --- a/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java @@ -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); } } diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaService.java b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java index 2b7066d..e621bf4 100644 --- a/src/main/java/de/ph87/data/series/data/delta/DeltaService.java +++ b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java @@ -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 void write(@NonNull final Series series, @NonNull final DeltaRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) { + private void write(@NonNull final Series series, @NonNull final DeltaRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final BiFunction 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 diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java index 0e31545..4ed1bbf 100644 --- a/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java @@ -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); } } diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingService.java b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java index 49164bf..46c48b5 100644 --- a/src/main/java/de/ph87/data/series/data/varying/VaryingService.java +++ b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java @@ -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 void write(@NonNull final Series series, @NonNull final VaryingRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) { + private void write(@NonNull final Series series, @NonNull final VaryingRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final BiFunction 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 diff --git a/src/main/java/de/ph87/data/topic/TopicReceiver.java b/src/main/java/de/ph87/data/topic/TopicReceiver.java index 1331214..476c250 100644 --- a/src/main/java/de/ph87/data/topic/TopicReceiver.java +++ b/src/main/java/de/ph87/data/topic/TopicReceiver.java @@ -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 -> { diff --git a/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java index e18a029..606a99b 100644 --- a/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java +++ b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java @@ -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();