Compare commits
39 Commits
DEPLOY-202
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b34a2296 | |||
| 4e678d9c65 | |||
| 54e1487300 | |||
| 59b9a5f44f | |||
| 83d8dec332 | |||
| f5e9fd3679 | |||
| 22e5f96d1d | |||
| 2c66314941 | |||
| 9ee8060f05 | |||
| b6dfdd5686 | |||
| 6aac9b2662 | |||
| d7ee5062e4 | |||
| 5358f1b9f6 | |||
| d81034e6c4 | |||
| 49ce44eff9 | |||
| 85a749f199 | |||
| 63548dc3ff | |||
| 0561940861 | |||
| f0f68f3285 | |||
| b4a8b25ae7 | |||
| 6c49f4738a | |||
| 906be87e50 | |||
| 2882184a82 | |||
| 9d12986720 | |||
| 5c3338fd9d | |||
| 088f086cef | |||
| f9e94357c8 | |||
| ac50440215 | |||
| f495ad9af1 | |||
| f5cbf9cf43 | |||
| 617c9a2ebb | |||
| 7f39ee6c41 | |||
| a0d15b1aa4 | |||
| 483331f6df | |||
| 0e9b99e7e4 | |||
| 27d827b9d6 | |||
| dfc374b13a | |||
| a6e1e189b3 | |||
| 3782c10693 |
@ -9,3 +9,5 @@ spring.datasource.password=password
|
||||
#-
|
||||
de.ph87.data.message.receive.mqtt.host=10.0.0.50
|
||||
de.ph87.data.message.receive.mqtt.topic=#
|
||||
#-
|
||||
server.port=8081
|
||||
5
deploy-backend.sh
Normal file
5
deploy-backend.sh
Normal file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
scp -P2222 -r ./target/Data.jar root@10.255.0.1:/srv/Data/update/ && git tag "DEPLOY-BACK---$(date +'%F---%H-%M-%S')" && ssh -p2222 root@10.255.0.1 systemctl restart Data.service
|
||||
6
pom.xml
6
pom.xml
@ -18,7 +18,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.2</version>
|
||||
<version>3.4.4</version>
|
||||
</parent>
|
||||
|
||||
<dependencies>
|
||||
@ -26,6 +26,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
|
||||
18
src/main/angular/.editorconfig
Normal file
18
src/main/angular/.editorconfig
Normal file
@ -0,0 +1,18 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
# noinspection EditorConfigKeyCorrectness
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
src/main/angular/.gitignore
vendored
Normal file
42
src/main/angular/.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
59
src/main/angular/README.md
Normal file
59
src/main/angular/README.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Angular
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.1.8.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
124
src/main/angular/angular.json
Normal file
124
src/main/angular/angular.json
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"angular": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "less",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/angular",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "less",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.less"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "angular:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "angular:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "less",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.less"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/main/angular/colors.less
Normal file
57
src/main/angular/colors.less
Normal file
@ -0,0 +1,57 @@
|
||||
/* bright theme */
|
||||
|
||||
@foreground: gray;
|
||||
@background: white;
|
||||
@FONT_SELECTABLE: white;
|
||||
|
||||
@consumption: orange;
|
||||
@purchase: orangered;
|
||||
@production: dodgerblue;
|
||||
@cistern: #0760ff;
|
||||
@self: forestgreen;
|
||||
@delivery: magenta;
|
||||
|
||||
@consumptionBack: @consumption;
|
||||
@purchaseBack: @purchase;
|
||||
@productionBack: @production;
|
||||
@selfBack: @self;
|
||||
@deliveryBack: @delivery;
|
||||
|
||||
/* dark theme */
|
||||
|
||||
@foreground: gray;
|
||||
@background: #11171b;
|
||||
|
||||
@consumptionBack: #856938;
|
||||
@purchaseBack: #71361d;
|
||||
@productionBack: #2d4255;
|
||||
@selfBack: #2b4e2b;
|
||||
@deliveryBack: #753475;
|
||||
|
||||
.consumption {
|
||||
color: @consumption;
|
||||
}
|
||||
|
||||
.purchase {
|
||||
color: @purchase;
|
||||
}
|
||||
|
||||
.production {
|
||||
color: @production;
|
||||
}
|
||||
|
||||
.self {
|
||||
color: @self;
|
||||
}
|
||||
|
||||
.delivery {
|
||||
color: @delivery;
|
||||
}
|
||||
|
||||
.cistern {
|
||||
color: @cistern;
|
||||
}
|
||||
|
||||
.zero {
|
||||
filter: opacity(20%);
|
||||
}
|
||||
49
src/main/angular/numberTable.less
Normal file
49
src/main/angular/numberTable.less
Normal file
@ -0,0 +1,49 @@
|
||||
@import "./colors.less";
|
||||
|
||||
.numberTable {
|
||||
|
||||
.arrowLeft {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.title {
|
||||
clear: left;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.option {
|
||||
clear: left;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
.entry {
|
||||
clear: left;
|
||||
|
||||
.name {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.value {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.percent {
|
||||
float: right;
|
||||
padding-top: 0.5em;
|
||||
font-size: 60%;
|
||||
width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
15064
src/main/angular/package-lock.json
generated
Normal file
15064
src/main/angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
src/main/angular/package.json
Normal file
40
src/main/angular/package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "angular",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"deploy": "ng build && scp -P2222 -r dist/angular/browser/* root@10.255.0.1:/srv/Data/www/ && git tag \"DEPLOY-FRONT---$(date +'%F---%H-%M-%S')\""
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.1.0",
|
||||
"@angular/common": "^19.1.0",
|
||||
"@angular/compiler": "^19.1.0",
|
||||
"@angular/core": "^19.1.0",
|
||||
"@angular/forms": "^19.1.0",
|
||||
"@angular/platform-browser": "^19.1.0",
|
||||
"@angular/platform-browser-dynamic": "^19.1.0",
|
||||
"@angular/router": "^19.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0",
|
||||
"@stomp/ng2-stompjs": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.1.8",
|
||||
"@angular/cli": "^19.1.8",
|
||||
"@angular/compiler-cli": "^19.1.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.5.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
6
src/main/angular/public/favicon.svg
Normal file
6
src/main/angular/public/favicon.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9f4c4c" d="M550,842c-0.474,6.588-4.111,13.6-7.5,18L524,842h26Z" transform="translate(-490 -810)"/>
|
||||
<path fill="#bf873e" d="M538.432,863.732A30.047,30.047,0,0,1,490,839.92c0-15.7,11.48-28.536,28-29.92v32Z" transform="translate(-490 -810)"/>
|
||||
<path fill="#5b75a0" d="M522,810a30.058,30.058,0,0,1,28,28H522V810Z" transform="translate(-490 -810)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
155
src/main/angular/src/app/View/View.ts
Normal file
155
src/main/angular/src/app/View/View.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import {Series} from '../series/Series';
|
||||
import {validateNumber, validateString} from '../core/validators';
|
||||
|
||||
export abstract class View {
|
||||
|
||||
protected constructor(
|
||||
readonly _type_: string,
|
||||
readonly uuid: string,
|
||||
readonly name: string,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): View {
|
||||
const type = validateString(json._type_);
|
||||
switch (type) {
|
||||
case 'literal':
|
||||
return ViewLiteral.fromJson2(json);
|
||||
case 'series':
|
||||
return ViewSeries.fromJson2(json, locale);
|
||||
case 'unary':
|
||||
return ViewUnary.fromJson2(json, locale);
|
||||
case 'binary':
|
||||
return ViewBinary.fromJson2(json, locale);
|
||||
default:
|
||||
throw new Error(`View type '${type}' not implemented.`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class ViewLiteral extends View {
|
||||
|
||||
constructor(
|
||||
_type_: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
readonly value: number,
|
||||
) {
|
||||
super(_type_, uuid, name);
|
||||
}
|
||||
|
||||
static fromJson2(json: any): ViewLiteral {
|
||||
return new ViewLiteral(
|
||||
validateString(json._type_),
|
||||
validateString(json.uuid),
|
||||
validateString(json.name),
|
||||
validateNumber(json.value),
|
||||
);
|
||||
}
|
||||
|
||||
static cast(view: View): ViewLiteral {
|
||||
return view as ViewLiteral;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class ViewSeries extends View {
|
||||
|
||||
constructor(
|
||||
_type_: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
readonly series: Series,
|
||||
) {
|
||||
super(_type_, uuid, name);
|
||||
}
|
||||
|
||||
static fromJson2(json: any, locale: string): ViewSeries {
|
||||
return new ViewSeries(
|
||||
validateString(json._type_),
|
||||
validateString(json.uuid),
|
||||
validateString(json.name),
|
||||
Series.fromJson(json.series, locale),
|
||||
);
|
||||
}
|
||||
|
||||
static cast(view: View): ViewSeries {
|
||||
return view as ViewSeries;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export enum ViewUnaryOperator {
|
||||
NEG = "NEG",
|
||||
REC = "REC",
|
||||
NOT_NEG = "NOT_NEG",
|
||||
}
|
||||
|
||||
export class ViewUnary extends View {
|
||||
|
||||
constructor(
|
||||
_type_: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
readonly operation: ViewUnaryOperator,
|
||||
readonly view: View,
|
||||
) {
|
||||
super(_type_, uuid, name);
|
||||
}
|
||||
|
||||
static fromJson2(json: any, locale: string): ViewUnary {
|
||||
return new ViewUnary(
|
||||
validateString(json._type_),
|
||||
validateString(json.uuid),
|
||||
validateString(json.name),
|
||||
validateString(json.operation) as ViewUnaryOperator,
|
||||
View.fromJson(json.view, locale),
|
||||
);
|
||||
}
|
||||
|
||||
static cast(view: View): ViewUnary {
|
||||
return view as ViewUnary;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export enum ViewBinaryOperator {
|
||||
PLUS = 'PLUS',
|
||||
MINUS = 'MINUS',
|
||||
MULTIPLY = 'MULTIPLY',
|
||||
DIVIDE = 'DIVIDE',
|
||||
MODULO = 'MODULO',
|
||||
PERCENT = 'PERCENT',
|
||||
}
|
||||
|
||||
export class ViewBinary extends View {
|
||||
|
||||
constructor(
|
||||
_type_: string,
|
||||
uuid: string,
|
||||
name: string,
|
||||
readonly operation: ViewBinaryOperator,
|
||||
readonly view0: View,
|
||||
readonly view1: View,
|
||||
) {
|
||||
super(_type_, uuid, name);
|
||||
}
|
||||
|
||||
static fromJson2(json: any, locale: string): ViewBinary {
|
||||
return new ViewBinary(
|
||||
validateString(json._type_),
|
||||
validateString(json.uuid),
|
||||
validateString(json.name),
|
||||
validateString(json.operation) as ViewBinaryOperator,
|
||||
View.fromJson(json.view0, locale),
|
||||
View.fromJson(json.view1, locale),
|
||||
);
|
||||
}
|
||||
|
||||
static cast(view: View): ViewBinary {
|
||||
return view as ViewBinary;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
<ng-container *ngIf="view && seriesList">
|
||||
|
||||
<div [ngSwitch]="view._type_">
|
||||
|
||||
<ng-container *ngSwitchCase="'literal'">
|
||||
<input type="number" [ngModel]="ViewLiteral.cast(view).value">
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'series'">
|
||||
<select [ngModel]="ViewSeries.cast(view).series.id">
|
||||
<option *ngFor="let series of seriesList" [ngValue]="series.id">{{ series.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'unary'">
|
||||
<select [ngModel]="ViewUnary.cast(view).operation">
|
||||
<option [ngValue]="ViewUnaryOperator.NEG">negiere</option>
|
||||
<option [ngValue]="ViewUnaryOperator.NOT_NEG">nicht negativ</option>
|
||||
<option [ngValue]="ViewUnaryOperator.REC">Kehrwert</option>
|
||||
</select>
|
||||
<div class="children">
|
||||
<div class="child">
|
||||
<app-view-body [view]="ViewUnary.cast(view).view" [seriesList]="seriesList"></app-view-body>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngSwitchCase="'binary'">
|
||||
<div class="children">
|
||||
<div class="child">
|
||||
<app-view-body [view]="ViewBinary.cast(view).view0" [seriesList]="seriesList"></app-view-body>
|
||||
</div>
|
||||
<select class="binary" [ngModel]="ViewBinary.cast(view).operation">
|
||||
<option [ngValue]="ViewBinaryOperator.PLUS">plus</option>
|
||||
<option [ngValue]="ViewBinaryOperator.MINUS">minus</option>
|
||||
<option [ngValue]="ViewBinaryOperator.MULTIPLY">mal</option>
|
||||
<option [ngValue]="ViewBinaryOperator.DIVIDE">geteilt</option>
|
||||
<option [ngValue]="ViewBinaryOperator.MODULO">mod</option>
|
||||
<option [ngValue]="ViewBinaryOperator.PERCENT">Prozent</option>
|
||||
</select>
|
||||
<div class="child">
|
||||
<app-view-body [view]="ViewBinary.cast(view).view1" [seriesList]="seriesList"></app-view-body>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
@ -0,0 +1,19 @@
|
||||
@import "../../../../colors";
|
||||
|
||||
.children {
|
||||
margin-left: 0.5em;
|
||||
border-left: 0.1em solid green;
|
||||
overflow: visible;
|
||||
|
||||
.child {
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
select.binary {
|
||||
background-color: @background;
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {NgForOf, NgIf, NgSwitch, NgSwitchCase} from '@angular/common';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {View, ViewBinary, ViewBinaryOperator, ViewLiteral, ViewSeries, ViewUnary, ViewUnaryOperator} from '../View';
|
||||
import {Series} from '../../series/Series';
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-body',
|
||||
imports: [
|
||||
NgForOf,
|
||||
NgSwitchCase,
|
||||
ReactiveFormsModule,
|
||||
NgSwitch,
|
||||
FormsModule,
|
||||
NgIf
|
||||
],
|
||||
templateUrl: './view-body.component.html',
|
||||
styleUrl: './view-body.component.less'
|
||||
})
|
||||
export class ViewBodyComponent {
|
||||
|
||||
protected readonly ViewSeries = ViewSeries;
|
||||
|
||||
protected readonly ViewLiteral = ViewLiteral;
|
||||
|
||||
protected readonly ViewUnary = ViewUnary;
|
||||
|
||||
protected readonly ViewUnaryOperator = ViewUnaryOperator;
|
||||
|
||||
protected readonly ViewBinaryOperator = ViewBinaryOperator;
|
||||
|
||||
protected readonly ViewBinary = ViewBinary;
|
||||
|
||||
@Input()
|
||||
view?: View;
|
||||
|
||||
@Input()
|
||||
seriesList?: Series[] = [];
|
||||
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<div class="list">
|
||||
<div class="view" *ngFor="let view of rootList">
|
||||
<div class="labelPair">
|
||||
<div class="name">Name:</div>
|
||||
<input [ngModel]="view.name" placeholder="Name">
|
||||
</div>
|
||||
<div class="body">
|
||||
<app-view-body [view]="view" [seriesList]="seriesList"></app-view-body>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,24 @@
|
||||
.view {
|
||||
font-size: 80%;
|
||||
|
||||
.labelPair {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
margin: 0.5em;
|
||||
|
||||
.name {
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {ViewService} from '../view.service';
|
||||
import {View, ViewLiteral, ViewSeries, ViewUnary, ViewUnaryOperator} from '../View';
|
||||
import {NgForOf} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Series} from '../../series/Series';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {ViewBodyComponent} from '../view-body/view-body.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-list',
|
||||
imports: [
|
||||
NgForOf,
|
||||
FormsModule,
|
||||
ViewBodyComponent
|
||||
],
|
||||
templateUrl: './view-list.component.html',
|
||||
styleUrl: './view-list.component.less'
|
||||
})
|
||||
export class ViewListComponent implements OnInit {
|
||||
|
||||
protected readonly ViewUnary = ViewUnary;
|
||||
|
||||
protected readonly ViewUnaryOperator = ViewUnaryOperator;
|
||||
|
||||
protected readonly ViewLiteral = ViewLiteral;
|
||||
|
||||
protected readonly ViewSeries = ViewSeries;
|
||||
|
||||
protected seriesList: Series[] = [];
|
||||
|
||||
protected rootList: View[] = [];
|
||||
|
||||
constructor(
|
||||
readonly viewService: ViewService,
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.viewService.list(list => this.rootList = list);
|
||||
this.seriesService.all(list => this.seriesList = list);
|
||||
}
|
||||
|
||||
}
|
||||
28
src/main/angular/src/app/View/view.service.ts
Normal file
28
src/main/angular/src/app/View/view.service.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
|
||||
import {ApiService} from '../core/api.service';
|
||||
import {FromJson, Next} from '../core/types';
|
||||
import {View} from './View';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ViewService {
|
||||
|
||||
readonly fromJson: FromJson<View> = json => View.fromJson(json, this.locale);
|
||||
|
||||
constructor(
|
||||
readonly api: ApiService,
|
||||
@Inject(LOCALE_ID) readonly locale: string,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
list(next: Next<View[]>) {
|
||||
return this.api.getList(['View', 'rootList'], this.fromJson, next);
|
||||
}
|
||||
|
||||
byUuid(uuid: string, next: Next<View>) {
|
||||
return this.api.getSingle(['View', 'byUuid', uuid], this.fromJson, next);
|
||||
}
|
||||
|
||||
}
|
||||
33
src/main/angular/src/app/app.component.html
Normal file
33
src/main/angular/src/app/app.component.html
Normal file
@ -0,0 +1,33 @@
|
||||
<div class="menubar">
|
||||
|
||||
<div class="menuitem menuitemLeft" *ngFor="let route of menubar()" [routerLink]="[route.routerLink]" routerLinkActive="menuitemActive">{{ route.title }}</div>
|
||||
|
||||
<div class="menuitem electricity">
|
||||
|
||||
<ng-container *ngIf="isDelivering">
|
||||
<div class="halfLine">
|
||||
<div class="delivery">{{ powerDelivery?.formatted2 }}</div>
|
||||
<div> + </div>
|
||||
<div class="self">{{ powerSelf?.formatted2 }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!isDelivering">
|
||||
<div class="halfLine">
|
||||
<div class="self">{{ powerProduction?.formatted2 }}</div>
|
||||
<div> + </div>
|
||||
<div class="purchase">{{ powerPurchase?.formatted2 }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="halfLine">
|
||||
<div class="delivery">{{ aggregations.energyDeliveredPercent?.formatted2 }}</div>
|
||||
<div> / </div>
|
||||
<div class="purchase">{{ aggregations.energyPurchasedPercent?.formatted2 }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<router-outlet/>
|
||||
38
src/main/angular/src/app/app.component.less
Normal file
38
src/main/angular/src/app/app.component.less
Normal file
@ -0,0 +1,38 @@
|
||||
.menubar {
|
||||
border-bottom: 0.05em solid black;
|
||||
background-color: #303d47;
|
||||
|
||||
.menuitem {
|
||||
padding: 0.1em 0.25em;
|
||||
}
|
||||
|
||||
.menuitemLeft {
|
||||
float: left;
|
||||
border-right: 0.05em solid black;
|
||||
}
|
||||
|
||||
.menuitemRight {
|
||||
float: right;
|
||||
border-left: 0.05em solid black;
|
||||
}
|
||||
|
||||
.menuitemActive {
|
||||
color: white;
|
||||
background-color: #006ebc;
|
||||
}
|
||||
|
||||
.electricity {
|
||||
float: right;
|
||||
padding-right: 0.25em;
|
||||
font-size: 55%;
|
||||
|
||||
.halfLine {
|
||||
|
||||
div {
|
||||
float: right;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
68
src/main/angular/src/app/app.component.ts
Normal file
68
src/main/angular/src/app/app.component.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
||||
import {menubar} from './app.routes';
|
||||
import {NgForOf, NgIf} from '@angular/common';
|
||||
import {SeriesService} from './series/series.service';
|
||||
import {Subscription, timer} from 'rxjs';
|
||||
import {Value} from './series/value/Value';
|
||||
import {AggregationWrapperDto} from './series/AggregationWrapperDto';
|
||||
import {Alignment} from './series/Alignment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, RouterLink, NgForOf, RouterLinkActive, NgIf],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.less'
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly menubar = menubar;
|
||||
|
||||
protected aggregations: AggregationWrapperDto = AggregationWrapperDto.EMPTY;
|
||||
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(this.seriesService.subscribeAny());
|
||||
this.subs.push(timer(0, 5000).subscribe(() => this.fetch()));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
get isDelivering(): boolean {
|
||||
return (this.powerDelivery?.value || 0) > 0;
|
||||
}
|
||||
|
||||
get powerProduction(): Value | undefined {
|
||||
return this.seriesService.powerProduced.series?.lastValue;
|
||||
}
|
||||
|
||||
get powerBalance(): Value | undefined {
|
||||
return this.seriesService.powerBalance.series?.lastValue;
|
||||
}
|
||||
|
||||
get powerSelf(): Value | undefined {
|
||||
return this.powerProduction?.minus(this.powerDelivery)?.notNegative();
|
||||
}
|
||||
|
||||
get powerPurchase(): Value | undefined {
|
||||
return this.powerBalance?.notNegative();
|
||||
}
|
||||
|
||||
get powerDelivery(): Value | undefined {
|
||||
return this.powerBalance?.negate()?.notNegative();
|
||||
}
|
||||
|
||||
private fetch() {
|
||||
this.seriesService.aggregations(Alignment.DAY, 0, aggregations => this.aggregations = aggregations);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/angular/src/app/app.config.ts
Normal file
23
src/main/angular/src/app/app.config.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {ApplicationConfig, LOCALE_ID, provideZoneChangeDetection} from '@angular/core';
|
||||
import {provideRouter} from '@angular/router';
|
||||
|
||||
import {routes} from './app.routes';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import localeDeExtra from '@angular/common/locales/extra/de';
|
||||
import {registerLocaleData} from '@angular/common';
|
||||
import {stompServiceFactory} from './core/websocket';
|
||||
import {StompService} from '@stomp/ng2-stompjs';
|
||||
|
||||
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({eventCoalescing: true}),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
{provide: StompService, useFactory: stompServiceFactory},
|
||||
{provide: LOCALE_ID, useValue: 'de-DE'},
|
||||
]
|
||||
};
|
||||
40
src/main/angular/src/app/app.routes.ts
Normal file
40
src/main/angular/src/app/app.routes.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {LiveComponent} from './live/live.component';
|
||||
import {GreenhouseComponent} from './live/greenhouse/greenhouse/greenhouse.component';
|
||||
import {HistoryComponent} from './history/history.component';
|
||||
import {ViewListComponent} from './View/view-list/view-list.component';
|
||||
|
||||
export class Path {
|
||||
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly title: string,
|
||||
readonly menu: boolean,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
get routerLink(): string {
|
||||
return `/${this.path}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const ROUTING = {
|
||||
LIVE: new Path('Live', 'Live', true),
|
||||
HISTORY: new Path('History', 'History', true),
|
||||
VIEW_LIST: new Path('ViewList', 'Views', true),
|
||||
GREENHOUSE: new Path('Greenhouse', 'Gewächshaus', false),
|
||||
}
|
||||
|
||||
export function menubar(): Path[] {
|
||||
return Object.values(ROUTING).filter(v => v.menu);
|
||||
}
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: ROUTING.LIVE.path, component: LiveComponent},
|
||||
{path: ROUTING.HISTORY.path, component: HistoryComponent},
|
||||
{path: ROUTING.GREENHOUSE.path, component: GreenhouseComponent},
|
||||
{path: ROUTING.VIEW_LIST.path, component: ViewListComponent},
|
||||
{path: '**', redirectTo: ROUTING.LIVE.path},
|
||||
];
|
||||
91
src/main/angular/src/app/core/AbstractRepositoryService.ts
Normal file
91
src/main/angular/src/app/core/AbstractRepositoryService.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import {Subscription} from "rxjs";
|
||||
import {Next} from "./types";
|
||||
import {Series} from "../series/Series";
|
||||
import {SeriesWrapper} from "../series/SeriesWrapper";
|
||||
import {Inject, LOCALE_ID} from "@angular/core";
|
||||
import {ApiService} from "./api.service";
|
||||
|
||||
export abstract class AbstractRepositoryService {
|
||||
|
||||
private readonly clientSubscriptions: Subscription[] = [];
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
private readonly clientCallbacks: Next<Series>[] = [];
|
||||
|
||||
protected abstract get liveValues(): SeriesWrapper[];
|
||||
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) readonly locale: string,
|
||||
protected readonly api: ApiService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
protected onSubscribe(subscription: Subscription): Subscription {
|
||||
this.clientSubscriptions.push(subscription);
|
||||
this.ensureApiSubscribed();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
protected onUnsubscribe(subscription: Subscription): Subscription {
|
||||
this.clientSubscriptions.splice(this.clientSubscriptions.indexOf(subscription), 1);
|
||||
if (this.clientSubscriptions.length === 0) {
|
||||
this.ensureApiUnsubscribed();
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private ensureApiSubscribed() {
|
||||
if (this.subs.length !== 0) {
|
||||
return;
|
||||
}
|
||||
this.subs.push(this.api.subscribe(['Series'], j => Series.fromJson(j, this.locale), series => this.update(series)));
|
||||
this.subs.push(this.api.subscribeConnection(connected => {
|
||||
if (connected) {
|
||||
this.all();
|
||||
} else {
|
||||
this.liveValues.forEach(liveValue => liveValue.series = null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private ensureApiUnsubscribed() {
|
||||
if (this.subs.length <= 0) {
|
||||
return;
|
||||
}
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
this.subs.length = 0;
|
||||
}
|
||||
|
||||
private update(series: Series) {
|
||||
this.liveValues
|
||||
.filter(liveValue => liveValue.name === series.name)
|
||||
.forEach(liveValue => liveValue.series = series);
|
||||
this.clientCallbacks.forEach(next => next(series));
|
||||
}
|
||||
|
||||
all(next?: Next<Series[]>) {
|
||||
this.api.getList(['Series', 'all'], j => Series.fromJson(j, this.locale), list => {
|
||||
list.forEach(item => this.update(item));
|
||||
if (next) {
|
||||
next(list);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribeAny(next?: Next<Series>): Subscription {
|
||||
const wrapper: Next<Series> = series => { // to let clientCallbacks only contain unique instances
|
||||
if (next) {
|
||||
next(series);
|
||||
}
|
||||
};
|
||||
this.clientCallbacks.push(wrapper);
|
||||
const subscription = new Subscription(() => {
|
||||
this.onUnsubscribe(subscription);
|
||||
this.clientCallbacks.splice(this.clientCallbacks.indexOf(wrapper), 1);
|
||||
});
|
||||
return this.onSubscribe(subscription);
|
||||
}
|
||||
|
||||
}
|
||||
50
src/main/angular/src/app/core/api.service.ts
Normal file
50
src/main/angular/src/app/core/api.service.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {map, Subscription} from 'rxjs';
|
||||
import {StompService} from '@stomp/ng2-stompjs';
|
||||
import {FromJson, Next} from './types';
|
||||
|
||||
const DEV_TO_PROD = false;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
|
||||
constructor(
|
||||
protected readonly http: HttpClient,
|
||||
private readonly stompService: StompService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
subscribeConnection(next: Next<boolean>): Subscription {
|
||||
const c = this.stompService.connected$.subscribe(() => next(true));
|
||||
const d = this.stompService.webSocketErrors$.subscribe(() => next(false));
|
||||
const subscription = new Subscription(() => {
|
||||
c.unsubscribe();
|
||||
d.unsubscribe();
|
||||
});
|
||||
next(this.stompService.connected());
|
||||
return subscription;
|
||||
}
|
||||
|
||||
subscribe<T>(path: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
|
||||
return this.stompService.subscribe(path.join('/')).pipe(map(m => fromJson(JSON.parse(m.body)))).subscribe(next);
|
||||
}
|
||||
|
||||
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): void {
|
||||
this.http.get<any>(ApiService.url('http', path)).pipe(map(fromJson)).subscribe(next);
|
||||
}
|
||||
|
||||
getList<T>(path: any[], fromJson: FromJson<T>, next?: Next<T[]>): void {
|
||||
this.http.get<any[]>(ApiService.url('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
|
||||
}
|
||||
|
||||
static url(protocol: string, path: any[]) {
|
||||
const host = DEV_TO_PROD ? '10.255.0.1' : location.hostname;
|
||||
const port = 8081;
|
||||
return `${protocol}${location.protocol.endsWith('s') ? 's' : ''}://${host}:${port}/${path.join('/')}`;
|
||||
}
|
||||
|
||||
}
|
||||
3
src/main/angular/src/app/core/types.ts
Normal file
3
src/main/angular/src/app/core/types.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type FromJson<T> = (json: any) => T;
|
||||
|
||||
export type Next<T> = (t: T) => any;
|
||||
23
src/main/angular/src/app/core/validators.ts
Normal file
23
src/main/angular/src/app/core/validators.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {FromJson} from './types';
|
||||
|
||||
export function validateString(value: any): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Not a string: ' + value);
|
||||
}
|
||||
return value as string;
|
||||
}
|
||||
|
||||
export function validateNumber(value: any): number {
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error('Not a number: ' + value);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function validateDate(value: any): Date {
|
||||
return new Date(validateString(value));
|
||||
}
|
||||
|
||||
export function validateList<T>(value: any[], fromJson: FromJson<T>): T[] {
|
||||
return value.map(fromJson);
|
||||
}
|
||||
15
src/main/angular/src/app/core/websocket.ts
Normal file
15
src/main/angular/src/app/core/websocket.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {StompService} from "@stomp/ng2-stompjs";
|
||||
import {ApiService} from './api.service';
|
||||
|
||||
export function stompServiceFactory() {
|
||||
const stomp = new StompService({
|
||||
url: ApiService.url('ws', ['ws']),
|
||||
debug: false,
|
||||
heartbeat_in: 2000,
|
||||
heartbeat_out: 2000,
|
||||
reconnect_delay: 2000,
|
||||
headers: {},
|
||||
});
|
||||
stomp.activate();
|
||||
return stomp;
|
||||
}
|
||||
64
src/main/angular/src/app/history/history.component.html
Normal file
64
src/main/angular/src/app/history/history.component.html
Normal file
@ -0,0 +1,64 @@
|
||||
<app-tile>
|
||||
<div tile-body>
|
||||
<div class="numberTable">
|
||||
<div class="option">
|
||||
<button class="arrowLeft" (click)="shiftAlignment(+1)">←</button>
|
||||
{{ offset > 0 ? -offset : '' }}{{ alignment === Alignment.FIVE && offset > 0 ? 'x' : '' }} {{ alignment.display }}{{ offset > 1 ? alignment.plural : '' }}
|
||||
<button class="arrowRight" (click)="shiftAlignment(-1)">→</button>
|
||||
</div>
|
||||
|
||||
<div class="option">
|
||||
<button class="arrowLeft" (click)="shiftOffset(+1)">←</button>
|
||||
{{ alignment.offsetTitle(offset, locale) }}
|
||||
<button class="arrowRight" (click)="shiftOffset(-1)" [disabled]="offset === 0">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-tile>
|
||||
|
||||
<app-tile>
|
||||
<div tile-head>
|
||||
Energie
|
||||
</div>
|
||||
<div tile-body>
|
||||
<div class="numberTable">
|
||||
<div class="content">
|
||||
<div class="entry consumption" [class.zero]="aggregations.energyConsumed?.zero">
|
||||
<div class="name">Verbraucht</div>
|
||||
<div class="percent"> </div>
|
||||
<div class="value">{{ aggregations.energyConsumed?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry purchase" [class.zero]="aggregations.energyPurchased?.delta?.zero">
|
||||
<div class="name">Bezogen</div>
|
||||
<div class="percent">{{ aggregations.energyPurchasedPercent?.formatted }}</div>
|
||||
<div class="value">{{ aggregations.energyPurchased?.delta?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry production" [class.zero]="aggregations.energyProduced?.delta?.zero">
|
||||
<div class="name">Produziert</div>
|
||||
<div class="percent">{{ aggregations.energyProducedPercent?.formatted }}</div>
|
||||
<div class="value">{{ aggregations.energyProduced?.delta?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry self" [class.zero]="aggregations.energySelf?.zero">
|
||||
<div class="name">Selbst verbr.</div>
|
||||
<div class="percent">{{ aggregations.energySelfPercent?.formatted }}</div>
|
||||
<div class="value">{{ aggregations.energySelf?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry delivery" [class.zero]="aggregations.energyDelivered?.delta?.zero">
|
||||
<div class="name">Eingespeist</div>
|
||||
<div class="percent">{{ aggregations.energyDeliveredPercent?.formatted }}</div>
|
||||
<div class="value">{{ aggregations.energyDelivered?.delta?.formatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-percent-bar [produktion]="aggregations.energyProduced?.delta" [self]="aggregations.energySelf" [purchase]="aggregations.energyPurchased?.delta" [delivery]="aggregations.energyDelivered?.delta"></app-percent-bar>
|
||||
</div>
|
||||
</app-tile>
|
||||
|
||||
<app-tile *ngIf="alignment.inner">
|
||||
<div tile-head>
|
||||
Zisterne
|
||||
</div>
|
||||
<div tile-body>
|
||||
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="seriesService.graph(seriesService.cisternVolume.series, 600, 200, alignment, offset, alignment.inner, 1)" [alt]="seriesService.cisternVolume.series.title">
|
||||
</div>
|
||||
</app-tile>
|
||||
1
src/main/angular/src/app/history/history.component.less
Normal file
1
src/main/angular/src/app/history/history.component.less
Normal file
@ -0,0 +1 @@
|
||||
@import "../../../colors.less";
|
||||
79
src/main/angular/src/app/history/history.component.ts
Normal file
79
src/main/angular/src/app/history/history.component.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core';
|
||||
import {PercentBarComponent} from '../shared/percent-bar/percent-bar.component';
|
||||
import {AggregationWrapperDto} from '../series/AggregationWrapperDto';
|
||||
import {Alignment} from '../series/Alignment';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {SeriesService} from '../series/series.service';
|
||||
import {NgIf} from "@angular/common";
|
||||
import {TileComponent} from '../shared/tile/tile.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
imports: [
|
||||
PercentBarComponent,
|
||||
NgIf,
|
||||
TileComponent
|
||||
],
|
||||
templateUrl: './history.component.html',
|
||||
styleUrl: './history.component.less'
|
||||
})
|
||||
export class HistoryComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected readonly Alignment = Alignment;
|
||||
|
||||
protected aggregations: AggregationWrapperDto = AggregationWrapperDto.EMPTY;
|
||||
|
||||
protected alignment: Alignment = Alignment.DAY;
|
||||
|
||||
protected offset: number = 0;
|
||||
|
||||
protected interval: any;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) public locale: string,
|
||||
protected readonly seriesService: SeriesService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
this.subs.push(this.seriesService.subscribeAny());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.intervalStop();
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
shiftOffset(delta: number) {
|
||||
this.offset = Math.max(0, this.offset + delta);
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
shiftAlignment(delta: number) {
|
||||
this.alignment = this.alignment.plus(delta)
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
private fetch() {
|
||||
if (this.offset === 0) {
|
||||
if (!this.interval) {
|
||||
this.interval = setInterval(() => this.fetch(), 5000);
|
||||
}
|
||||
} else {
|
||||
this.intervalStop();
|
||||
}
|
||||
this.seriesService.aggregations(this.alignment, this.offset, aggregations => this.aggregations = aggregations);
|
||||
}
|
||||
|
||||
private intervalStop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/angular/src/app/live/cistern/cistern.component.html
Normal file
23
src/main/angular/src/app/live/cistern/cistern.component.html
Normal file
@ -0,0 +1,23 @@
|
||||
<app-tile>
|
||||
|
||||
<div tile-head>
|
||||
Zisterne
|
||||
</div>
|
||||
|
||||
<div tile-body>
|
||||
|
||||
<div class="numberTable">
|
||||
<div class="content">
|
||||
<div class="entry cistern">
|
||||
<div class="name">Volumen</div>
|
||||
<div class="percent">{{ seriesService.cisternVolume.series?.lastValue?.percent(2000)?.localeString }} %</div>
|
||||
<div class="value">{{ seriesService.cisternVolume.series?.lastValue?.localeString }} {{ seriesService.cisternVolume.series?.unit?.unit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="graph(seriesService.cisternVolume.series)" [alt]="seriesService.cisternVolume.series.title">
|
||||
|
||||
</div>
|
||||
|
||||
</app-tile>
|
||||
39
src/main/angular/src/app/live/cistern/cistern.component.ts
Normal file
39
src/main/angular/src/app/live/cistern/cistern.component.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {NgIf} from '@angular/common';
|
||||
import {Series} from '../../series/Series';
|
||||
import {TileComponent} from '../../shared/tile/tile.component';
|
||||
import {Alignment} from '../../series/Alignment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cistern',
|
||||
imports: [
|
||||
NgIf,
|
||||
TileComponent
|
||||
],
|
||||
templateUrl: './cistern.component.html',
|
||||
styleUrl: './cistern.component.less'
|
||||
})
|
||||
export class CisternComponent implements OnInit, OnDestroy {
|
||||
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(this.seriesService.subscribeAny());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
graph(series: Series) {
|
||||
return this.seriesService.graph(series, 600, 200, Alignment.FIVE, 0, Alignment.FIVE, 24 * 12);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<table class="vertical">
|
||||
<tr>
|
||||
<th>Temperatur</th>
|
||||
<td class="valueInteger">{{seriesService.greenhouseTemperature.series?.lastValue?.localeString}}</td>
|
||||
<td class="unit">{{seriesService.greenhouseTemperature.series?.lastValue?.unit?.unit}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Relative Luftfeuchte</th>
|
||||
<td class="valueInteger">{{seriesService.greenhouseHumidityRelative.series?.lastValue?.localeString}}</td>
|
||||
<td class="unit">{{seriesService.greenhouseHumidityRelative.series?.lastValue?.unit?.unit}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Absolute Luftfeuchte</th>
|
||||
<td class="valueInteger">{{seriesService.greenhouseHumidityAbsolute.series?.lastValue?.localeString}}</td>
|
||||
<td class="unit">{{seriesService.greenhouseHumidityAbsolute.series?.lastValue?.unit?.unit}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Beleuchtungsstärke</th>
|
||||
<td class="valueInteger">{{seriesService.greenhouseIlluminance.series?.lastValue?.localeString}}</td>
|
||||
<td class="unit">{{seriesService.greenhouseIlluminance.series?.lastValue?.unit?.unit}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -0,0 +1,28 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {SeriesService} from '../../../series/series.service';
|
||||
import {Subscription} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-greenhouse',
|
||||
imports: [],
|
||||
templateUrl: './greenhouse.component.html',
|
||||
styleUrl: './greenhouse.component.less'
|
||||
})
|
||||
export class GreenhouseComponent implements OnInit, OnDestroy {
|
||||
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(this.seriesService.subscribeAny());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
5
src/main/angular/src/app/live/live.component.html
Normal file
5
src/main/angular/src/app/live/live.component.html
Normal file
@ -0,0 +1,5 @@
|
||||
<app-weather-diagram></app-weather-diagram>
|
||||
|
||||
<app-electro-power></app-electro-power>
|
||||
|
||||
<app-cistern></app-cistern>
|
||||
0
src/main/angular/src/app/live/live.component.less
Normal file
0
src/main/angular/src/app/live/live.component.less
Normal file
18
src/main/angular/src/app/live/live.component.ts
Normal file
18
src/main/angular/src/app/live/live.component.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ElectroPowerComponent} from "./power/electro-power.component";
|
||||
import {WeatherDiagramComponent} from './weather/weather-diagram/weather-diagram.component';
|
||||
import {CisternComponent} from './cistern/cistern.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-live',
|
||||
imports: [
|
||||
ElectroPowerComponent,
|
||||
WeatherDiagramComponent,
|
||||
CisternComponent
|
||||
],
|
||||
templateUrl: './live.component.html',
|
||||
styleUrl: './live.component.less'
|
||||
})
|
||||
export class LiveComponent {
|
||||
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<app-tile>
|
||||
|
||||
<div tile-head>
|
||||
Leistung
|
||||
</div>
|
||||
|
||||
<div tile-body>
|
||||
<div class="numberTable">
|
||||
<div class="content">
|
||||
<div class="entry consumption" [class.zero]="powerConsumption?.zero">
|
||||
<div class="name">Verbrauch</div>
|
||||
<div class="percent"> </div>
|
||||
<div class="value">{{ powerConsumption?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry purchase" [class.zero]="powerPurchase?.zero">
|
||||
<div class="name">Bezug</div>
|
||||
<div class="percent">{{ powerPurchasePercent?.formatted }}</div>
|
||||
<div class="value">{{ powerPurchase?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry production" [class.zero]="powerProduction?.zero">
|
||||
<div class="name">Produktion</div>
|
||||
<div class="percent">{{ powerProducedPercent?.formatted }}</div>
|
||||
<div class="value">{{ powerProduction?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry self" [class.zero]="powerSelf?.zero">
|
||||
<div class="name">Eigenverbrauch</div>
|
||||
<div class="percent">{{ powerSelfPercent?.formatted }}</div>
|
||||
<div class="value">{{ powerSelf?.formatted }}</div>
|
||||
</div>
|
||||
<div class="entry delivery" [class.zero]="powerDelivery?.zero">
|
||||
<div class="name">Einspeisung</div>
|
||||
<div class="percent">{{ powerDeliveryPercent?.formatted }}</div>
|
||||
<div class="value">{{ powerDelivery?.formatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-percent-bar [produktion]="powerProduction" [self]="powerSelf" [purchase]="powerPurchase" [delivery]="powerDelivery"></app-percent-bar>
|
||||
|
||||
</div>
|
||||
|
||||
</app-tile>
|
||||
@ -0,0 +1 @@
|
||||
@import "../../../../colors.less";
|
||||
@ -0,0 +1,74 @@
|
||||
import {Component, OnDestroy, OnInit} from '@angular/core';
|
||||
import {PercentBarComponent} from "../../shared/percent-bar/percent-bar.component";
|
||||
import {Value} from '../../series/value/Value';
|
||||
import {SeriesService} from '../../series/series.service';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {TileComponent} from '../../shared/tile/tile.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-electro-power',
|
||||
imports: [
|
||||
PercentBarComponent,
|
||||
TileComponent
|
||||
],
|
||||
templateUrl: './electro-power.component.html',
|
||||
styleUrl: './electro-power.component.less'
|
||||
})
|
||||
export class ElectroPowerComponent implements OnInit, OnDestroy {
|
||||
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
readonly seriesService: SeriesService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(this.seriesService.subscribeAny());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
get powerProduction(): Value | undefined {
|
||||
return this.seriesService.powerProduced.series?.lastValue;
|
||||
}
|
||||
|
||||
get powerBalance(): Value | undefined {
|
||||
return this.seriesService.powerBalance.series?.lastValue;
|
||||
}
|
||||
|
||||
get powerPurchase(): Value | undefined {
|
||||
return this.powerBalance?.notNegative();
|
||||
}
|
||||
|
||||
get powerDelivery(): Value | undefined {
|
||||
return this.powerBalance?.negate()?.notNegative();
|
||||
}
|
||||
|
||||
get powerSelf(): Value | undefined {
|
||||
return this.powerProduction?.minus(this.powerDelivery)?.notNegative();
|
||||
}
|
||||
|
||||
get powerConsumption(): Value | undefined {
|
||||
return this.powerPurchase?.plus(this.powerProduction)?.minus(this.powerDelivery);
|
||||
}
|
||||
|
||||
get powerProducedPercent(): Value | undefined {
|
||||
return this.powerProduction?.percent(this.powerConsumption);
|
||||
}
|
||||
|
||||
get powerSelfPercent(): Value | undefined {
|
||||
return this.powerSelf?.percent(this.powerProduction);
|
||||
}
|
||||
|
||||
get powerPurchasePercent(): Value | undefined {
|
||||
return this.powerPurchase?.percent(this.powerConsumption);
|
||||
}
|
||||
|
||||
get powerDeliveryPercent(): Value | undefined {
|
||||
return this.powerDelivery?.percent(this.powerProduction);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import {Value} from "../../../series/value/Value";
|
||||
import {validateDate, validateList} from "../../../core/validators";
|
||||
import {WeatherHour} from "./WeatherHour";
|
||||
|
||||
export class WeatherDay {
|
||||
|
||||
constructor(
|
||||
readonly date: Date,
|
||||
readonly hours: WeatherHour[],
|
||||
readonly clouds: Value,
|
||||
readonly irradiation: Value,
|
||||
readonly precipitation: Value,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): WeatherDay {
|
||||
return new WeatherDay(
|
||||
validateDate(json['date']),
|
||||
validateList(json['hours'], hour => WeatherHour.fromJson(hour, locale)),
|
||||
Value.fromJson2(json['clouds'], locale),
|
||||
Value.fromJson2(json['irradiation'], locale),
|
||||
Value.fromJson2(json['precipitation'], locale),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import {Value} from '../../../series/value/Value';
|
||||
import {validateDate} from '../../../core/validators';
|
||||
|
||||
export class WeatherHour {
|
||||
|
||||
constructor(
|
||||
readonly date: Date,
|
||||
readonly clouds: Value,
|
||||
readonly irradiation: Value,
|
||||
readonly precipitation: Value,
|
||||
readonly temperature: Value,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): WeatherHour {
|
||||
return new WeatherHour(
|
||||
validateDate(json['date']),
|
||||
Value.fromJson2(json['clouds'], locale),
|
||||
Value.fromJson2(json['irradiation'], locale),
|
||||
Value.fromJson2(json['precipitation'], locale),
|
||||
Value.fromJson2(json['temperature'], locale),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<app-tile [padding]="false">
|
||||
|
||||
<div tile-body>
|
||||
<div class="day">
|
||||
<div class="hour" *ngFor="let hour of hours">
|
||||
<div class="bar weekdayHolder" *ngIf="hour.date.getHours() === 8">
|
||||
{{ dateFormat(hour.date | date:'E') }}
|
||||
</div>
|
||||
<div class="bar clouds" [style.height]="clouds(hour)"></div>
|
||||
<div class="bar irradiation" [style.height]="irradiation(hour)"></div>
|
||||
<div class="bar precipitation" [style.height]="precipitation(hour)"></div>
|
||||
<div class="bar temperature" [style.height]="temperature(hour)" [ngClass]="temperatureClasses(hour)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="line temperatureGTE30">≥30°C</span>
|
||||
<span class="line temperatureGTE20">≥20°C</span>
|
||||
<span class="line temperatureGTE10">≥10°C</span>
|
||||
<span class="line temperatureGT0">>0°C</span>
|
||||
<span class="line temperatureNegative">≤0°C</span>
|
||||
<span class="line"> </span>
|
||||
<span class="line">Niederschlag 100% = {{ PRECIPITATION_MAX_MM }}mm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</app-tile>
|
||||
@ -0,0 +1,98 @@
|
||||
.day {
|
||||
display: flex;
|
||||
height: 3em;
|
||||
background-color: #2d4255;
|
||||
|
||||
.hour {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%; // any width will do (flex cares about real with)
|
||||
overflow: visible;
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.clouds {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.irradiation {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
.precipitation {
|
||||
background-color: blue;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
border-top: 0.06em solid black;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.temperatureGTE30 {
|
||||
border-top-color: red;
|
||||
}
|
||||
|
||||
.temperatureGTE20 {
|
||||
border-top-color: orange;
|
||||
}
|
||||
|
||||
.temperatureGTE10 {
|
||||
border-top-color: yellow;
|
||||
}
|
||||
|
||||
.temperatureGT0 {
|
||||
border-top-color: blue;
|
||||
}
|
||||
|
||||
.temperatureNegative {
|
||||
border-top-color: white;
|
||||
}
|
||||
|
||||
.weekdayHolder {
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
.line {
|
||||
padding: 0 0.25em;
|
||||
font-size: 50%;
|
||||
}
|
||||
|
||||
.temperatureGTE30 {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.temperatureGTE20 {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.temperatureGTE10 {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.temperatureGT0 {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.temperatureNegative {
|
||||
color: white;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {DatePipe, NgClass, NgForOf, NgIf} from '@angular/common';
|
||||
import {WeatherHour} from './WeatherHour';
|
||||
import {WeatherService} from './weather.service';
|
||||
import {WeatherDay} from './WeatherDay';
|
||||
import {TileComponent} from '../../../shared/tile/tile.component';
|
||||
|
||||
const PAST_HOURS_COUNT = 0;
|
||||
|
||||
const DAY_COUNT = 7;
|
||||
|
||||
@Component({
|
||||
selector: 'app-weather-diagram',
|
||||
imports: [
|
||||
NgForOf,
|
||||
NgIf,
|
||||
DatePipe,
|
||||
NgClass,
|
||||
TileComponent
|
||||
],
|
||||
templateUrl: './weather-diagram.component.html',
|
||||
styleUrl: './weather-diagram.component.less'
|
||||
})
|
||||
export class WeatherDiagramComponent implements OnInit {
|
||||
|
||||
protected readonly PRECIPITATION_MAX_MM = 15;
|
||||
|
||||
protected days: WeatherDay[] = [];
|
||||
|
||||
protected hours: WeatherHour[] = [];
|
||||
|
||||
constructor(
|
||||
readonly weatherService: WeatherService,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.weatherService.all(all => {
|
||||
this.days = all;
|
||||
this.updateHours();
|
||||
})
|
||||
}
|
||||
|
||||
clouds(hour: WeatherHour): string {
|
||||
return (hour.clouds?.value || 0) + '%';
|
||||
}
|
||||
|
||||
irradiation(hour: WeatherHour): string {
|
||||
return (hour.irradiation.percent(1000)?.value || 0) + '%';
|
||||
}
|
||||
|
||||
precipitation(hour: WeatherHour) {
|
||||
return (hour.precipitation.percent(this.PRECIPITATION_MAX_MM)?.value || 0) + '%';
|
||||
}
|
||||
|
||||
temperature(hour: WeatherHour) {
|
||||
return (hour.temperature.plus(10)?.percent(50)?.value || 0) + '%';
|
||||
}
|
||||
|
||||
private updateHours() {
|
||||
const nowHour = new Date();
|
||||
nowHour.setMinutes(0);
|
||||
nowHour.setSeconds(0);
|
||||
nowHour.setMilliseconds(0);
|
||||
|
||||
const firstHour = new Date(nowHour);
|
||||
firstHour.setHours(firstHour.getHours() - PAST_HOURS_COUNT);
|
||||
|
||||
const endHour = new Date(firstHour);
|
||||
endHour.setHours(endHour.getHours() + 24 * DAY_COUNT);
|
||||
|
||||
this.hours = [];
|
||||
const currentHour = new Date(firstHour);
|
||||
for (const day of this.days) {
|
||||
for (const hour of day.hours) {
|
||||
if (hour.date.getTime() === currentHour.getTime()) {
|
||||
this.hours.push(hour);
|
||||
currentHour.setHours(currentHour.getHours() + 1);
|
||||
if (currentHour.getTime() >= endHour.getTime()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dateFormat(date: string | null) {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
return date.substring(0, 2);
|
||||
}
|
||||
|
||||
temperatureClasses(hour: WeatherHour): {} {
|
||||
const temperatureGTE30 = hour.temperature.gte(30);
|
||||
const temperatureGTE20 = hour.temperature.gte(20);
|
||||
const temperatureGTE10 = hour.temperature.gte(10);
|
||||
const temperatureGT0 = hour.temperature.gt(0);
|
||||
const temperatureNegative = hour.temperature.lte(0);
|
||||
return {
|
||||
"temperatureGTE30": temperatureGTE30,
|
||||
"temperatureGTE20": temperatureGTE20 && !temperatureGTE30,
|
||||
"temperatureGTE10": temperatureGTE10 && !temperatureGTE20,
|
||||
"temperatureGT0": temperatureGT0 && !temperatureGTE10,
|
||||
"temperatureNegative": temperatureNegative
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
|
||||
import {ApiService} from '../../../core/api.service';
|
||||
import {Next} from '../../../core/types';
|
||||
import {WeatherDay} from './WeatherDay';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WeatherService {
|
||||
|
||||
constructor(
|
||||
readonly api: ApiService,
|
||||
@Inject(LOCALE_ID) readonly locale: string,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
all(next: Next<WeatherDay[]>) {
|
||||
return this.api.getList(['Weather', 'all'], json => WeatherDay.fromJson(json, this.locale), next);
|
||||
}
|
||||
|
||||
}
|
||||
11
src/main/angular/src/app/series/Aggregate.ts
Normal file
11
src/main/angular/src/app/series/Aggregate.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import {Series} from './Series';
|
||||
|
||||
export abstract class Aggregate {
|
||||
|
||||
protected constructor(
|
||||
readonly series: Series,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
}
|
||||
68
src/main/angular/src/app/series/AggregationWrapperDto.ts
Normal file
68
src/main/angular/src/app/series/AggregationWrapperDto.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {Alignment} from "./Alignment";
|
||||
|
||||
import {MeterAggregate} from './meter/MeterAggregate';
|
||||
import {Aggregate} from './Aggregate';
|
||||
import {VaryingAggregate} from './varying/VaryingAggregate';
|
||||
|
||||
import {Value} from './value/Value';
|
||||
|
||||
export class AggregationWrapperDto {
|
||||
|
||||
static readonly EMPTY: AggregationWrapperDto = new AggregationWrapperDto(Alignment.DAY, new Date(), []);
|
||||
|
||||
readonly energyProduced: MeterAggregate | undefined;
|
||||
|
||||
readonly energyPurchased: MeterAggregate | undefined;
|
||||
|
||||
readonly energyDelivered: MeterAggregate | undefined;
|
||||
|
||||
readonly energyConsumed: Value | undefined;
|
||||
|
||||
readonly energySelf: Value | undefined;
|
||||
|
||||
get energyPurchasedPercent(): Value | undefined {
|
||||
return this.energyPurchased?.delta.percent(this.energyConsumed);
|
||||
}
|
||||
|
||||
get energyProducedPercent(): Value | undefined {
|
||||
return this.energyProduced?.delta.percent(this.energyConsumed);
|
||||
}
|
||||
|
||||
get energySelfPercent(): Value | undefined {
|
||||
return this.energySelf?.percent(this.energyProduced?.delta);
|
||||
}
|
||||
|
||||
get energyDeliveredPercent(): Value | undefined {
|
||||
return this.energyDelivered?.delta.percent(this.energyProduced?.delta);
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly alignment: Alignment,
|
||||
readonly date: Date,
|
||||
readonly aggregations: Aggregate[],
|
||||
) {
|
||||
this.energyProduced = this.getMeter('energy/produced');
|
||||
this.energyPurchased = this.getMeter('energy/purchased');
|
||||
this.energyDelivered = this.getMeter('energy/delivered');
|
||||
this.energyConsumed = this.energyPurchased?.delta.plus(this.energyProduced?.delta)?.minus(this.energyDelivered?.delta);
|
||||
this.energySelf = this.energyProduced?.delta.minus(this.energyDelivered?.delta);
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): AggregationWrapperDto {
|
||||
return new AggregationWrapperDto(
|
||||
json['alignment'] as Alignment,
|
||||
new Date(json['date']),
|
||||
(json['aggregations'] as any[]).map(a => a.hasOwnProperty('delta') ? MeterAggregate.fromJson(a, locale) : VaryingAggregate.fromJson(a, locale)),
|
||||
);
|
||||
}
|
||||
|
||||
private getMeter(name: string): MeterAggregate | undefined {
|
||||
return this.aggregations.filter(a => a instanceof MeterAggregate && a.series.name === name)[0] as MeterAggregate;
|
||||
}
|
||||
|
||||
// noinspection JSUnusedLocalSymbols
|
||||
private getVarying(name: string): VaryingAggregate | undefined {
|
||||
return this.aggregations.filter(a => a instanceof VaryingAggregate && a.series.name === name)[0] as VaryingAggregate;
|
||||
}
|
||||
|
||||
}
|
||||
148
src/main/angular/src/app/series/Alignment.ts
Normal file
148
src/main/angular/src/app/series/Alignment.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import {formatDate} from '@angular/common';
|
||||
|
||||
export class Alignment {
|
||||
|
||||
private static readonly values: Alignment[] = [];
|
||||
|
||||
static readonly FIVE = new Alignment('FIVE', '5 Minuten', '', Alignment.offsetTitleFive, null, 0);
|
||||
|
||||
static readonly HOUR = new Alignment('HOUR', 'Stunde', 'n', Alignment.offsetTitleHour, Alignment.FIVE, 12);
|
||||
|
||||
static readonly DAY = new Alignment('DAY', 'Tag', 'e', Alignment.offsetTitleDay, Alignment.FIVE, 24 * 12);
|
||||
|
||||
static readonly WEEK = new Alignment('WEEK', 'Woche', 'n', Alignment.offsetTitleWeek, Alignment.HOUR, 7 * 24);
|
||||
|
||||
static readonly MONTH = new Alignment('MONTH', 'Monat', 'e', Alignment.offsetTitleMonth, Alignment.HOUR, 30 * 24);
|
||||
|
||||
static readonly YEAR = new Alignment('YEAR', 'Jahr', 'e', Alignment.offsetTitleYear, Alignment.DAY, 365);
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly display: string,
|
||||
readonly plural: string,
|
||||
readonly offsetTitle: (offset: number, locale: string) => string,
|
||||
readonly inner: Alignment | null,
|
||||
readonly innerCount: number,
|
||||
) {
|
||||
Alignment.values.push(this);
|
||||
}
|
||||
|
||||
plus(delta: number) {
|
||||
let index = Alignment.values.indexOf(this) + delta;
|
||||
while (index < 0) {
|
||||
index += Alignment.values.length;
|
||||
}
|
||||
return Alignment.values[index % Alignment.values.length];
|
||||
}
|
||||
|
||||
static offsetTitleFive(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Diese 5 Minuten";
|
||||
} else if (offset === 1) {
|
||||
return "Letzte 5 Minuten";
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setMinutes(today.getMinutes() - today.getMinutes() % 5);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const yesterday2 = new Date(today);
|
||||
yesterday2.setDate(yesterday2.getDate() - 1);
|
||||
|
||||
const date = new Date(today);
|
||||
date.setMinutes(date.getMinutes() - offset * 5);
|
||||
if (date.getDay() === today.getDay()) {
|
||||
return `${formatDate(date, "HH:mm", locale)}`;
|
||||
} else if (date.getDay() === yesterday.getDay()) {
|
||||
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
|
||||
} else if (date.getDay() === yesterday2.getDay()) {
|
||||
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
|
||||
}
|
||||
return `${formatDate(date, "EE", locale)} ${formatDate(date, "dd.MM.yyyy", locale)} ${formatDate(date, "HH:mm", locale)}`;
|
||||
}
|
||||
|
||||
static offsetTitleHour(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Diese Stunde";
|
||||
} else if (offset === 1) {
|
||||
return "Letzte Stunde";
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setMinutes(0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const yesterday2 = new Date(today);
|
||||
yesterday2.setDate(yesterday2.getDate() - 1);
|
||||
|
||||
const date = new Date(today);
|
||||
date.setHours(date.getHours() - offset);
|
||||
if (date.getDay() === today.getDay()) {
|
||||
return `${formatDate(date, "HH:mm", locale)}`;
|
||||
} else if (date.getDay() === yesterday.getDay()) {
|
||||
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
|
||||
} else if (date.getDay() === yesterday2.getDay()) {
|
||||
return `Gestern ${formatDate(date, "HH:mm", locale)}`;
|
||||
}
|
||||
return `${formatDate(date, "EE", locale)} ${formatDate(date, "dd.MM.yyyy", locale)} ${formatDate(date, "HH:mm", locale)}`;
|
||||
}
|
||||
|
||||
static offsetTitleDay(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Heute";
|
||||
} else if (offset === 1) {
|
||||
return "Gestern";
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - offset);
|
||||
if (offset < 7) {
|
||||
return `${formatDate(date, "EEEE", locale)}`;
|
||||
}
|
||||
return `${formatDate(date, "EE", locale)} ${formatDate(date, "dd.MM.yyyy", locale)}`;
|
||||
}
|
||||
|
||||
static offsetTitleWeek(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Diese Woche";
|
||||
} else if (offset === 1) {
|
||||
return "Letzte Woche";
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - date.getDay() + 1 - offset * 7);
|
||||
return `${formatDate(date, "yyyy", locale)} KW${formatDate(date, "ww", locale)}`;
|
||||
}
|
||||
|
||||
static offsetTitleMonth(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Diesen Monat";
|
||||
} else if (offset === 1) {
|
||||
return "Letzte Monat";
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setMonth(date.getMonth() - offset);
|
||||
return `${formatDate(date, "MMMM", locale)} ${formatDate(date, "yyyy", locale)}`;
|
||||
}
|
||||
|
||||
static offsetTitleYear(offset: number, locale: string): string {
|
||||
if (offset === 0) {
|
||||
return "Dieses Jahr";
|
||||
} else if (offset === 1) {
|
||||
return "Letztes Jahr";
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setMonth(1);
|
||||
date.setFullYear(date.getFullYear() - offset);
|
||||
return `${formatDate(date, "yyyy", locale)}`;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
}
|
||||
31
src/main/angular/src/app/series/Series.ts
Normal file
31
src/main/angular/src/app/series/Series.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {Unit} from './value/Unit';
|
||||
import {validateNumber, validateString} from '../core/validators';
|
||||
import {Value} from './value/Value';
|
||||
|
||||
export class Series {
|
||||
|
||||
constructor(
|
||||
readonly id: number,
|
||||
readonly name: string,
|
||||
readonly title: string,
|
||||
readonly decimals: number,
|
||||
readonly unit: Unit,
|
||||
readonly lastValue: Value,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): Series {
|
||||
const decimals = validateNumber(json['decimals']);
|
||||
const unit = Unit.fromJson(json['unit']);
|
||||
return new Series(
|
||||
json['id'] as number,
|
||||
validateString(json['name']),
|
||||
validateString(json['title']),
|
||||
decimals,
|
||||
unit,
|
||||
Value.fromJson(json['lastValue'], unit, decimals, locale),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
39
src/main/angular/src/app/series/SeriesWrapper.ts
Normal file
39
src/main/angular/src/app/series/SeriesWrapper.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {Series} from "./Series";
|
||||
import {Subscription} from "rxjs";
|
||||
import {Next} from '../core/types';
|
||||
|
||||
export class SeriesWrapper {
|
||||
|
||||
private _series: Series | null = null;
|
||||
|
||||
private readonly nextList: Next<Series | null>[] = [];
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly onSubscribe: (subscription: Subscription) => Subscription,
|
||||
private readonly onUnsubscribe: (subscription: Subscription) => Subscription,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
get series(): Series | null {
|
||||
return this._series;
|
||||
}
|
||||
|
||||
set series(series: Series | null) {
|
||||
this._series = series;
|
||||
this.nextList.forEach(next => next(series))
|
||||
}
|
||||
|
||||
subscribe(next: Next<Series | null>): Subscription {
|
||||
const wrapper: Next<Series | null> = series => next(series); // to let nextList only contain unique instances
|
||||
this.nextList.push(wrapper);
|
||||
wrapper(this.series);
|
||||
const subscription = new Subscription(() => {
|
||||
this.nextList.splice(this.nextList.indexOf(wrapper), 1);
|
||||
this.onUnsubscribe(subscription);
|
||||
});
|
||||
return this.onSubscribe(subscription);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/angular/src/app/series/meter/MeterAggregate.ts
Normal file
23
src/main/angular/src/app/series/meter/MeterAggregate.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {Series} from '../Series';
|
||||
import {Aggregate} from '../Aggregate';
|
||||
import {Value} from '../value/Value';
|
||||
|
||||
export class MeterAggregate extends Aggregate {
|
||||
|
||||
constructor(
|
||||
series: Series,
|
||||
readonly delta: Value,
|
||||
) {
|
||||
super(series);
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): MeterAggregate {
|
||||
const series = Series.fromJson(json['series'], locale);
|
||||
return new MeterAggregate(
|
||||
series,
|
||||
Value.fromJson(json['delta'], series.unit, series.decimals, locale),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
68
src/main/angular/src/app/series/series.service.ts
Normal file
68
src/main/angular/src/app/series/series.service.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {Inject, Injectable, LOCALE_ID} from '@angular/core';
|
||||
import {ApiService} from '../core/api.service';
|
||||
import {Alignment} from './Alignment';
|
||||
import {AggregationWrapperDto} from './AggregationWrapperDto';
|
||||
import {Series} from './Series';
|
||||
import {SeriesWrapper} from './SeriesWrapper';
|
||||
import {Next} from '../core/types';
|
||||
import {AbstractRepositoryService} from '../core/AbstractRepositoryService';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SeriesService extends AbstractRepositoryService {
|
||||
|
||||
readonly powerConsumed: SeriesWrapper = new SeriesWrapper("power/consumed", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly powerProduced: SeriesWrapper = new SeriesWrapper("power/produced", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly powerSelf: SeriesWrapper = new SeriesWrapper("power/self", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly powerBalance: SeriesWrapper = new SeriesWrapper("power/balance", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly greenhouseTemperature: SeriesWrapper = new SeriesWrapper("greenhouse/temperature", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly greenhouseHumidityRelative: SeriesWrapper = new SeriesWrapper("greenhouse/humidity/relative", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly greenhouseHumidityAbsolute: SeriesWrapper = new SeriesWrapper("greenhouse/humidity/absolute", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly greenhouseIlluminance: SeriesWrapper = new SeriesWrapper("greenhouse/illuminance", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly cisternHeight: SeriesWrapper = new SeriesWrapper("cistern/height", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
readonly cisternVolume: SeriesWrapper = new SeriesWrapper("cistern/volume", this.onSubscribe, this.onUnsubscribe);
|
||||
|
||||
protected get liveValues(): SeriesWrapper[] {
|
||||
return [
|
||||
this.powerConsumed,
|
||||
this.powerProduced,
|
||||
this.powerSelf,
|
||||
this.powerBalance,
|
||||
this.greenhouseTemperature,
|
||||
this.greenhouseHumidityRelative,
|
||||
this.greenhouseHumidityAbsolute,
|
||||
this.greenhouseIlluminance,
|
||||
this.cisternHeight,
|
||||
this.cisternVolume,
|
||||
];
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) locale: string,
|
||||
api: ApiService,
|
||||
) {
|
||||
super(locale, api);
|
||||
}
|
||||
|
||||
aggregations(alignment: Alignment, offset: number, next: Next<AggregationWrapperDto>) {
|
||||
this.api.getSingle(['Series', 'agg', 'all', alignment.name, 'offset', offset], j => AggregationWrapperDto.fromJson(j, this.locale), next);
|
||||
}
|
||||
|
||||
graph(series: Series, width: number, height: number, alignment: Alignment, offset: number, innerAlignment: Alignment | null, count: number): any {
|
||||
if (innerAlignment === null) {
|
||||
return null;
|
||||
}
|
||||
return ApiService.url('http', ['Series', 'Graph', series.id, width, height, alignment, offset, count, innerAlignment]);
|
||||
}
|
||||
|
||||
}
|
||||
55
src/main/angular/src/app/series/value/Unit.ts
Normal file
55
src/main/angular/src/app/series/value/Unit.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {validateString} from '../../core/validators';
|
||||
|
||||
export class Unit {
|
||||
|
||||
private static readonly values: Unit[] = [];
|
||||
|
||||
static readonly _UNKNOWN_ = new Unit('_UNKNOWN_', "?");
|
||||
|
||||
static readonly POWER_W = new Unit('POWER_W', "W");
|
||||
|
||||
static readonly ENERGY_KWH = new Unit('ENERGY_KWH', "kWh");
|
||||
|
||||
static readonly PERCENT = new Unit('PERCENT', "%");
|
||||
|
||||
static readonly CLOUD_COVER_PERCENT = new Unit('CLOUD_COVER_PERCENT', '%');
|
||||
|
||||
static readonly IRRADIATION_WH_M2 = new Unit('IRRADIATION_WH_M2', 'Wh/m²');
|
||||
|
||||
static readonly IRRADIATION_KWH_M2 = new Unit('IRRADIATION_KWH_M2', 'kWh/m²');
|
||||
|
||||
static readonly PRECIPITATION_MM = new Unit('PRECIPITATION_MM', 'mm');
|
||||
|
||||
static readonly TEMPERATURE_C = new Unit('TEMPERATURE_C', '°C');
|
||||
|
||||
static readonly HUMIDITY_RELATIVE_PERCENT = new Unit('HUMIDITY_RELATIVE_PERCENT', '%');
|
||||
|
||||
static readonly HUMIDITY_ABSOLUTE_GM3 = new Unit('HUMIDITY_ABSOLUTE_GM3', 'g/m³');
|
||||
|
||||
static readonly ILLUMINANCE_LUX = new Unit('ILLUMINANCE_LUX', 'lux');
|
||||
|
||||
static readonly BOOLEAN = new Unit('BOOLEAN', '');
|
||||
|
||||
static readonly DOOR_BOOLEAN = new Unit('DOOR_BOOLEAN', '');
|
||||
|
||||
static readonly WINDOW_BOOLEAN = new Unit('WINDOW_BOOLEAN', '');
|
||||
|
||||
static readonly LIGHT_BOOLEAN = new Unit('LIGHT_BOOLEAN', '');
|
||||
|
||||
static readonly VOLUME_L = new Unit('VOLUME_L', 'l');
|
||||
|
||||
static readonly LENGTH_CM = new Unit('LENGTH_CM', 'cm');
|
||||
|
||||
private constructor(
|
||||
readonly name: string,
|
||||
readonly unit: string,
|
||||
) {
|
||||
Unit.values.push(this);
|
||||
}
|
||||
|
||||
static fromJson(json: any): Unit {
|
||||
const name: string = validateString(json);
|
||||
return this.values.filter(unit => unit.name === name)[0] || this._UNKNOWN_;
|
||||
}
|
||||
|
||||
}
|
||||
119
src/main/angular/src/app/series/value/Value.ts
Normal file
119
src/main/angular/src/app/series/value/Value.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import {Unit} from "./Unit";
|
||||
import {validateNumber} from "../../core/validators";
|
||||
|
||||
export class Value {
|
||||
|
||||
readonly localeString: string;
|
||||
|
||||
readonly valueInteger: string;
|
||||
|
||||
readonly valueFraction: string;
|
||||
|
||||
constructor(
|
||||
readonly value: number,
|
||||
readonly unit: Unit,
|
||||
readonly decimals: number,
|
||||
readonly locale: string,
|
||||
) {
|
||||
this.localeString = this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals});
|
||||
this.valueInteger = this.localeString.split(/[,.]/)[0];
|
||||
this.valueFraction = this.localeString.split(/[,.]/)[1];
|
||||
}
|
||||
|
||||
static fromJson2(json: any, locale: string): Value {
|
||||
return new Value(
|
||||
validateNumber(json['value']),
|
||||
Unit.fromJson(json['unit']),
|
||||
validateNumber(json['decimals']),
|
||||
locale
|
||||
);
|
||||
}
|
||||
|
||||
static fromJson(value: any, unit: Unit, decimals: number, locale: string): Value {
|
||||
return new Value(
|
||||
validateNumber(value),
|
||||
unit,
|
||||
decimals,
|
||||
locale
|
||||
);
|
||||
}
|
||||
|
||||
get zero(): boolean {
|
||||
return this.value === 0;
|
||||
}
|
||||
|
||||
get formatted(): string {
|
||||
return `${(this.localeString)} ${this.unit.unit}`;
|
||||
}
|
||||
|
||||
get formatted2(): string {
|
||||
return `${(this.localeString)}${this.unit.unit}`;
|
||||
}
|
||||
|
||||
negate() {
|
||||
return new Value(-this.value, this.unit, this.decimals, this.locale);
|
||||
}
|
||||
|
||||
plus(other: Value | number | undefined | null): Value | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : new Value(this.value + v, this.unit, this.decimals, this.locale);
|
||||
}
|
||||
|
||||
minus(other: Value | number | undefined | null): Value | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : new Value(this.value - v, this.unit, this.decimals, this.locale);
|
||||
}
|
||||
|
||||
gte(other: Value | number | undefined | null): boolean | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : this.value >= v;
|
||||
}
|
||||
|
||||
gt(other: Value | number | undefined | null): boolean | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : this.value > v;
|
||||
}
|
||||
|
||||
lte(other: Value | number | undefined | null): boolean | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : this.value <= v;
|
||||
}
|
||||
|
||||
lt(other: Value | number | undefined | null): boolean | undefined {
|
||||
const v = this.extractValue(other);
|
||||
return v === undefined ? undefined : this.value < v;
|
||||
}
|
||||
|
||||
extractValue(other: Value | number | undefined | null): number | undefined {
|
||||
if (other === undefined || other === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (other instanceof Value && other.unit !== this.unit) {
|
||||
throw new Error(`Unit mismatch: this=${JSON.stringify(this)}, other=${JSON.stringify(other)}`);
|
||||
}
|
||||
return typeof other === "number" ? other : other.value;
|
||||
}
|
||||
|
||||
notNegative(): Value {
|
||||
if (this.value < 0) {
|
||||
return this.toZero();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
toZero(): Value {
|
||||
if (this.value === 0) {
|
||||
return this;
|
||||
}
|
||||
return new Value(0, this.unit, this.decimals, this.locale);
|
||||
}
|
||||
|
||||
percent(other: Value | number | undefined): Value | undefined {
|
||||
const v = other instanceof Value ? other.value : typeof other === "number" ? other : 0;
|
||||
if (v === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return new Value(this.value / v * 100, Unit.PERCENT, 0, this.locale);
|
||||
}
|
||||
|
||||
}
|
||||
24
src/main/angular/src/app/series/varying/VaryingAggregate.ts
Normal file
24
src/main/angular/src/app/series/varying/VaryingAggregate.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import {Aggregate} from "../Aggregate";
|
||||
import {Series} from "../Series";
|
||||
|
||||
export class VaryingAggregate extends Aggregate {
|
||||
|
||||
constructor(
|
||||
series: Series,
|
||||
readonly min: number,
|
||||
readonly avg: number,
|
||||
readonly max: number,
|
||||
) {
|
||||
super(series);
|
||||
}
|
||||
|
||||
static fromJson(json: any, locale: string): VaryingAggregate {
|
||||
return new VaryingAggregate(
|
||||
Series.fromJson(json['series'], locale),
|
||||
json['min'] as number,
|
||||
json['avg'] as number,
|
||||
json['max'] as number,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<div class="bar">
|
||||
<div class="part purchase" *ngIf="barPurchasePercent" [style.width]="barPurchasePercent.formatted2">
|
||||
<div class="text">
|
||||
{{ _purchase?.formatted }}<br>
|
||||
{{ purchasePercent?.formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="part self" *ngIf="barSelfPercent" [style.width]="barSelfPercent.formatted2">
|
||||
<div class="text">
|
||||
{{ _self?.formatted }}<br>
|
||||
{{ selfPercent?.formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="part delivery" *ngIf="barDeliveryPercent" [style.width]="barDeliveryPercent.formatted2">
|
||||
<div class="text">
|
||||
{{ _delivery?.formatted }}<br>
|
||||
{{ deliveryPercent?.formatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
@import "../../../../colors.less";
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
border-radius: 0.5em;
|
||||
|
||||
.part {
|
||||
white-space: nowrap;
|
||||
font-size: 40%;
|
||||
|
||||
.text {
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.part:first-child {
|
||||
.text:first-child {
|
||||
padding-left: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.self {
|
||||
background-color: @selfBack;
|
||||
}
|
||||
|
||||
.purchase {
|
||||
background-color: @purchaseBack;
|
||||
}
|
||||
|
||||
.delivery {
|
||||
background-color: @deliveryBack;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Value} from '../../series/value/Value';
|
||||
import {NgIf} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-percent-bar',
|
||||
imports: [
|
||||
NgIf
|
||||
],
|
||||
templateUrl: './percent-bar.component.html',
|
||||
styleUrl: './percent-bar.component.less'
|
||||
})
|
||||
export class PercentBarComponent {
|
||||
|
||||
protected _produktion: Value | undefined;
|
||||
|
||||
@Input()
|
||||
set produktion(produktion: Value | undefined) {
|
||||
this._produktion = produktion;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected _self: Value | undefined;
|
||||
|
||||
@Input()
|
||||
set self(self: Value | undefined) {
|
||||
this._self = self;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected _purchase: Value | undefined;
|
||||
|
||||
@Input()
|
||||
set purchase(purchase: Value | undefined) {
|
||||
this._purchase = purchase;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected _delivery: Value | undefined;
|
||||
|
||||
@Input()
|
||||
set delivery(delivery: Value | undefined) {
|
||||
this._delivery = delivery;
|
||||
this.update();
|
||||
}
|
||||
|
||||
@Input()
|
||||
percent: boolean = false;
|
||||
|
||||
protected consumption: Value | undefined;
|
||||
|
||||
protected barSum: Value | undefined;
|
||||
|
||||
protected selfPercent: Value | undefined;
|
||||
|
||||
protected purchasePercent: Value | undefined;
|
||||
|
||||
protected deliveryPercent: Value | undefined;
|
||||
|
||||
protected barSelfPercent: Value | undefined;
|
||||
|
||||
protected barPurchasePercent: Value | undefined;
|
||||
|
||||
protected barDeliveryPercent: Value | undefined;
|
||||
|
||||
private update() {
|
||||
this.consumption = this._self?.plus(this._purchase);
|
||||
this.selfPercent = this._self?.percent(this.consumption);
|
||||
this.purchasePercent = this._purchase?.percent(this.consumption);
|
||||
this.deliveryPercent = this._delivery?.percent(this._produktion);
|
||||
|
||||
this.barSum = this.consumption?.plus(this._delivery);
|
||||
this.barSelfPercent = this._self?.percent(this.barSum);
|
||||
this.barPurchasePercent = this._purchase?.percent(this.barSum);
|
||||
this.barDeliveryPercent = this._delivery?.percent(this.barSum);
|
||||
}
|
||||
|
||||
}
|
||||
10
src/main/angular/src/app/shared/tile/tile.component.html
Normal file
10
src/main/angular/src/app/shared/tile/tile.component.html
Normal file
@ -0,0 +1,10 @@
|
||||
<div class="tile">
|
||||
<div class="tileInner" [class.tilePadding]="padding">
|
||||
<div class="tileHead">
|
||||
<ng-content select="[tile-head]"></ng-content>
|
||||
</div>
|
||||
<div class="tileBody">
|
||||
<ng-content select="[tile-body]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
src/main/angular/src/app/shared/tile/tile.component.less
Normal file
16
src/main/angular/src/app/shared/tile/tile.component.less
Normal file
@ -0,0 +1,16 @@
|
||||
.tile {
|
||||
float: left;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.tilePadding {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.tileHead {
|
||||
font-size: 70%;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
text-decoration: underline;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
14
src/main/angular/src/app/shared/tile/tile.component.ts
Normal file
14
src/main/angular/src/app/shared/tile/tile.component.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tile',
|
||||
imports: [],
|
||||
templateUrl: './tile.component.html',
|
||||
styleUrl: './tile.component.less'
|
||||
})
|
||||
export class TileComponent {
|
||||
|
||||
@Input()
|
||||
padding: boolean = true;
|
||||
|
||||
}
|
||||
14
src/main/angular/src/index.html
Normal file
14
src/main/angular/src/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Data</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=yes">
|
||||
<!--suppress HtmlUnknownTarget -->
|
||||
<link rel="icon" type="image/x-icon" href="favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
src/main/angular/src/main.ts
Normal file
6
src/main/angular/src/main.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import {bootstrapApplication} from '@angular/platform-browser';
|
||||
import {appConfig} from './app/app.config';
|
||||
import {AppComponent} from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
85
src/main/angular/src/styles.less
Normal file
85
src/main/angular/src/styles.less
Normal file
@ -0,0 +1,85 @@
|
||||
@import "../colors.less";
|
||||
@import "../numberTable.less";
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-size: 6vw;
|
||||
user-select: none;
|
||||
background-color: @background;
|
||||
color: @foreground;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background-color: transparent;
|
||||
font-size: inherit;
|
||||
color: @FONT_SELECTABLE;
|
||||
border: 0.05em solid gray;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
font-size: inherit;
|
||||
padding: 0 0.25em;
|
||||
background-color: #002433;
|
||||
color: #008fca;
|
||||
border: unset;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table.vertical {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td.valueInteger {
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td.valueDelimiter {
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td.valueFraction {
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td.unit {
|
||||
width: 0;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@tile-width: 400px;
|
||||
@font-base: 6vw;
|
||||
|
||||
.generate-tiles(@i, @max) when (@i =< @max) {
|
||||
@media (min-width: @tile-width * @i) {
|
||||
body {
|
||||
font-size: calc(@font-base / @i);
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: calc(100% / @i);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-tiles(@i + 1, @max);
|
||||
}
|
||||
|
||||
.generate-tiles(1, 10);
|
||||
15
src/main/angular/tsconfig.app.json
Normal file
15
src/main/angular/tsconfig.app.json
Normal file
@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
27
src/main/angular/tsconfig.json
Normal file
27
src/main/angular/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
src/main/angular/tsconfig.spec.json
Normal file
15
src/main/angular/tsconfig.spec.json
Normal file
@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
package de.ph87.data;
|
||||
|
||||
public enum Action {
|
||||
CREATED, CHANGED, DELETED
|
||||
CREATED, CHANGED, STATE, DELETED
|
||||
}
|
||||
|
||||
26
src/main/java/de/ph87/data/chart/Chart.java
Normal file
26
src/main/java/de/ph87/data/chart/Chart.java
Normal file
@ -0,0 +1,26 @@
|
||||
package de.ph87.data.chart;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
public class Chart {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private long id;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private String name = "";
|
||||
|
||||
}
|
||||
35
src/main/java/de/ph87/data/chart/axis/Axis.java
Normal file
35
src/main/java/de/ph87/data/chart/axis/Axis.java
Normal file
@ -0,0 +1,35 @@
|
||||
package de.ph87.data.chart.axis;
|
||||
|
||||
import de.ph87.data.chart.Chart;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
public class Axis {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private long id;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@NonNull
|
||||
@ManyToOne(optional = false)
|
||||
private Chart chart;
|
||||
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private String name = "";
|
||||
|
||||
public Axis(@NonNull final Chart chart) {
|
||||
this.chart = chart;
|
||||
}
|
||||
|
||||
}
|
||||
41
src/main/java/de/ph87/data/chart/axis/graph/Graph.java
Normal file
41
src/main/java/de/ph87/data/chart/axis/graph/Graph.java
Normal file
@ -0,0 +1,41 @@
|
||||
package de.ph87.data.chart.axis.graph;
|
||||
|
||||
import de.ph87.data.chart.axis.Axis;
|
||||
import de.ph87.data.series.Series;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@ToString
|
||||
@NoArgsConstructor
|
||||
public class Graph {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private long id;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@NonNull
|
||||
@ManyToOne(optional = false)
|
||||
private Axis axis;
|
||||
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private String name = "";
|
||||
|
||||
@NonNull
|
||||
@ManyToOne(optional = false)
|
||||
private Series series;
|
||||
|
||||
public Graph(@NonNull final Axis axis, @NonNull final Series series) {
|
||||
this.axis = axis;
|
||||
this.series = series;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,17 +1,20 @@
|
||||
package de.ph87.data.series.graph;
|
||||
package de.ph87.data.graph;
|
||||
|
||||
import de.ph87.data.series.*;
|
||||
import de.ph87.data.value.*;
|
||||
import jakarta.annotation.*;
|
||||
import lombok.*;
|
||||
import de.ph87.data.point.Point;
|
||||
import de.ph87.data.point.PointRequest;
|
||||
import de.ph87.data.series.SeriesDto;
|
||||
import de.ph87.data.series.SeriesType;
|
||||
import de.ph87.data.value.Autoscale;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.geom.*;
|
||||
import java.awt.image.*;
|
||||
import java.awt.geom.Line2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.List;
|
||||
import java.util.function.*;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static java.lang.Math.*;
|
||||
import static java.lang.Math.max;
|
||||
|
||||
public class Graph {
|
||||
|
||||
@ -23,10 +26,7 @@ public class Graph {
|
||||
public final SeriesDto series;
|
||||
|
||||
@NonNull
|
||||
public final Aligned begin;
|
||||
|
||||
@NonNull
|
||||
public final Aligned end;
|
||||
public final PointRequest request;
|
||||
|
||||
public final int width;
|
||||
|
||||
@ -34,7 +34,7 @@ public class Graph {
|
||||
|
||||
public final int border;
|
||||
|
||||
public final List<Point> points;
|
||||
public final List<java.awt.Point> points;
|
||||
|
||||
public final long minuteMin;
|
||||
|
||||
@ -60,21 +60,20 @@ public class Graph {
|
||||
|
||||
public final int maxLabelWidth;
|
||||
|
||||
private final Autoscale autoscale;
|
||||
public final Autoscale autoscale;
|
||||
|
||||
public Graph(@NonNull final SeriesDto series, @NonNull final List<GraphPoint> points, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) {
|
||||
public Graph(@NonNull final SeriesDto series, @NonNull final List<Point> points, @NonNull final PointRequest request, final int width, final int height, final int border) {
|
||||
this.series = series;
|
||||
this.begin = begin;
|
||||
this.end = end;
|
||||
this.request = request;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.border = border;
|
||||
|
||||
// find bounds
|
||||
double vSum = 0;
|
||||
double vMin = series.isGraphZero() ? 0.0 : Double.MAX_VALUE;
|
||||
double vMax = series.isGraphZero() ? 0.0 : Double.MIN_VALUE;
|
||||
for (final GraphPoint point : points) {
|
||||
double vMin = series.getYMin() == null || Double.isNaN(series.getYMin()) ? Double.MIN_VALUE : series.getYMin();
|
||||
double vMax = series.getYMax() == null || Double.isNaN(series.getYMax()) ? Double.MAX_VALUE : series.getYMax();
|
||||
for (final Point point : points) {
|
||||
vMin = Math.min(vMin, point.getValue());
|
||||
vMax = max(vMax, point.getValue());
|
||||
vSum += point.getValue();
|
||||
@ -87,9 +86,9 @@ public class Graph {
|
||||
vSum *= autoscale.factor;
|
||||
|
||||
// find max label width
|
||||
int __maxLabelWidth = 80;
|
||||
int __maxLabelWidth = 0;
|
||||
final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics();
|
||||
for (final GraphPoint point : points) {
|
||||
for (final Point point : points) {
|
||||
__maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor)));
|
||||
}
|
||||
this.maxLabelWidth = __maxLabelWidth;
|
||||
@ -97,10 +96,10 @@ public class Graph {
|
||||
widthInner = width - 3 * border - this.maxLabelWidth;
|
||||
heightInner = height - 2 * border;
|
||||
|
||||
minuteMin = begin.date.toEpochSecond() / 60;
|
||||
minuteMax = end.date.toEpochSecond() / 60;
|
||||
minuteMin = request.begin.date.toEpochSecond() / 60;
|
||||
minuteMax = request.end.date.toEpochSecond() / 60;
|
||||
minuteRange = minuteMax - minuteMin;
|
||||
minuteScale = (double) widthInner / (minuteRange + begin.alignment.maxDuration.toMinutes());
|
||||
minuteScale = (double) widthInner / (minuteRange + request.inner.maxDuration.toMinutes());
|
||||
|
||||
valueMin = vMin;
|
||||
valueMax = vMax;
|
||||
@ -115,35 +114,29 @@ public class Graph {
|
||||
public BufferedImage draw() {
|
||||
final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
final Graphics2D g = (Graphics2D) image.getGraphics();
|
||||
final int fontH3_4 = (int) Math.round(g.getFontMetrics().getHeight() * 0.75);
|
||||
|
||||
g.setColor(Color.gray);
|
||||
final String string = "%s [%s]".formatted(series.getTitle(), autoscale.unit);
|
||||
g.drawString(string, border, border + fontH3_4);
|
||||
|
||||
yLabel(g, valueMax, DASHED, Color.red);
|
||||
yLabel(g, valueAvg, DASHED, new Color(0, 127, 0));
|
||||
yLabel(g, 0, NORMAL, Color.BLACK);
|
||||
yLabel(g, valueMin, DASHED, Color.blue);
|
||||
yLabel(g, valueMax, Color.red);
|
||||
yLabel(g, valueAvg, new Color(0, 255, 0));
|
||||
yLabel(g, valueMin, new Color(64, 128, 255));
|
||||
|
||||
g.translate(border, height - border);
|
||||
g.scale(1, -1);
|
||||
|
||||
// y-axis
|
||||
g.setStroke(NORMAL);
|
||||
g.setColor(Color.BLACK);
|
||||
g.drawLine(widthInner, 0, widthInner, heightInner); // y-axis
|
||||
g.setColor(Color.GRAY);
|
||||
g.drawLine(widthInner, 0, widthInner, heightInner);
|
||||
|
||||
g.setColor(Color.WHITE);
|
||||
if (series.type == SeriesType.METER) {
|
||||
g.setColor(Color.PINK);
|
||||
final int space = (int) (minuteScale * begin.alignment.maxDuration.toMinutes());
|
||||
final int space = (int) (minuteScale * request.inner.maxDuration.toMinutes());
|
||||
final int width = (int) (space * 0.95);
|
||||
for (final Point point : points) {
|
||||
for (final java.awt.Point point : points) {
|
||||
g.fillRect(point.x + (space - width), 0, width, point.y);
|
||||
}
|
||||
} else {
|
||||
g.setColor(Color.RED);
|
||||
Point last = null;
|
||||
for (final Point current : points) {
|
||||
java.awt.Point last = null;
|
||||
for (final java.awt.Point current : points) {
|
||||
if (last != null) {
|
||||
g.drawLine(last.x, last.y, current.x, current.y);
|
||||
}
|
||||
@ -153,20 +146,20 @@ public class Graph {
|
||||
return image;
|
||||
}
|
||||
|
||||
private void yLabel(@NonNull final Graphics2D g, final double value, @Nullable final Stroke stroke, @Nullable final Color color) {
|
||||
private void yLabel(@NonNull final Graphics2D g, final double value, @Nullable final Color color) {
|
||||
final String string = autoscale.format(value);
|
||||
final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string);
|
||||
final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border);
|
||||
g.setColor(color);
|
||||
g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25));
|
||||
if (stroke != null && color != null) {
|
||||
g.setStroke(stroke);
|
||||
if (color != null) {
|
||||
g.setStroke(Graph.DASHED);
|
||||
g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y));
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Function<GraphPoint, Point> toPoint() {
|
||||
private Function<Point, java.awt.Point> toPoint() {
|
||||
return point -> {
|
||||
final long minuteEpoch = point.getDate().toEpochSecond() / 60;
|
||||
final long minuteRelative = minuteEpoch - minuteMin;
|
||||
@ -177,7 +170,7 @@ public class Graph {
|
||||
final double valueScaled = valueRelative * valueScale;
|
||||
final int y = (int) Math.round(valueScaled);
|
||||
|
||||
return new Point(x, y);
|
||||
return new java.awt.Point(x, y);
|
||||
};
|
||||
}
|
||||
|
||||
49
src/main/java/de/ph87/data/graph/GraphController.java
Normal file
49
src/main/java/de/ph87/data/graph/GraphController.java
Normal file
@ -0,0 +1,49 @@
|
||||
package de.ph87.data.graph;
|
||||
|
||||
import de.ph87.data.point.Point;
|
||||
import de.ph87.data.point.PointRequest;
|
||||
import de.ph87.data.point.PointService;
|
||||
import de.ph87.data.series.Alignment;
|
||||
import de.ph87.data.series.Series;
|
||||
import de.ph87.data.series.SeriesDto;
|
||||
import de.ph87.data.series.SeriesRepository;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("Series/Graph")
|
||||
public class GraphController {
|
||||
|
||||
private final SeriesRepository seriesRepository;
|
||||
|
||||
private final PointService pointService;
|
||||
|
||||
@GetMapping(path = "{seriesId}/{width}/{height}/{outerName}/{offset}/{duration}/{innerName}", produces = "image/png")
|
||||
public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String outerName, @PathVariable final long offset, @PathVariable final long duration, @PathVariable final String innerName) throws IOException {
|
||||
final Alignment outer = Alignment.valueOf(outerName);
|
||||
final Alignment inner = Alignment.valueOf(innerName);
|
||||
final PointRequest request = new PointRequest(outer, offset, duration, inner);
|
||||
|
||||
final Series series = seriesRepository.findById(seriesId).orElseThrow();
|
||||
final List<Point> points = pointService.getPoints(series, request);
|
||||
final Graph graph = new Graph(new SeriesDto(series), points, request, width, height, 10);
|
||||
|
||||
final BufferedImage image = graph.draw();
|
||||
response.setContentType("image/png");
|
||||
ImageIO.write(image, "PNG", response.getOutputStream());
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
|
||||
}
|
||||
@ -18,37 +18,55 @@ import java.time.*;
|
||||
@RequiredArgsConstructor
|
||||
public class OpenDTUHandler implements IMessageHandler {
|
||||
|
||||
private static final String METER_NUMBER = "114190515175";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
public void handle(@NonNull final Message message) throws Exception {
|
||||
if (!"openDTU/pv/patrix/json".equals(message.topic)) {
|
||||
if (!"openDTU/pv/patrix/json2".equals(message.topic)) {
|
||||
return;
|
||||
}
|
||||
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class);
|
||||
applicationEventPublisher.publishEvent(new VaryingInbound("power/produced", inbound.date, inbound.power.as(Unit.POWER_W)));
|
||||
applicationEventPublisher.publishEvent(new MeterInbound("energy/produced", METER_NUMBER, inbound.date, inbound.energy));
|
||||
applicationEventPublisher.publishEvent(new VaryingInbound("power/produced", inbound.date, inbound.powerTotal.as(Unit.POWER_W)));
|
||||
applicationEventPublisher.publishEvent(new MeterInbound("energy/produced", inbound.inverter, inbound.date, inbound.energyTotal.as(Unit.ENERGY_KWH)));
|
||||
applicationEventPublisher.publishEvent(new VaryingInbound("power/produced/string0", inbound.date, inbound.powerString0.as(Unit.POWER_W)));
|
||||
applicationEventPublisher.publishEvent(new MeterInbound("energy/produced/string0", inbound.inverter, inbound.date, inbound.energyString0.as(Unit.ENERGY_KWH)));
|
||||
applicationEventPublisher.publishEvent(new VaryingInbound("power/produced/string1", inbound.date, inbound.powerString1.as(Unit.POWER_W)));
|
||||
applicationEventPublisher.publishEvent(new MeterInbound("energy/produced/string1", inbound.inverter, inbound.date, inbound.energyString1.as(Unit.ENERGY_KWH)));
|
||||
}
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public static class Inbound {
|
||||
|
||||
@NonNull
|
||||
public final String inverter;
|
||||
|
||||
@NonNull
|
||||
public final ZonedDateTime date;
|
||||
|
||||
public final Value energy;
|
||||
public final Value energyTotal;
|
||||
|
||||
public final Value power;
|
||||
public final Value powerTotal;
|
||||
|
||||
public Inbound(final long timestamp, final double energyProducedKWh, final double powerW) {
|
||||
public final Value energyString0;
|
||||
|
||||
public final Value powerString0;
|
||||
|
||||
public final Value energyString1;
|
||||
|
||||
public final Value powerString1;
|
||||
|
||||
public Inbound(final long timestamp, @NonNull final String inverter, final double totalKWh, final double totalW, final double string0KWh, final double string0W, final double string1KWh, final double string1W) {
|
||||
this.inverter = inverter;
|
||||
this.date = ZonedDateTime.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault());
|
||||
this.energy = new Value(energyProducedKWh, Unit.ENERGY_KWH);
|
||||
this.power = new Value(powerW, Unit.POWER_W);
|
||||
this.energyTotal = new Value(totalKWh, Unit.ENERGY_KWH);
|
||||
this.powerTotal = new Value(totalW, Unit.POWER_W);
|
||||
this.energyString0 = new Value(string0KWh, Unit.ENERGY_KWH);
|
||||
this.powerString0 = new Value(string0W, Unit.POWER_W);
|
||||
this.energyString1 = new Value(string1KWh, Unit.ENERGY_KWH);
|
||||
this.powerString1 = new Value(string1W, Unit.POWER_W);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package de.ph87.data.message.handler;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.ph87.data.message.IMessageHandler;
|
||||
import de.ph87.data.message.Message;
|
||||
import de.ph87.data.series.meter.MeterInbound;
|
||||
import de.ph87.data.series.varying.VaryingInbound;
|
||||
import de.ph87.data.value.Unit;
|
||||
import de.ph87.data.value.Value;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.ToString;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PatrixJsonHandler implements IMessageHandler {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
public void handle(@NonNull final Message message) throws Exception {
|
||||
if (!message.topic.endsWith("/PatrixJson")) {
|
||||
return;
|
||||
}
|
||||
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class);
|
||||
applicationEventPublisher.publishEvent(inbound);
|
||||
}
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(name = "VARYING", value = Varying.class),
|
||||
@JsonSubTypes.Type(name = "METER", value = Meter.class),
|
||||
})
|
||||
private interface Inbound {
|
||||
|
||||
enum Type {
|
||||
VARYING,
|
||||
METER,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ToString(callSuper = true)
|
||||
public static class Varying extends VaryingInbound implements Inbound {
|
||||
|
||||
protected Varying(@NonNull final String name, final long date, final double value, @NonNull final Unit unit) {
|
||||
super(name, ZonedDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneId.systemDefault()), new Value(value, unit));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ToString(callSuper = true)
|
||||
public static class Meter extends MeterInbound implements Inbound {
|
||||
|
||||
protected Meter(@NonNull final String name, @NonNull final String number, final long date, final double value, @NonNull final Unit unit) {
|
||||
super(name, number, ZonedDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneId.systemDefault()), new Value(value, unit));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -81,11 +81,16 @@ public class MqttReceiver {
|
||||
try {
|
||||
log.info("Connecting mqtt broker: {} as \"{}\"{}", uri, clientId, cleanSession ? " [CLEAN SESSION]" : "");
|
||||
client = new MqttClient(uri, clientId, persistence);
|
||||
client.setCallback(new MyMqttCallback());
|
||||
|
||||
final MqttConnectOptions options = new MqttConnectOptions();
|
||||
options.setAutomaticReconnect(true);
|
||||
options.setCleanSession(cleanSession);
|
||||
options.setConnectionTimeout(config.getConnectTimeoutSec());
|
||||
options.setKeepAliveInterval(config.getConnectKeepAliveSec());
|
||||
assert client != null;
|
||||
client.connect(options);
|
||||
|
||||
log.info("MQTT broker connected.");
|
||||
client.subscribe(config.getTopic(), this::_receive);
|
||||
} catch (MqttException e) {
|
||||
@ -116,4 +121,26 @@ public class MqttReceiver {
|
||||
messageService.handle(message);
|
||||
}
|
||||
|
||||
private class MyMqttCallback implements MqttCallback {
|
||||
|
||||
@Override
|
||||
public void connectionLost(final Throwable throwable) {
|
||||
synchronized (thread) {
|
||||
log.warn("MQTT broker disconnected.");
|
||||
thread.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void messageArrived(final String s, final MqttMessage mqttMessage) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deliveryComplete(final IMqttDeliveryToken iMqttDeliveryToken) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
22
src/main/java/de/ph87/data/point/Point.java
Normal file
22
src/main/java/de/ph87/data/point/Point.java
Normal file
@ -0,0 +1,22 @@
|
||||
package de.ph87.data.point;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.*;
|
||||
|
||||
@Data
|
||||
public class Point {
|
||||
|
||||
public final ZonedDateTime date;
|
||||
|
||||
public final double value;
|
||||
|
||||
@NonNull
|
||||
public Point plus(@NonNull final Point other) {
|
||||
if (this.date.compareTo(other.date) != 0) {
|
||||
throw new RuntimeException("Cannot 'add' GraphPoints with different dates: this=%s, other=%s".formatted(this, other));
|
||||
}
|
||||
return new Point(date, value + other.value);
|
||||
}
|
||||
|
||||
}
|
||||
26
src/main/java/de/ph87/data/point/PointController.java
Normal file
26
src/main/java/de/ph87/data/point/PointController.java
Normal file
@ -0,0 +1,26 @@
|
||||
package de.ph87.data.point;
|
||||
|
||||
import de.ph87.data.view.ViewPointRequest;
|
||||
import de.ph87.data.view.ViewService;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("points")
|
||||
public class PointController {
|
||||
|
||||
private final ViewService viewService;
|
||||
|
||||
@PostMapping("fetch")
|
||||
public List<Point> fetch(@RequestBody @NonNull final ViewPointRequest request) {
|
||||
return viewService.getPoints(request);
|
||||
}
|
||||
|
||||
}
|
||||
41
src/main/java/de/ph87/data/point/PointRequest.java
Normal file
41
src/main/java/de/ph87/data/point/PointRequest.java
Normal file
@ -0,0 +1,41 @@
|
||||
package de.ph87.data.point;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import de.ph87.data.series.Aligned;
|
||||
import de.ph87.data.series.Alignment;
|
||||
import lombok.Data;
|
||||
import lombok.NonNull;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@Data
|
||||
public class PointRequest {
|
||||
|
||||
@NonNull
|
||||
public final Alignment outer;
|
||||
|
||||
public final long outerOffset;
|
||||
|
||||
public final long outerCount;
|
||||
|
||||
@NonNull
|
||||
public final Alignment inner;
|
||||
|
||||
@NonNull
|
||||
@JsonIgnore
|
||||
public final Aligned begin;
|
||||
|
||||
@NonNull
|
||||
@JsonIgnore
|
||||
public final Aligned end;
|
||||
|
||||
public PointRequest(@NonNull final Alignment outer, final long outerOffset, final long outerCount, @NonNull final Alignment inner) {
|
||||
this.outer = outer;
|
||||
this.outerOffset = outerOffset;
|
||||
this.outerCount = outerCount;
|
||||
this.inner = inner;
|
||||
this.end = outer.align(ZonedDateTime.now()).plus(1).minus(outerOffset);
|
||||
this.begin = end.minus(outerCount);
|
||||
}
|
||||
|
||||
}
|
||||
30
src/main/java/de/ph87/data/point/PointService.java
Normal file
30
src/main/java/de/ph87/data/point/PointService.java
Normal file
@ -0,0 +1,30 @@
|
||||
package de.ph87.data.point;
|
||||
|
||||
import de.ph87.data.series.Series;
|
||||
import de.ph87.data.series.meter.MeterService;
|
||||
import de.ph87.data.series.varying.VaryingService;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PointService {
|
||||
|
||||
private final VaryingService varyingService;
|
||||
|
||||
private final MeterService meterService;
|
||||
|
||||
@NonNull
|
||||
public List<Point> getPoints(@NonNull final Series series, @NonNull final PointRequest pointRequest) {
|
||||
return switch (series.getType()) {
|
||||
case METER -> meterService.getPoints(series, pointRequest);
|
||||
case VARYING -> varyingService.getPoints(series, pointRequest);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
21
src/main/java/de/ph87/data/series/AggregationWrapperDto.java
Normal file
21
src/main/java/de/ph87/data/series/AggregationWrapperDto.java
Normal file
@ -0,0 +1,21 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Data
|
||||
public class AggregationWrapperDto {
|
||||
|
||||
@NonNull
|
||||
public final Aligned date;
|
||||
|
||||
@NonNull
|
||||
public final List<IAggregationDto> aggregations;
|
||||
|
||||
public AggregationWrapperDto(@NonNull final Aligned date, @NonNull final List<IAggregationDto> aggregations) {
|
||||
this.date = date;
|
||||
this.aggregations = aggregations;
|
||||
}
|
||||
|
||||
}
|
||||
@ -19,8 +19,13 @@ public class Aligned {
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Aligned minus(final long offset) {
|
||||
return new Aligned(alignment, alignment.plus.apply(date, -offset));
|
||||
public Aligned plus(final long amount) {
|
||||
return new Aligned(alignment, alignment.plus(date, amount));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Aligned minus(final long amount) {
|
||||
return plus(-amount);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -7,27 +7,27 @@ import java.time.temporal.*;
|
||||
import java.util.function.*;
|
||||
|
||||
public enum Alignment {
|
||||
FIVE(Duration.ofMinutes(5), t -> t.truncatedTo(ChronoUnit.MINUTES).minusMinutes(t.getMinute() % 5), (t, a) -> t.plusMinutes(5 * a)),
|
||||
HOUR(Duration.ofHours(1), t -> t.truncatedTo(ChronoUnit.HOURS), ZonedDateTime::plusHours),
|
||||
DAY(Duration.ofDays(1), t -> t.truncatedTo(ChronoUnit.DAYS), ZonedDateTime::plusDays),
|
||||
WEEK(Duration.ofDays(7), t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfWeek().getValue() - 1), ZonedDateTime::plusWeeks),
|
||||
MONTH(Duration.ofDays(31), t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfMonth() - 1), ZonedDateTime::plusMonths),
|
||||
YEAR(Duration.ofDays(366), t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfYear() - 1), ZonedDateTime::plusYears),
|
||||
FIVE(t -> t.truncatedTo(ChronoUnit.MINUTES).minusMinutes(t.getMinute() % 5), Duration.ofMinutes(5), Duration.ofMinutes(5)),
|
||||
HOUR(t -> t.truncatedTo(ChronoUnit.HOURS), Duration.ofHours(1), Duration.ofHours(1)),
|
||||
DAY(t -> t.truncatedTo(ChronoUnit.DAYS), Duration.ofDays(1), Duration.ofDays(1)),
|
||||
WEEK(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfWeek().getValue() - 1), Period.ofWeeks(1), Duration.ofDays(7)),
|
||||
MONTH(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfMonth() - 1), Period.ofMonths(1), Duration.ofDays(31)),
|
||||
YEAR(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfYear() - 1), Period.ofYears(1), Duration.ofDays(366)),
|
||||
;
|
||||
|
||||
@NonNull
|
||||
public final Duration maxDuration;
|
||||
|
||||
@NonNull
|
||||
public final Function<ZonedDateTime, ZonedDateTime> align;
|
||||
|
||||
@NonNull
|
||||
public final BiFunction<ZonedDateTime, Long, ZonedDateTime> plus;
|
||||
public final TemporalAmount amount;
|
||||
|
||||
Alignment(@NonNull final Duration maxDuration, @NonNull final Function<@NonNull ZonedDateTime, @NonNull ZonedDateTime> align, @NonNull final BiFunction<@NonNull ZonedDateTime, @NonNull Long, @NonNull ZonedDateTime> plus) {
|
||||
@NonNull
|
||||
public final Duration maxDuration;
|
||||
|
||||
Alignment(@NonNull final Function<@NonNull ZonedDateTime, @NonNull ZonedDateTime> align, @NonNull final TemporalAmount amount, @NonNull final Duration maxDuration) {
|
||||
this.maxDuration = maxDuration;
|
||||
this.align = align;
|
||||
this.plus = plus;
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ -35,4 +35,25 @@ public enum Alignment {
|
||||
return new Aligned(this, date);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public ZonedDateTime plus(@NonNull final ZonedDateTime date, final long amount) {
|
||||
return date.plus(multiplyAmount(amount));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@SuppressWarnings("unused")
|
||||
public ZonedDateTime minus(@NonNull final ZonedDateTime date, final long amount) {
|
||||
return plus(date, -amount);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public TemporalAmount multiplyAmount(final long amount) {
|
||||
if (this.amount instanceof final Duration duration) {
|
||||
return duration.multipliedBy(amount);
|
||||
} else if (this.amount instanceof final Period period) {
|
||||
return period.multipliedBy((int) amount);
|
||||
}
|
||||
throw new RuntimeException("Cannot multiply unimplemented TemporalAmount: %s".formatted(this.amount));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
10
src/main/java/de/ph87/data/series/IAggregationDto.java
Normal file
10
src/main/java/de/ph87/data/series/IAggregationDto.java
Normal file
@ -0,0 +1,10 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
public interface IAggregationDto {
|
||||
|
||||
@NonNull
|
||||
SeriesDto getSeries();
|
||||
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import de.ph87.data.value.Value;
|
||||
import de.ph87.data.value.*;
|
||||
import de.ph87.data.value.Unit;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@ToString
|
||||
@ -38,11 +40,16 @@ public class Series {
|
||||
@Enumerated(EnumType.STRING)
|
||||
private SeriesType type;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean graphZero = false;
|
||||
@Column
|
||||
@Nullable
|
||||
public final Double yMin = null;
|
||||
|
||||
@Column
|
||||
@Nullable
|
||||
public final Double yMax = null;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean graphAutoscale = false;
|
||||
private boolean autoscale = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double min;
|
||||
@ -56,24 +63,37 @@ public class Series {
|
||||
@Column(nullable = false)
|
||||
private int count;
|
||||
|
||||
public Series(@NonNull final String name, @NonNull final Unit unit, @NonNull final SeriesType type, @NonNull final Value value) {
|
||||
this.name = name;
|
||||
this.title = name;
|
||||
this.unit = unit;
|
||||
@Column(nullable = false)
|
||||
private double lastValue;
|
||||
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private ZonedDateTime lastDate;
|
||||
|
||||
public Series(@NonNull final SeriesInbound inbound, @NonNull final SeriesType type) {
|
||||
this.name = inbound.name;
|
||||
this.title = inbound.name;
|
||||
this.unit = inbound.value.unit;
|
||||
this.type = type;
|
||||
|
||||
final double converted = value.as(unit).value;
|
||||
final double converted = inbound.value.as(unit).value;
|
||||
this.min = converted;
|
||||
this.max = converted;
|
||||
this.avg = converted;
|
||||
this.count = 1;
|
||||
this.lastDate = inbound.date;
|
||||
this.lastValue = converted;
|
||||
}
|
||||
|
||||
public void update(@NonNull final Value value) {
|
||||
final double converted = value.as(unit).value;
|
||||
public void update(@NonNull final SeriesInbound inbound) {
|
||||
final double converted = inbound.value.as(unit).value;
|
||||
this.min = Math.min(this.min, converted);
|
||||
this.max = Math.max(this.max, converted);
|
||||
this.avg = (this.avg * this.count + converted) / ++this.count;
|
||||
if (this.lastDate.isBefore(inbound.date)) {
|
||||
this.lastDate = inbound.date;
|
||||
this.lastValue = converted;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
53
src/main/java/de/ph87/data/series/SeriesController.java
Normal file
53
src/main/java/de/ph87/data/series/SeriesController.java
Normal file
@ -0,0 +1,53 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import de.ph87.data.series.meter.*;
|
||||
import de.ph87.data.series.varying.*;
|
||||
import lombok.*;
|
||||
import org.springframework.format.annotation.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("Series")
|
||||
public class SeriesController {
|
||||
|
||||
private final MeterService meterService;
|
||||
|
||||
private final VaryingService varyingService;
|
||||
|
||||
private final SeriesService seriesService;
|
||||
|
||||
@NonNull
|
||||
@GetMapping("all")
|
||||
public List<SeriesDto> all() {
|
||||
return seriesService.findAllDto();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@GetMapping("agg/all/{alignmentName}/offset/{offsetAmount}")
|
||||
public AggregationWrapperDto agg(@PathVariable final String alignmentName, @PathVariable final long offsetAmount) {
|
||||
final Alignment alignment = Alignment.valueOf(alignmentName);
|
||||
final Aligned date = alignment.align(ZonedDateTime.now()).minus(offsetAmount);
|
||||
return getAggregationWrapperDto(date);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@GetMapping("agg/all/{alignmentName}/zoned/{zonedDateTime}")
|
||||
public AggregationWrapperDto agg(@PathVariable final String alignmentName, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) final ZonedDateTime zonedDateTime) {
|
||||
final Alignment alignment = Alignment.valueOf(alignmentName);
|
||||
final Aligned date = alignment.align(zonedDateTime);
|
||||
return getAggregationWrapperDto(date);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private AggregationWrapperDto getAggregationWrapperDto(@NonNull final Aligned date) {
|
||||
final List<IAggregationDto> aggregations = new ArrayList<>();
|
||||
aggregations.addAll(meterService.findAllAggregations(date));
|
||||
aggregations.addAll(varyingService.findAllAggregations(date));
|
||||
return new AggregationWrapperDto(date, aggregations);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,12 +1,22 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import de.ph87.data.value.*;
|
||||
import jakarta.annotation.*;
|
||||
import lombok.*;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import de.ph87.data.value.Unit;
|
||||
import de.ph87.data.web.IWebSocketMessage;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public class SeriesDto {
|
||||
public class SeriesDto implements IWebSocketMessage {
|
||||
|
||||
@JsonIgnore
|
||||
public final List<Object> websocketTopic = List.of("Series");
|
||||
|
||||
public final long id;
|
||||
|
||||
@ -20,9 +30,26 @@ public class SeriesDto {
|
||||
|
||||
public final SeriesType type;
|
||||
|
||||
private final boolean graphZero;
|
||||
@Nullable
|
||||
public final Double yMin;
|
||||
|
||||
private final boolean graphAutoscale;
|
||||
@Nullable
|
||||
public final Double yMax;
|
||||
|
||||
public final boolean autoscale;
|
||||
|
||||
public final double min;
|
||||
|
||||
public final double max;
|
||||
|
||||
public final double avg;
|
||||
|
||||
public final int count;
|
||||
|
||||
public final double lastValue;
|
||||
|
||||
@NonNull
|
||||
public final ZonedDateTime lastDate;
|
||||
|
||||
public SeriesDto(@NonNull final Series series) {
|
||||
this.id = series.getId();
|
||||
@ -31,15 +58,15 @@ public class SeriesDto {
|
||||
this.unit = series.getUnit();
|
||||
this.decimals = series.getDecimals();
|
||||
this.type = series.getType();
|
||||
this.graphZero = series.isGraphZero();
|
||||
this.graphAutoscale = series.isGraphAutoscale();
|
||||
}
|
||||
|
||||
public String format(@Nullable final Double value) {
|
||||
if (value == null || Double.isNaN(value)) {
|
||||
return "--- %s".formatted(unit.unit);
|
||||
}
|
||||
return "%%.%df %%s".formatted(decimals).formatted(value, unit.unit);
|
||||
this.yMin = series.getYMin();
|
||||
this.yMax = series.getYMax();
|
||||
this.autoscale = series.isAutoscale();
|
||||
this.min = series.getMin();
|
||||
this.max = series.getMax();
|
||||
this.avg = series.getAvg();
|
||||
this.count = series.getCount();
|
||||
this.lastDate = series.getLastDate();
|
||||
this.lastValue = series.getLastValue();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
package de.ph87.data.series;
|
||||
|
||||
import de.ph87.data.*;
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.*;
|
||||
import org.springframework.stereotype.*;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import de.ph87.data.Action;
|
||||
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.*;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -16,6 +20,8 @@ public class SeriesService {
|
||||
|
||||
private final SeriesRepository seriesRepository;
|
||||
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public SeriesDto modify(final long id, @NonNull final Consumer<Series> modifier) {
|
||||
final Series series = getById(id);
|
||||
@ -35,37 +41,44 @@ public class SeriesService {
|
||||
return seriesRepository.findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SeriesDto getDtoById(final long id) {
|
||||
return toDto(getById(id));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private SeriesDto publish(@NonNull final Series series, @NonNull final Action action) {
|
||||
final SeriesDto dto = toDto(series);
|
||||
if (action == Action.STATE) {
|
||||
log.debug("Series {}: {}", action, series);
|
||||
} else {
|
||||
log.info("Series {}: {}", action, series);
|
||||
}
|
||||
applicationEventPublisher.publishEvent(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private SeriesDto toDto(@NonNull final Series series) {
|
||||
public SeriesDto toDto(@NonNull final Series series) {
|
||||
return new SeriesDto(series);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Series getOrCreate(@NonNull final SeriesInbound inbound, @NonNull final SeriesType type) {
|
||||
public <R> R onInbound(@NonNull final SeriesInbound inbound, @NonNull final SeriesType type, @NonNull final Function<Series, R> mapper) {
|
||||
final Series series = seriesRepository
|
||||
.findByName(inbound.getName())
|
||||
.orElseGet(() -> {
|
||||
final Series fresh = seriesRepository.save(new Series(inbound.name, inbound.value.unit, type, inbound.value));
|
||||
final Series fresh = seriesRepository.save(new Series(inbound, type));
|
||||
publish(fresh, Action.CREATED);
|
||||
return fresh;
|
||||
});
|
||||
if (series.getType() != type) {
|
||||
log.warn("Existing Series type does not match requested type: requested={}, series={}", type, series);
|
||||
}
|
||||
series.update(inbound.value);
|
||||
return series;
|
||||
series.update(inbound);
|
||||
final R result = mapper.apply(series);
|
||||
publish(series, Action.STATE);
|
||||
return result;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<SeriesDto> findAllDto() {
|
||||
return seriesRepository.findAll().stream().map(this::toDto).toList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
package de.ph87.data.series.graph;
|
||||
|
||||
import de.ph87.data.series.*;
|
||||
import jakarta.servlet.http.*;
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.imageio.*;
|
||||
import java.awt.image.*;
|
||||
import java.io.*;
|
||||
import java.time.*;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("Series/Graph")
|
||||
public class GraphController {
|
||||
|
||||
private final GraphService graphService;
|
||||
|
||||
@GetMapping(path = "{seriesId}/{width}/{height}/{alignmentName}/{offset}/{duration}", produces = "image/png")
|
||||
public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String alignmentName, @PathVariable final long offset, @PathVariable final long duration) throws IOException {
|
||||
final Alignment alignment = Alignment.valueOf(alignmentName);
|
||||
final Aligned end = alignment.align(ZonedDateTime.now()).minus(offset);
|
||||
final Aligned begin = end.minus(duration - 1);
|
||||
final Graph graph = graphService.getGraph(seriesId, begin, end, width, height, 10);
|
||||
final BufferedImage image = graph.draw();
|
||||
response.setContentType("image/png");
|
||||
ImageIO.write(image, "PNG", response.getOutputStream());
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package de.ph87.data.series.graph;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.*;
|
||||
|
||||
@Data
|
||||
public class GraphPoint {
|
||||
|
||||
public final ZonedDateTime date;
|
||||
|
||||
public final double value;
|
||||
|
||||
@NonNull
|
||||
public GraphPoint plus(@NonNull final GraphPoint other) {
|
||||
if (this.date.compareTo(other.date) != 0) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
return new GraphPoint(date, value + other.value);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
package de.ph87.data.series.graph;
|
||||
|
||||
import de.ph87.data.series.*;
|
||||
import de.ph87.data.series.meter.*;
|
||||
import de.ph87.data.series.varying.*;
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.*;
|
||||
import org.springframework.stereotype.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GraphService {
|
||||
|
||||
private final SeriesService seriesService;
|
||||
|
||||
private final VaryingService varyingService;
|
||||
|
||||
private final MeterService meterService;
|
||||
|
||||
@NonNull
|
||||
public Graph getGraph(final long seriesId, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) {
|
||||
final SeriesDto series = seriesService.getDtoById(seriesId);
|
||||
final List<GraphPoint> entries = switch (series.getType()) {
|
||||
case METER -> meterService.getPoints(series, begin, end);
|
||||
case VARYING -> varyingService.getPoints(series, begin, end);
|
||||
};
|
||||
return new Graph(series, entries, begin, end, width, height, border);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package de.ph87.data.series.meter;
|
||||
|
||||
import de.ph87.data.series.*;
|
||||
import lombok.*;
|
||||
|
||||
@Data
|
||||
public class MeterAggregation {
|
||||
|
||||
@NonNull
|
||||
public final Series series;
|
||||
|
||||
public final double delta;
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package de.ph87.data.series.meter;
|
||||
|
||||
import de.ph87.data.series.*;
|
||||
import lombok.*;
|
||||
|
||||
@Data
|
||||
public class MeterAggregationDto implements IAggregationDto {
|
||||
|
||||
@NonNull
|
||||
public final SeriesDto series;
|
||||
|
||||
public final double delta;
|
||||
|
||||
public MeterAggregationDto(@NonNull final MeterAggregation meterAggregation, @NonNull final SeriesDto series) {
|
||||
this.series = series;
|
||||
this.delta = meterAggregation.delta;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,21 +1,31 @@
|
||||
package de.ph87.data.series.meter;
|
||||
|
||||
import de.ph87.data.point.Point;
|
||||
import de.ph87.data.point.PointRequest;
|
||||
import de.ph87.data.series.*;
|
||||
import de.ph87.data.series.graph.*;
|
||||
import de.ph87.data.series.meter.day.*;
|
||||
import de.ph87.data.series.meter.five.*;
|
||||
import de.ph87.data.series.meter.hour.*;
|
||||
import de.ph87.data.series.meter.month.*;
|
||||
import de.ph87.data.series.meter.week.*;
|
||||
import de.ph87.data.series.meter.year.*;
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.*;
|
||||
import de.ph87.data.series.meter.day.MeterDay;
|
||||
import de.ph87.data.series.meter.day.MeterDayRepository;
|
||||
import de.ph87.data.series.meter.five.MeterFive;
|
||||
import de.ph87.data.series.meter.five.MeterFiveRepository;
|
||||
import de.ph87.data.series.meter.hour.MeterHour;
|
||||
import de.ph87.data.series.meter.hour.MeterHourRepository;
|
||||
import de.ph87.data.series.meter.month.MeterMonth;
|
||||
import de.ph87.data.series.meter.month.MeterMonthRepository;
|
||||
import de.ph87.data.series.meter.week.MeterWeek;
|
||||
import de.ph87.data.series.meter.week.MeterWeekRepository;
|
||||
import de.ph87.data.series.meter.year.MeterYear;
|
||||
import de.ph87.data.series.meter.year.MeterYearRepository;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.*;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.*;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -40,9 +50,8 @@ public class MeterService {
|
||||
private final MeterYearRepository meterYearRepository;
|
||||
|
||||
@EventListener(MeterInbound.class)
|
||||
public void onEvent(@NonNull final MeterInbound inbound) {
|
||||
final Meter meter = getOrCreate(inbound);
|
||||
|
||||
public void onInbound(@NonNull final MeterInbound inbound) {
|
||||
onInbound(inbound, meter -> {
|
||||
final MeterValue.Id five = new MeterValue.Id(meter, inbound.date, Alignment.FIVE);
|
||||
meterFiveRepository.findById(five).ifPresentOrElse(existing -> existing.update(inbound.value), () -> meterFiveRepository.save(new MeterFive(five, inbound.value)));
|
||||
|
||||
@ -60,11 +69,17 @@ public class MeterService {
|
||||
|
||||
final MeterValue.Id year = new MeterValue.Id(meter, inbound.date, Alignment.YEAR);
|
||||
meterYearRepository.findById(year).ifPresentOrElse(existing -> existing.update(inbound.value), () -> meterYearRepository.save(new MeterYear(year, inbound.value)));
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Meter getOrCreate(@NonNull final MeterInbound inbound) {
|
||||
final Series series = seriesService.getOrCreate(inbound, SeriesType.METER);
|
||||
private void onInbound(@NonNull final MeterInbound inbound, @NonNull final Consumer<Meter> modifier) {
|
||||
final Meter meter = seriesService.onInbound(inbound, SeriesType.METER, series -> onInbound(series, inbound));
|
||||
modifier.accept(meter);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Meter onInbound(@NonNull final Series series, @NonNull final MeterInbound inbound) {
|
||||
return meterRepository.findFirstBySeriesOrderByDateDesc(series)
|
||||
.filter(m -> m.getNumber().equals(inbound.meterNumber))
|
||||
.stream()
|
||||
@ -74,24 +89,39 @@ public class MeterService {
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Aligned begin, @NonNull final Aligned end) {
|
||||
final List<? extends MeterValue> graphPoints = switch (begin.alignment) {
|
||||
case FIVE -> meterFiveRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
case HOUR -> meterHourRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
case DAY -> meterDayRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
case WEEK -> meterWeekRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
case MONTH -> meterMonthRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
case YEAR -> meterYearRepository.findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
|
||||
};
|
||||
final List<GraphPoint> points = graphPoints.stream().map(meterValue -> new GraphPoint(meterValue.getId().getDate(), meterValue.getMax() - meterValue.getMin())).collect(Collectors.toCollection(LinkedList::new));
|
||||
public List<Point> getPoints(final @NonNull Series series, @NonNull final PointRequest pointRequest) {
|
||||
final List<? extends MeterValue> graphPoints = findRepository(pointRequest.inner).findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.getId(), pointRequest.begin.date, pointRequest.end.date);
|
||||
final List<Point> points = graphPoints.stream().map(meterValue -> new Point(meterValue.getId().getDate(), meterValue.getMax() - meterValue.getMin())).collect(Collectors.toCollection(LinkedList::new));
|
||||
for (int i = 0; i < points.size() - 1; i++) {
|
||||
if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) {
|
||||
final GraphPoint first = points.remove(i);
|
||||
final GraphPoint second = points.remove(i + 1);
|
||||
final Point first = points.remove(i);
|
||||
final Point second = points.remove(i + 1);
|
||||
points.add(i, first.plus(second));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<MeterAggregationDto> findAllAggregations(@NonNull final Aligned date) {
|
||||
return findRepository(date.alignment).findAllDeltaSum(date.date).stream().map(this::toDto).toList();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private MeterAggregationDto toDto(@NonNull final MeterAggregation meterAggregation) {
|
||||
return new MeterAggregationDto(meterAggregation, seriesService.toDto(meterAggregation.series));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private MeterValueRepository<?> findRepository(@NonNull final Alignment alignment) {
|
||||
return switch (alignment) {
|
||||
case FIVE -> meterFiveRepository;
|
||||
case HOUR -> meterHourRepository;
|
||||
case DAY -> meterDayRepository;
|
||||
case WEEK -> meterWeekRepository;
|
||||
case MONTH -> meterMonthRepository;
|
||||
case YEAR -> meterYearRepository;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user