big cleanup + refactor: live+history, enhanced Graph

This commit is contained in:
Patrick Haßel 2025-05-05 12:44:23 +02:00
parent 9ee8060f05
commit 2c66314941
52 changed files with 431 additions and 287 deletions

View File

@ -5,7 +5,7 @@ spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa spring.datasource.username=sa
spring.datasource.password=password spring.datasource.password=password
#- #-
spring.jpa.hibernate.ddl-auto=create #spring.jpa.hibernate.ddl-auto=create
#- #-
de.ph87.data.message.receive.mqtt.host=10.0.0.50 de.ph87.data.message.receive.mqtt.host=10.0.0.50
de.ph87.data.message.receive.mqtt.topic=# de.ph87.data.message.receive.mqtt.topic=#

View File

@ -1,7 +1,6 @@
@import "./colors.less"; @import "./colors.less";
.numberTable { .numberTable {
margin-bottom: 2em;
.arrowLeft { .arrowLeft {
float: left; float: left;

View File

@ -1,6 +1,6 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {menubar, ROUTING} from './app.routes'; import {menubar} from './app.routes';
import {NgForOf} from '@angular/common'; import {NgForOf} from '@angular/common';
@Component({ @Component({
@ -11,7 +11,5 @@ import {NgForOf} from '@angular/common';
}) })
export class AppComponent { export class AppComponent {
protected readonly ROUTING = ROUTING;
protected readonly menubar = menubar; protected readonly menubar = menubar;
} }

View File

@ -1,7 +1,7 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
import {ElectroComponent} from './electro/electro.component'; import {LiveComponent} from './live/live.component';
import {GreenhouseComponent} from './greenhouse/greenhouse/greenhouse.component'; import {GreenhouseComponent} from './live/greenhouse/greenhouse/greenhouse.component';
import {CisternComponent} from './cistern/cistern.component'; import {HistoryComponent} from './history/history.component';
export class Path { export class Path {
@ -20,8 +20,8 @@ export class Path {
} }
export const ROUTING = { export const ROUTING = {
ENERGY: new Path('Energy', 'Energie', true), LIVE: new Path('Live', 'Live', true),
CISTERN: new Path('Cistern', 'Zisterne', true), HISTORY: new Path('History', 'Historie', true),
GREENHOUSE: new Path('Greenhouse', 'Gewächshaus', false), GREENHOUSE: new Path('Greenhouse', 'Gewächshaus', false),
} }
@ -30,8 +30,8 @@ export function menubar(): Path[] {
} }
export const routes: Routes = [ export const routes: Routes = [
{path: ROUTING.ENERGY.path, component: ElectroComponent}, {path: ROUTING.LIVE.path, component: LiveComponent},
{path: ROUTING.CISTERN.path, component: CisternComponent}, {path: ROUTING.HISTORY.path, component: HistoryComponent},
{path: ROUTING.GREENHOUSE.path, component: GreenhouseComponent}, {path: ROUTING.GREENHOUSE.path, component: GreenhouseComponent},
{path: '**', redirectTo: ROUTING.ENERGY.path}, {path: '**', redirectTo: ROUTING.LIVE.path},
]; ];

View File

@ -1,18 +0,0 @@
<table class="vertical">
<tr>
<th>Füllgrad</th>
<td class="valueInteger">{{ seriesService.cisternHeight.series?.lastValue?.percent(93.5)?.localeString }}</td>
<td class="unit">%</td>
</tr>
<tr>
<th>Füllhöhe</th>
<td class="valueInteger">{{ seriesService.cisternHeight.series?.lastValue?.localeString }}</td>
<td class="unit">{{ seriesService.cisternHeight.series?.lastValue?.unit?.unit }}</td>
</tr>
<tr>
<th>Volumen</th>
<td class="valueInteger">{{ seriesService.cisternVolume.series?.lastValue?.localeString }}</td>
<td class="unit">{{ seriesService.cisternVolume.series?.lastValue?.unit?.unit }}</td>
</tr>
</table>
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="graph(seriesService.cisternVolume.series)" [alt]="seriesService.cisternVolume.series.title">

View File

@ -1,18 +0,0 @@
import {Component} from '@angular/core';
import {ElectroEnergyComponent} from "./energy/electro-energy.component";
import {ElectroPowerComponent} from "./power/electro-power.component";
import {WeatherDiagramComponent} from '../weather/weather-diagram/weather-diagram.component';
@Component({
selector: 'app-electro',
imports: [
ElectroEnergyComponent,
ElectroPowerComponent,
WeatherDiagramComponent
],
templateUrl: './electro.component.html',
styleUrl: './electro.component.less'
})
export class ElectroComponent {
}

View File

@ -1,49 +0,0 @@
<div class="numberTable">
<div class="title">
Energie
</div>
<div class="option">
<button class="arrowLeft" (click)="shiftAlignment(+1)">&larr;</button>
{{ offset > 0 ? -offset : '' }}{{ alignment === Alignment.FIVE && offset > 0 ? 'x' : '' }} {{ alignment.display }}{{ offset > 1 ? alignment.plural : '' }}
<button class="arrowRight" (click)="shiftAlignment(-1)">&rarr;</button>
</div>
<div class="option">
<button class="arrowLeft" (click)="shiftOffset(+1)">&larr;</button>
{{ alignment.offsetTitle(offset, locale) }}
<button class="arrowRight" (click)="shiftOffset(-1)" [disabled]="offset === 0">&rarr;</button>
</div>
<div class="content">
<div class="entry consumption" [class.zero]="aggregations.energyConsumed?.zero">
<div class="name">Bedarf</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ aggregations.energyConsumed?.formatted }}</div>
</div>
<div class="entry purchase" [class.zero]="aggregations.energyPurchased?.delta?.zero">
<div class="name">Bezug</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">Produktion</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">Eigenbedarf</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">Einspeisung</div>
<div class="percent">{{ aggregations.energyDeliveredPercent?.formatted }}</div>
<div class="value">{{ aggregations.energyDelivered?.delta?.formatted }}</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>

View File

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

View File

@ -1,37 +0,0 @@
<div class="numberTable">
<div class="title">
Leistung
</div>
<div class="content">
<div class="entry consumption" [class.zero]="powerConsumption?.zero">
<div class="name">Bedarf</div>
<div class="percent">&nbsp;</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">Eigenbedarf</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>
<app-percent-bar [produktion]="powerProduction" [self]="powerSelf" [purchase]="powerPurchase" [delivery]="powerDelivery"></app-percent-bar>
</div>

View File

@ -0,0 +1,64 @@
<app-tile>
<div tile-body>
<div class="numberTable">
<div class="option">
<button class="arrowLeft" (click)="shiftAlignment(+1)">&larr;</button>
{{ offset > 0 ? -offset : '' }}{{ alignment === Alignment.FIVE && offset > 0 ? 'x' : '' }} {{ alignment.display }}{{ offset > 1 ? alignment.plural : '' }}
<button class="arrowRight" (click)="shiftAlignment(-1)">&rarr;</button>
</div>
<div class="option">
<button class="arrowLeft" (click)="shiftOffset(+1)">&larr;</button>
{{ alignment.offsetTitle(offset, locale) }}
<button class="arrowRight" (click)="shiftOffset(-1)" [disabled]="offset === 0">&rarr;</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">Bedarf</div>
<div class="percent">&nbsp;</div>
<div class="value">{{ aggregations.energyConsumed?.formatted }}</div>
</div>
<div class="entry purchase" [class.zero]="aggregations.energyPurchased?.delta?.zero">
<div class="name">Bezug</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">Produktion</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">Eigenbedarf</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">Einspeisung</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>
<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>

View File

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

View File

@ -1,19 +1,23 @@
import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core'; import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core';
import {PercentBarComponent} from '../../shared/percent-bar/percent-bar.component'; import {PercentBarComponent} from '../shared/percent-bar/percent-bar.component';
import {AggregationWrapperDto} from '../../series/AggregationWrapperDto'; import {AggregationWrapperDto} from '../series/AggregationWrapperDto';
import {Alignment} from '../../series/Alignment'; import {Alignment} from '../series/Alignment';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {SeriesService} from '../../series/series.service'; import {SeriesService} from '../series/series.service';
import {NgIf} from "@angular/common";
import {TileComponent} from '../shared/tile/tile.component';
@Component({ @Component({
selector: 'app-electro-energy', selector: 'app-history',
imports: [ imports: [
PercentBarComponent PercentBarComponent,
NgIf,
TileComponent
], ],
templateUrl: './electro-energy.component.html', templateUrl: './history.component.html',
styleUrl: './electro-energy.component.less' styleUrl: './history.component.less'
}) })
export class ElectroEnergyComponent implements OnInit, OnDestroy { export class HistoryComponent implements OnInit, OnDestroy {
protected readonly Alignment = Alignment; protected readonly Alignment = Alignment;

View File

@ -0,0 +1,31 @@
<app-tile>
<div tile-head>
Zisterne
</div>
<div tile-body>
<table class="vertical">
<tr>
<th>Füllgrad</th>
<td class="valueInteger">{{ seriesService.cisternHeight.series?.lastValue?.percent(93.5)?.localeString }}</td>
<td class="unit">%</td>
</tr>
<tr>
<th>Füllhöhe</th>
<td class="valueInteger">{{ seriesService.cisternHeight.series?.lastValue?.localeString }}</td>
<td class="unit">{{ seriesService.cisternHeight.series?.lastValue?.unit?.unit }}</td>
</tr>
<tr>
<th>Volumen</th>
<td class="valueInteger">{{ seriesService.cisternVolume.series?.lastValue?.localeString }}</td>
<td class="unit">{{ seriesService.cisternVolume.series?.lastValue?.unit?.unit }}</td>
</tr>
</table>
<img width="100%" *ngIf="seriesService.cisternVolume.series" [src]="graph(seriesService.cisternVolume.series)" [alt]="seriesService.cisternVolume.series.title">
</div>
</app-tile>

View File

@ -1,14 +1,16 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {SeriesService} from '../series/series.service'; import {SeriesService} from '../../series/series.service';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {NgIf} from '@angular/common'; import {NgIf} from '@angular/common';
import {Series} from '../series/Series'; import {Series} from '../../series/Series';
import {ApiService} from '../core/api.service'; import {TileComponent} from '../../shared/tile/tile.component';
import {Alignment} from '../../series/Alignment';
@Component({ @Component({
selector: 'app-cistern', selector: 'app-cistern',
imports: [ imports: [
NgIf NgIf,
TileComponent
], ],
templateUrl: './cistern.component.html', templateUrl: './cistern.component.html',
styleUrl: './cistern.component.less' styleUrl: './cistern.component.less'
@ -31,7 +33,7 @@ export class CisternComponent implements OnInit, OnDestroy {
} }
graph(series: Series) { graph(series: Series) {
return ApiService.url('http', ['Series', 'Graph', series.id, '400', '100', 'FIVE', '0', '288']); return this.seriesService.graph(series, 600, 200, Alignment.FIVE, 0, Alignment.FIVE, 24 * 12);
} }
} }

View File

@ -1,5 +1,5 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {SeriesService} from '../../series/series.service'; import {SeriesService} from '../../../series/series.service';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
@Component({ @Component({

View File

@ -2,4 +2,4 @@
<app-electro-power></app-electro-power> <app-electro-power></app-electro-power>
<app-electro-energy></app-electro-energy> <app-cistern></app-cistern>

View 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 {
}

View File

@ -0,0 +1,41 @@
<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">Bedarf</div>
<div class="percent">&nbsp;</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">Eigenbedarf</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>
</div>
<app-percent-bar [produktion]="powerProduction" [self]="powerSelf" [purchase]="powerPurchase" [delivery]="powerDelivery"></app-percent-bar>
</app-tile>

View File

@ -1,13 +1,15 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {PercentBarComponent} from "../../shared/percent-bar/percent-bar.component"; import {PercentBarComponent} from "../../shared/percent-bar/percent-bar.component";
import {Value} from '../../value/Value'; import {Value} from '../../series/value/Value';
import {SeriesService} from '../../series/series.service'; import {SeriesService} from '../../series/series.service';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {TileComponent} from '../../shared/tile/tile.component';
@Component({ @Component({
selector: 'app-electro-power', selector: 'app-electro-power',
imports: [ imports: [
PercentBarComponent PercentBarComponent,
TileComponent
], ],
templateUrl: './electro-power.component.html', templateUrl: './electro-power.component.html',
styleUrl: './electro-power.component.less' styleUrl: './electro-power.component.less'

View File

@ -1,5 +1,5 @@
import {Value} from "../../value/Value"; import {Value} from "../../../series/value/Value";
import {validateDate, validateList} from "../../core/validators"; import {validateDate, validateList} from "../../../core/validators";
import {WeatherHour} from "./WeatherHour"; import {WeatherHour} from "./WeatherHour";
export class WeatherDay { export class WeatherDay {

View File

@ -1,5 +1,5 @@
import {Value} from '../../value/Value'; import {Value} from '../../../series/value/Value';
import {validateDate} from '../../core/validators'; import {validateDate} from '../../../core/validators';
export class WeatherHour { export class WeatherHour {

View File

@ -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">&ge;30°C</span>
<span class="line temperatureGTE20">&ge;20°C</span>
<span class="line temperatureGTE10">&ge;10°C</span>
<span class="line temperatureGT0">&gt;0°C</span>
<span class="line temperatureNegative">&le;0°C</span>
<span class="line">&nbsp;</span>
<span class="line">Niederschlag 100% = {{ PRECIPITATION_MAX_MM }}mm</span>
</div>
</div>
</app-tile>

View File

@ -3,6 +3,7 @@ import {DatePipe, NgClass, NgForOf, NgIf} from '@angular/common';
import {WeatherHour} from './WeatherHour'; import {WeatherHour} from './WeatherHour';
import {WeatherService} from './weather.service'; import {WeatherService} from './weather.service';
import {WeatherDay} from './WeatherDay'; import {WeatherDay} from './WeatherDay';
import {TileComponent} from '../../../shared/tile/tile.component';
const PAST_HOURS_COUNT = 0; const PAST_HOURS_COUNT = 0;
@ -14,7 +15,8 @@ const DAY_COUNT = 7;
NgForOf, NgForOf,
NgIf, NgIf,
DatePipe, DatePipe,
NgClass NgClass,
TileComponent
], ],
templateUrl: './weather-diagram.component.html', templateUrl: './weather-diagram.component.html',
styleUrl: './weather-diagram.component.less' styleUrl: './weather-diagram.component.less'

View File

@ -1,6 +1,6 @@
import {Inject, Injectable, LOCALE_ID} from '@angular/core'; import {Inject, Injectable, LOCALE_ID} from '@angular/core';
import {ApiService} from '../../core/api.service'; import {ApiService} from '../../../core/api.service';
import {Next} from '../../core/types'; import {Next} from '../../../core/types';
import {WeatherDay} from './WeatherDay'; import {WeatherDay} from './WeatherDay';
@Injectable({ @Injectable({

View File

@ -4,7 +4,7 @@ import {MeterAggregate} from './meter/MeterAggregate';
import {Aggregate} from './Aggregate'; import {Aggregate} from './Aggregate';
import {VaryingAggregate} from './varying/VaryingAggregate'; import {VaryingAggregate} from './varying/VaryingAggregate';
import {Value} from '../value/Value'; import {Value} from './value/Value';
export class AggregationWrapperDto { export class AggregationWrapperDto {

View File

@ -4,23 +4,25 @@ export class Alignment {
private static readonly values: Alignment[] = []; private static readonly values: Alignment[] = [];
static readonly FIVE = new Alignment('FIVE', '5 Minuten', '', Alignment.offsetTitleFive); static readonly FIVE = new Alignment('FIVE', '5 Minuten', '', Alignment.offsetTitleFive, null, 0);
static readonly HOUR = new Alignment('HOUR', 'Stunde', 'n', Alignment.offsetTitleHour); static readonly HOUR = new Alignment('HOUR', 'Stunde', 'n', Alignment.offsetTitleHour, Alignment.FIVE, 12);
static readonly DAY = new Alignment('DAY', 'Tag', 'e', Alignment.offsetTitleDay); static readonly DAY = new Alignment('DAY', 'Tag', 'e', Alignment.offsetTitleDay, Alignment.FIVE, 24 * 12);
static readonly WEEK = new Alignment('WEEK', 'Woche', 'n', Alignment.offsetTitleWeek); static readonly WEEK = new Alignment('WEEK', 'Woche', 'n', Alignment.offsetTitleWeek, Alignment.HOUR, 7 * 24);
static readonly MONTH = new Alignment('MONTH', 'Monat', 'e', Alignment.offsetTitleMonth); static readonly MONTH = new Alignment('MONTH', 'Monat', 'e', Alignment.offsetTitleMonth, Alignment.HOUR, 30 * 24);
static readonly YEAR = new Alignment('YEAR', 'Jahr', 'e', Alignment.offsetTitleYear); static readonly YEAR = new Alignment('YEAR', 'Jahr', 'e', Alignment.offsetTitleYear, Alignment.DAY, 365);
constructor( constructor(
readonly name: string, readonly name: string,
readonly display: string, readonly display: string,
readonly plural: string, readonly plural: string,
readonly offsetTitle: (offset: number, locale: string) => string readonly offsetTitle: (offset: number, locale: string) => string,
readonly inner: Alignment | null,
readonly innerCount: number,
) { ) {
Alignment.values.push(this); Alignment.values.push(this);
} }
@ -139,4 +141,8 @@ export class Alignment {
return `${formatDate(date, "yyyy", locale)}`; return `${formatDate(date, "yyyy", locale)}`;
} }
toString(): string {
return this.name;
}
} }

View File

@ -1,6 +1,6 @@
import {Unit} from '../value/Unit'; import {Unit} from './value/Unit';
import {validateNumber, validateString} from '../core/validators'; import {validateNumber, validateString} from '../core/validators';
import {Value} from '../value/Value'; import {Value} from './value/Value';
export class Series { export class Series {

View File

@ -1,6 +1,6 @@
import {Series} from '../Series'; import {Series} from '../Series';
import {Aggregate} from '../Aggregate'; import {Aggregate} from '../Aggregate';
import {Value} from '../../value/Value'; import {Value} from '../value/Value';
export class MeterAggregate extends Aggregate { export class MeterAggregate extends Aggregate {

View File

@ -58,4 +58,11 @@ export class SeriesService extends AbstractRepositoryService {
this.api.getSingle(['Series', 'agg', 'all', alignment.name, 'offset', offset], j => AggregationWrapperDto.fromJson(j, this.locale), next); 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]);
}
} }

View File

@ -1,4 +1,4 @@
import {validateString} from '../core/validators'; import {validateString} from '../../core/validators';
export class Unit { export class Unit {

View File

@ -1,5 +1,5 @@
import {Unit} from "./Unit"; import {Unit} from "./Unit";
import {validateNumber} from "../core/validators"; import {validateNumber} from "../../core/validators";
export class Value { export class Value {
@ -13,7 +13,8 @@ export class Value {
readonly value: number, readonly value: number,
readonly unit: Unit, readonly unit: Unit,
readonly decimals: number, readonly decimals: number,
readonly locale: string) { readonly locale: string,
) {
this.localeString = this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals}); this.localeString = this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals});
this.valueInteger = this.localeString.split(/[,.]/)[0]; this.valueInteger = this.localeString.split(/[,.]/)[0];
this.valueFraction = this.localeString.split(/[,.]/)[1]; this.valueFraction = this.localeString.split(/[,.]/)[1];

View File

@ -1,5 +1,5 @@
import {Component, Input} from '@angular/core'; import {Component, Input} from '@angular/core';
import {Value} from '../../value/Value'; import {Value} from '../../series/value/Value';
import {NgIf} from '@angular/common'; import {NgIf} from '@angular/common';
@Component({ @Component({

View 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>

View 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;
}

View 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;
}

View File

@ -1,25 +0,0 @@
<div class="numberTable">
<div class="title">
Wetter
</div>
<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">&ge;30°C</span>
<span class="line temperatureGTE20">&ge;20°C</span>
<span class="line temperatureGTE10">&ge;10°C</span>
<span class="line temperatureGT0">&gt;0°C</span>
<span class="line temperatureNegative">&le;0°C</span>
<span class="line">&nbsp;</span>
<span class="line">Niederschlag 100% = {{ PRECIPITATION_MAX_MM }}mm</span>
</div>
</div>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>Data</title> <title>Data</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=yes">
<!--suppress HtmlUnknownTarget --> <!--suppress HtmlUnknownTarget -->
<link rel="icon" type="image/x-icon" href="favicon.svg"> <link rel="icon" type="image/x-icon" href="favicon.svg">
</head> </head>

View File

@ -56,3 +56,22 @@ table.vertical {
} }
} }
@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);

View File

@ -1,10 +1,11 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.value.*; import de.ph87.data.value.Unit;
import jakarta.annotation.Nullable;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.time.*; import java.time.ZonedDateTime;
@Entity @Entity
@Getter @Getter
@ -39,8 +40,13 @@ public class Series {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private SeriesType type; private SeriesType type;
@Column(nullable = false) @Column
private boolean graphZero = false; @Nullable
public final Double yMin = null;
@Column
@Nullable
public final Double yMax = null;
@Column(nullable = false) @Column(nullable = false)
private boolean autoscale = false; private boolean autoscale = false;

View File

@ -1,11 +1,14 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.value.*; import de.ph87.data.value.Unit;
import de.ph87.data.web.*; import de.ph87.data.web.IWebSocketMessage;
import lombok.*; import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.*; import java.time.ZonedDateTime;
import java.util.*; import java.util.List;
@Getter @Getter
@ToString @ToString
@ -25,7 +28,11 @@ public class SeriesDto implements IWebSocketMessage {
public final SeriesType type; public final SeriesType type;
public final boolean graphZero; @Nullable
public final Double yMin;
@Nullable
public final Double yMax;
public final boolean autoscale; public final boolean autoscale;
@ -49,7 +56,8 @@ public class SeriesDto implements IWebSocketMessage {
this.unit = series.getUnit(); this.unit = series.getUnit();
this.decimals = series.getDecimals(); this.decimals = series.getDecimals();
this.type = series.getType(); this.type = series.getType();
this.graphZero = series.isGraphZero(); this.yMin = series.getYMin();
this.yMax = series.getYMax();
this.autoscale = series.isAutoscale(); this.autoscale = series.isAutoscale();
this.min = series.getMin(); this.min = series.getMin();
this.max = series.getMax(); this.max = series.getMax();

View File

@ -74,8 +74,8 @@ public class Graph {
// find bounds // find bounds
double vSum = 0; double vSum = 0;
double vMin = series.isGraphZero() ? 0.0 : Double.MAX_VALUE; double vMin = series.getYMin() == null || Double.isNaN(series.getYMin()) ? Double.MIN_VALUE : series.getYMin();
double vMax = series.isGraphZero() ? 0.0 : Double.MIN_VALUE; double vMax = series.getYMax() == null || Double.isNaN(series.getYMax()) ? Double.MAX_VALUE : series.getYMax();
for (final GraphPoint point : points) { for (final GraphPoint point : points) {
vMin = Math.min(vMin, point.getValue()); vMin = Math.min(vMin, point.getValue());
vMax = max(vMax, point.getValue()); vMax = max(vMax, point.getValue());
@ -89,7 +89,7 @@ public class Graph {
vSum *= autoscale.factor; vSum *= autoscale.factor;
// find max label width // find max label width
int __maxLabelWidth = 80; int __maxLabelWidth = 0;
final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics(); final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics();
for (final GraphPoint point : points) { for (final GraphPoint point : points) {
__maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor))); __maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor)));
@ -117,33 +117,27 @@ public class Graph {
public BufferedImage draw() { public BufferedImage draw() {
final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
final Graphics2D g = (Graphics2D) image.getGraphics(); final Graphics2D g = (Graphics2D) image.getGraphics();
final int fontH3_4 = (int) Math.round(g.getFontMetrics().getHeight() * 0.75);
// g.setColor(Color.gray); yLabel(g, valueMax, Color.red);
// final String string = "%s [%s]".formatted(series.getTitle(), autoscale.unit); yLabel(g, valueAvg, new Color(0, 255, 0));
// g.drawString(string, border, border + fontH3_4); yLabel(g, valueMin, new Color(64, 128, 255));
yLabel(g, valueMax, DASHED, Color.red.darker().darker());
// yLabel(g, valueAvg, DASHED, new Color(0, 127, 0));
yLabel(g, 0, NORMAL, Color.BLACK);
yLabel(g, valueMin, DASHED, new Color(64, 128, 255).darker().darker());
g.translate(border, height - border); g.translate(border, height - border);
g.scale(1, -1); g.scale(1, -1);
// y-axis
g.setStroke(NORMAL); g.setStroke(NORMAL);
g.setColor(Color.BLACK); g.setColor(Color.GRAY);
g.drawLine(widthInner, 0, widthInner, heightInner); // y-axis g.drawLine(widthInner, 0, widthInner, heightInner);
g.setColor(Color.WHITE);
if (series.type == SeriesType.METER) { if (series.type == SeriesType.METER) {
g.setColor(Color.PINK);
final int space = (int) (minuteScale * begin.alignment.maxDuration.toMinutes()); final int space = (int) (minuteScale * begin.alignment.maxDuration.toMinutes());
final int width = (int) (space * 0.95); final int width = (int) (space * 0.95);
for (final Point point : points) { for (final Point point : points) {
g.fillRect(point.x + (space - width), 0, width, point.y); g.fillRect(point.x + (space - width), 0, width, point.y);
} }
} else { } else {
g.setColor(Color.RED);
Point last = null; Point last = null;
for (final Point current : points) { for (final Point current : points) {
if (last != null) { if (last != null) {
@ -155,14 +149,14 @@ public class Graph {
return image; 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 String string = autoscale.format(value);
final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string); final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string);
final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border); final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border);
g.setColor(color); g.setColor(color);
g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25)); g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25));
if (stroke != null && color != null) { if (color != null) {
g.setStroke(stroke); g.setStroke(Graph.DASHED);
g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y)); g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y));
} }
} }

View File

@ -1,15 +1,19 @@
package de.ph87.data.series.graph; package de.ph87.data.series.graph;
import de.ph87.data.series.*; import de.ph87.data.series.Aligned;
import jakarta.servlet.http.*; import de.ph87.data.series.Alignment;
import lombok.*; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.*; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; 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.*; import javax.imageio.ImageIO;
import java.awt.image.*; import java.awt.image.BufferedImage;
import java.io.*; import java.io.IOException;
import java.time.*; import java.time.ZonedDateTime;
@Slf4j @Slf4j
@RestController @RestController
@ -19,12 +23,13 @@ public class GraphController {
private final GraphService graphService; private final GraphService graphService;
@GetMapping(path = "{seriesId}/{width}/{height}/{alignmentName}/{offset}/{duration}", produces = "image/png") @GetMapping(path = "{seriesId}/{width}/{height}/{outerAlignmentName}/{offset}/{duration}/{innerAlignmentName}", 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 { public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String outerAlignmentName, @PathVariable final long offset, @PathVariable final long duration, @PathVariable final String innerAlignmentName) throws IOException {
final Alignment alignment = Alignment.valueOf(alignmentName); final Alignment outerAlignment = Alignment.valueOf(outerAlignmentName);
final Aligned end = alignment.align(ZonedDateTime.now()).minus(offset); final Alignment innerAlignment = Alignment.valueOf(innerAlignmentName);
final Aligned begin = end.minus(duration - 1); final Aligned end = outerAlignment.align(ZonedDateTime.now()).plus(1).minus(offset);
final Graph graph = graphService.getGraph(seriesId, begin, end, width, height, 10); final Aligned begin = end.minus(duration);
final Graph graph = graphService.getGraph(seriesId, innerAlignment, begin, end, width, height, 10);
final BufferedImage image = graph.draw(); final BufferedImage image = graph.draw();
response.setContentType("image/png"); response.setContentType("image/png");
ImageIO.write(image, "PNG", response.getOutputStream()); ImageIO.write(image, "PNG", response.getOutputStream());

View File

@ -1,13 +1,17 @@
package de.ph87.data.series.graph; package de.ph87.data.series.graph;
import de.ph87.data.series.*; import de.ph87.data.series.Aligned;
import de.ph87.data.series.meter.*; import de.ph87.data.series.Alignment;
import de.ph87.data.series.varying.*; import de.ph87.data.series.SeriesDto;
import lombok.*; import de.ph87.data.series.SeriesService;
import lombok.extern.slf4j.*; import de.ph87.data.series.meter.MeterService;
import org.springframework.stereotype.*; 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.*; import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -21,11 +25,11 @@ public class GraphService {
private final MeterService meterService; private final MeterService meterService;
@NonNull @NonNull
public Graph getGraph(final long seriesId, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) { public Graph getGraph(final long seriesId, @NonNull final Alignment innerAlignment, @NonNull final Aligned begin, @NonNull final Aligned end, final int width, final int height, final int border) {
final SeriesDto series = seriesService.getDtoById(seriesId); final SeriesDto series = seriesService.getDtoById(seriesId);
final List<GraphPoint> entries = switch (series.getType()) { final List<GraphPoint> entries = switch (series.getType()) {
case METER -> meterService.getPoints(series, begin, end); case METER -> meterService.getPoints(series, innerAlignment, begin, end);
case VARYING -> varyingService.getPoints(series, begin, end); case VARYING -> varyingService.getPoints(series, innerAlignment, begin, end);
}; };
return new Graph(series, entries, begin, end, width, height, border); return new Graph(series, entries, begin, end, width, height, border);
} }

View File

@ -80,8 +80,8 @@ public class MeterService {
} }
@NonNull @NonNull
public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Aligned begin, @NonNull final Aligned end) { public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Alignment innerAlignment, @NonNull final Aligned begin, @NonNull final Aligned end) {
final List<? extends MeterValue> graphPoints = findRepository(begin.alignment).findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date); final List<? extends MeterValue> graphPoints = findRepository(innerAlignment).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)); final List<GraphPoint> points = graphPoints.stream().map(meterValue -> new GraphPoint(meterValue.getId().getDate(), meterValue.getMax() - meterValue.getMin())).collect(Collectors.toCollection(LinkedList::new));
for (int i = 0; i < points.size() - 1; i++) { for (int i = 0; i < points.size() - 1; i++) {
if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) { if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) {

View File

@ -1,20 +1,27 @@
package de.ph87.data.series.varying; package de.ph87.data.series.varying;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.graph.*; import de.ph87.data.series.graph.GraphPoint;
import de.ph87.data.series.varying.day.*; import de.ph87.data.series.varying.day.VaryingDay;
import de.ph87.data.series.varying.five.*; import de.ph87.data.series.varying.day.VaryingDayRepository;
import de.ph87.data.series.varying.hour.*; import de.ph87.data.series.varying.five.VaryingFive;
import de.ph87.data.series.varying.month.*; import de.ph87.data.series.varying.five.VaryingFiveRepository;
import de.ph87.data.series.varying.week.*; import de.ph87.data.series.varying.hour.VaryingHour;
import de.ph87.data.series.varying.year.*; import de.ph87.data.series.varying.hour.VaryingHourRepository;
import lombok.*; import de.ph87.data.series.varying.month.VaryingMonth;
import lombok.extern.slf4j.*; import de.ph87.data.series.varying.month.VaryingMonthRepository;
import de.ph87.data.series.varying.week.VaryingWeek;
import de.ph87.data.series.varying.week.VaryingWeekRepository;
import de.ph87.data.series.varying.year.VaryingYear;
import de.ph87.data.series.varying.year.VaryingYearRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.*; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*; import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -65,8 +72,8 @@ public class VaryingService {
} }
@NonNull @NonNull
public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Aligned begin, @NonNull final Aligned end) { public List<GraphPoint> getPoints(@NonNull final SeriesDto series, @NonNull final Alignment innerAlignment, @NonNull final Aligned begin, @NonNull final Aligned end) {
return findRepository(begin.alignment) return findRepository(innerAlignment)
.findAllByIdSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqual(series.id, begin.date, end.date) .findAllByIdSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqual(series.id, begin.date, end.date)
.stream() .stream()
.map(v -> new GraphPoint(v.getId().getDate(), v.getAvg())) .map(v -> new GraphPoint(v.getId().getDate(), v.getAvg()))

View File

@ -1,19 +1,26 @@
package de.ph87.data.weather; package de.ph87.data.weather;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.*; import lombok.NonNull;
import lombok.extern.slf4j.*; import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.event.*; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.*; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.*; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.*; import java.io.IOException;
import java.net.*; import java.net.HttpURLConnection;
import java.nio.charset.*; import java.net.URI;
import java.time.*; import java.nio.charset.StandardCharsets;
import java.time.format.*; import java.time.LocalDate;
import java.util.*; import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -53,8 +60,8 @@ public class WeatherService {
} }
days = newDays; days = newDays;
log.info("Weather update complete"); log.info("Weather update complete");
} catch (IOException e) { } catch (Exception e) {
log.error(e.toString()); log.error("Failed fetching Weather data!", e);
} }
} }