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.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.topic=#

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {Routes} from '@angular/router';
import {ElectroComponent} from './electro/electro.component';
import {GreenhouseComponent} from './greenhouse/greenhouse/greenhouse.component';
import {CisternComponent} from './cistern/cistern.component';
import {LiveComponent} from './live/live.component';
import {GreenhouseComponent} from './live/greenhouse/greenhouse/greenhouse.component';
import {HistoryComponent} from './history/history.component';
export class Path {
@ -20,8 +20,8 @@ export class Path {
}
export const ROUTING = {
ENERGY: new Path('Energy', 'Energie', true),
CISTERN: new Path('Cistern', 'Zisterne', true),
LIVE: new Path('Live', 'Live', true),
HISTORY: new Path('History', 'Historie', true),
GREENHOUSE: new Path('Greenhouse', 'Gewächshaus', false),
}
@ -30,8 +30,8 @@ export function menubar(): Path[] {
}
export const routes: Routes = [
{path: ROUTING.ENERGY.path, component: ElectroComponent},
{path: ROUTING.CISTERN.path, component: CisternComponent},
{path: ROUTING.LIVE.path, component: LiveComponent},
{path: ROUTING.HISTORY.path, component: HistoryComponent},
{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 {PercentBarComponent} from '../../shared/percent-bar/percent-bar.component';
import {AggregationWrapperDto} from '../../series/AggregationWrapperDto';
import {Alignment} from '../../series/Alignment';
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 {SeriesService} from '../series/series.service';
import {NgIf} from "@angular/common";
import {TileComponent} from '../shared/tile/tile.component';
@Component({
selector: 'app-electro-energy',
selector: 'app-history',
imports: [
PercentBarComponent
PercentBarComponent,
NgIf,
TileComponent
],
templateUrl: './electro-energy.component.html',
styleUrl: './electro-energy.component.less'
templateUrl: './history.component.html',
styleUrl: './history.component.less'
})
export class ElectroEnergyComponent implements OnInit, OnDestroy {
export class HistoryComponent implements OnInit, OnDestroy {
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 {SeriesService} from '../series/series.service';
import {SeriesService} from '../../series/series.service';
import {Subscription} from 'rxjs';
import {NgIf} from '@angular/common';
import {Series} from '../series/Series';
import {ApiService} from '../core/api.service';
import {Series} from '../../series/Series';
import {TileComponent} from '../../shared/tile/tile.component';
import {Alignment} from '../../series/Alignment';
@Component({
selector: 'app-cistern',
imports: [
NgIf
NgIf,
TileComponent
],
templateUrl: './cistern.component.html',
styleUrl: './cistern.component.less'
@ -31,7 +33,7 @@ export class CisternComponent implements OnInit, OnDestroy {
}
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 {SeriesService} from '../../series/series.service';
import {SeriesService} from '../../../series/series.service';
import {Subscription} from 'rxjs';
@Component({

View File

@ -2,4 +2,4 @@
<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 {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 {Subscription} from 'rxjs';
import {TileComponent} from '../../shared/tile/tile.component';
@Component({
selector: 'app-electro-power',
imports: [
PercentBarComponent
PercentBarComponent,
TileComponent
],
templateUrl: './electro-power.component.html',
styleUrl: './electro-power.component.less'

View File

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

View File

@ -1,5 +1,5 @@
import {Value} from '../../value/Value';
import {validateDate} from '../../core/validators';
import {Value} from '../../../series/value/Value';
import {validateDate} from '../../../core/validators';
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 {WeatherService} from './weather.service';
import {WeatherDay} from './WeatherDay';
import {TileComponent} from '../../../shared/tile/tile.component';
const PAST_HOURS_COUNT = 0;
@ -14,7 +15,8 @@ const DAY_COUNT = 7;
NgForOf,
NgIf,
DatePipe,
NgClass
NgClass,
TileComponent
],
templateUrl: './weather-diagram.component.html',
styleUrl: './weather-diagram.component.less'

View File

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

View File

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

View File

@ -4,23 +4,25 @@ export class 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(
readonly name: string,
readonly display: 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);
}
@ -139,4 +141,8 @@ export class Alignment {
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 {Value} from '../value/Value';
import {Value} from './value/Value';
export class Series {

View File

@ -1,6 +1,6 @@
import {Series} from '../Series';
import {Aggregate} from '../Aggregate';
import {Value} from '../../value/Value';
import {Value} from '../value/Value';
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);
}
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 {

View File

@ -1,5 +1,5 @@
import {Unit} from "./Unit";
import {validateNumber} from "../core/validators";
import {validateNumber} from "../../core/validators";
export class Value {
@ -13,7 +13,8 @@ export class Value {
readonly value: number,
readonly unit: Unit,
readonly decimals: number,
readonly locale: string) {
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];

View File

@ -1,5 +1,5 @@
import {Component, Input} from '@angular/core';
import {Value} from '../../value/Value';
import {Value} from '../../series/value/Value';
import {NgIf} from '@angular/common';
@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">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="viewport" content="width=device-width, user-scalable=yes">
<!--suppress HtmlUnknownTarget -->
<link rel="icon" type="image/x-icon" href="favicon.svg">
</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;
import de.ph87.data.value.*;
import de.ph87.data.value.Unit;
import jakarta.annotation.Nullable;
import jakarta.persistence.*;
import lombok.*;
import java.time.*;
import java.time.ZonedDateTime;
@Entity
@Getter
@ -39,8 +40,13 @@ 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 autoscale = false;

View File

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

View File

@ -74,8 +74,8 @@ public class Graph {
// find bounds
double vSum = 0;
double vMin = series.isGraphZero() ? 0.0 : Double.MAX_VALUE;
double vMax = series.isGraphZero() ? 0.0 : Double.MIN_VALUE;
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 GraphPoint point : points) {
vMin = Math.min(vMin, point.getValue());
vMax = max(vMax, point.getValue());
@ -89,7 +89,7 @@ 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) {
__maxLabelWidth = max(__maxLabelWidth, fontMetrics.stringWidth(autoscale.format(point.getValue() * autoscale.factor)));
@ -117,33 +117,27 @@ 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.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());
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 width = (int) (space * 0.95);
for (final 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) {
if (last != null) {
@ -155,14 +149,14 @@ 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));
}
}

View File

@ -1,15 +1,19 @@
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 de.ph87.data.series.Aligned;
import de.ph87.data.series.Alignment;
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.*;
import java.awt.image.*;
import java.io.*;
import java.time.*;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.time.ZonedDateTime;
@Slf4j
@RestController
@ -19,12 +23,13 @@ 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);
@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 outerAlignmentName, @PathVariable final long offset, @PathVariable final long duration, @PathVariable final String innerAlignmentName) throws IOException {
final Alignment outerAlignment = Alignment.valueOf(outerAlignmentName);
final Alignment innerAlignment = Alignment.valueOf(innerAlignmentName);
final Aligned end = outerAlignment.align(ZonedDateTime.now()).plus(1).minus(offset);
final Aligned begin = end.minus(duration);
final Graph graph = graphService.getGraph(seriesId, innerAlignment, begin, end, width, height, 10);
final BufferedImage image = graph.draw();
response.setContentType("image/png");
ImageIO.write(image, "PNG", response.getOutputStream());

View File

@ -1,13 +1,17 @@
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 de.ph87.data.series.Aligned;
import de.ph87.data.series.Alignment;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.SeriesService;
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.*;
import java.util.List;
@Slf4j
@Service
@ -21,11 +25,11 @@ public class GraphService {
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) {
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 List<GraphPoint> entries = switch (series.getType()) {
case METER -> meterService.getPoints(series, begin, end);
case VARYING -> varyingService.getPoints(series, begin, end);
case METER -> meterService.getPoints(series, innerAlignment, begin, end);
case VARYING -> varyingService.getPoints(series, innerAlignment, begin, end);
};
return new Graph(series, entries, begin, end, width, height, border);
}

View File

@ -80,8 +80,8 @@ 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 = findRepository(begin.alignment).findAllByIdMeterSeriesIdAndIdDateGreaterThanEqualAndIdDateLessThanEqualOrderByIdDate(series.id, begin.date, end.date);
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(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));
for (int i = 0; i < points.size() - 1; i++) {
if (points.get(i).date.compareTo(points.get(i + 1).date) == 0) {

View File

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

View File

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