Compare commits

...

14 Commits

41 changed files with 1238 additions and 532 deletions

View File

@ -9,12 +9,12 @@ import {registerLocaleData} from '@angular/common';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de'; import localeDeExtra from '@angular/common/locales/extra/de';
import {stompServiceFactory} from './common'; import {stompServiceFactory} from './common';
import {Chart, registerables} from 'chart.js'; import {BarController, BarElement, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip} from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
registerLocaleData(localeDe, 'de-DE', localeDeExtra); registerLocaleData(localeDe, 'de-DE', localeDeExtra);
Chart.register(...registerables); Chart.register(TimeScale, LinearScale, BarController, BarElement, Tooltip, Legend, LineController, PointElement, LineElement, Filler);
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [

View File

@ -1,20 +1,30 @@
<div class="MainMenu NoUserSelect"> <div class="MainMenu NoUserSelect">
<div class="MainMenuBar"> <div class="MainMenuBar">
<div class="MainMenuItem MainMenuButton" (click)="showDrawer = !showDrawer"> <div class="MainMenuItem MainMenuButton" (click)="showDrawer = !showDrawer">
<fa-icon [icon]="faBars"></fa-icon> <fa-icon [icon]="faBars"></fa-icon>
</div> </div>
<div class="MainMenuItem MainMenuTitle"> <div class="MainMenuItem MainMenuTitle">
{{ menuService.title }} {{ menuService.title }}
</div> </div>
@if (!ws.connected) {
<div class="MainMenuItem MainMenuNotConnected">NICHT VERBUNDEN</div> <div class="MainMenuItem" routerLink="/Location">
} <fa-icon [icon]="faHome"></fa-icon>
</div>
<div class="MainMenuItem" routerLink="/Settings">
<fa-icon [icon]="faGears"></fa-icon>
</div>
</div> </div>
</div> </div>
<div class="MainMenuDrawer NoUserSelect" [hidden]="!showDrawer"> <div class="MainMenuDrawer NoUserSelect" [hidden]="!showDrawer">
@for (location of locationList; track location.id) { @for (location of locationList; track location.id) {
<div class="MainMenuItem" (click)="navigate(`Location/${location.id}`); showDrawer = false" routerLinkActive="MainMenuItemActive">{{ location.name }}</div> @if (location.id !== menuService.locationId) {
<div class="MainMenuItem" (click)="navigate(`Location/${location.id}`); showDrawer = false" routerLinkActive="MainMenuItemActive">{{ location.name }}</div>
}
} }
</div> </div>

View File

@ -6,6 +6,7 @@
.MainMenuBar { .MainMenuBar {
display: flex; display: flex;
padding: 0.25em; padding: 0.25em;
font-size: 120%;
.MainMenuItem { .MainMenuItem {
padding: 0.25em; padding: 0.25em;
@ -25,8 +26,12 @@
flex: 1; flex: 1;
} }
.MainMenuNotConnected { .bookmarkInactive {
color: red; color: lightgray;
}
.bookmarkActive {
color: steelblue;
} }
} }

View File

@ -1,9 +1,10 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
import {LocationList} from './location/list/location-list';
import {LocationDetail} from './location/detail/location-detail'; import {LocationDetail} from './location/detail/location-detail';
import {SettingsComponent} from './settings/settings-component';
export const routes: Routes = [ export const routes: Routes = [
{path: 'Location/:id', component: LocationDetail}, {path: 'Location/:id', component: LocationDetail},
{path: 'Location', component: LocationList}, {path: 'Location', component: LocationDetail},
{path: 'Settings', component: SettingsComponent},
{path: '**', redirectTo: '/Location'}, {path: '**', redirectTo: '/Location'},
]; ];

View File

@ -1,19 +1,26 @@
import {Component, OnDestroy, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {Router, RouterLinkActive, RouterOutlet} from '@angular/router'; import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {FaIconComponent} from '@fortawesome/angular-fontawesome'; import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faBars} from '@fortawesome/free-solid-svg-icons'; import {faBars, faGears} from '@fortawesome/free-solid-svg-icons';
import {MenuService} from './menu-service'; import {MenuService} from './menu-service';
import {Location} from './location/Location'; import {Location} from './location/Location';
import {LocationService} from './location/location-service'; import {LocationService} from './location/location-service';
import {WebsocketService} from './common'; import {WebsocketService} from './common';
import {faHome} from '@fortawesome/free-regular-svg-icons';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, FaIconComponent, RouterLinkActive], imports: [RouterOutlet, FaIconComponent, RouterLinkActive, RouterLink],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.less' styleUrl: './app.less'
}) })
export class App implements OnInit, OnDestroy { export class App implements OnInit {
protected readonly faHome = faHome;
protected readonly location = location;
protected readonly faGears = faGears;
protected readonly faBars = faBars; protected readonly faBars = faBars;
@ -32,11 +39,6 @@ export class App implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.locationService.findAll(list => this.locationList = list); this.locationService.findAll(list => this.locationList = list);
this.menuService.title = "Orte";
}
ngOnDestroy(): void {
this.menuService.title = "";
} }
navigate(url: string): void { navigate(url: string): void {

View File

@ -0,0 +1,103 @@
import {Injectable} from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
private readonly LOCATION_ID_KEY = "locationId";
private readonly ENERGY_PERCENT_KEY = "energyPercent";
private readonly ENERGY_PERCENT_FALLBACK = false;
private readonly LOCATION_CONFIG_KEY = "locationConfig";
private readonly LOCATION_CONFIG_FALLBACK = false;
constructor() {
this._locationId = this.readNumberOrNull(this.LOCATION_ID_KEY);
this._energyPercent = this.readBoolean(this.ENERGY_PERCENT_KEY, this.ENERGY_PERCENT_FALLBACK);
this._locationConfig = this.readBoolean(this.LOCATION_CONFIG_KEY, this.LOCATION_CONFIG_FALLBACK);
}
private readNumberOrNull(key: string): number | null {
const value = this.read(key);
if (value === null) {
return null;
}
const number = parseInt(value);
return isNaN(number) ? null : number;
}
private readBoolean(key: string, fallback: boolean): boolean {
const value = this.read(key);
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
return fallback;
}
private read(key: string): string | null {
return localStorage.getItem(key);
}
private writeBoolean(key: string, value: boolean | null): void {
if (value !== null && value !== undefined) {
localStorage.setItem(key, value + "");
} else {
localStorage.removeItem(key);
}
}
// locationId -----------------------------------------------------------------------------------
private _locationId: number | null = null;
get locationId(): number | null {
return this._locationId;
}
set locationId(value: number | null) {
this._locationId = value;
if (value !== null && value !== undefined) {
localStorage.setItem(this.LOCATION_ID_KEY, value + "");
} else {
localStorage.removeItem(this.LOCATION_ID_KEY);
}
}
// energyPercent -----------------------------------------------------------------------------------
private _energyPercent: boolean = this.ENERGY_PERCENT_FALLBACK;
get energyPercent(): boolean {
return this._energyPercent;
}
set energyPercent(value: boolean) {
this._energyPercent = value;
this.writeBoolean(this.ENERGY_PERCENT_KEY, value);
}
// locationConfig -----------------------------------------------------------------------------------
private _locationConfig: boolean = this.LOCATION_CONFIG_FALLBACK;
get locationConfig(): boolean {
return this._locationConfig;
}
set locationConfig(value: boolean) {
this._locationConfig = value;
if (value !== null && value !== undefined) {
localStorage.setItem(this.LOCATION_CONFIG_KEY, value + "");
} else {
localStorage.removeItem(this.LOCATION_CONFIG_KEY);
}
}
}

View File

@ -1,66 +1,26 @@
import {or, validateNumber, validateString} from '../common'; import {or, validateNumber, validateString} from '../common';
import {Series} from '../series/Series'; import {Series} from '../series/Series';
import {Value} from '../series/Value';
export class Location { export class Location {
powerSelf: Value = Value.NULL;
powerPurchasePercentConsume: Value = Value.NULL;
powerProducePercentConsume: Value = Value.NULL;
powerDeliveryPercentConsume: Value = Value.NULL;
powerDeliveryPercentProduce: Value = Value.NULL;
powerSelfPercentConsume: Value = Value.NULL;
powerSelfPercentProduce: Value = Value.NULL;
constructor( constructor(
readonly id: number, readonly id: number,
readonly name: string, readonly name: string,
readonly latitude: number, readonly latitude: number,
readonly longitude: number, readonly longitude: number,
private _energyPurchase: Series | null, readonly energyPurchase: Series | null,
private _energyDeliver: Series | null, readonly energyDeliver: Series | null,
private _energyProduce: Series | null, readonly energyProduce: Series | null,
private _powerPurchase: Series | null, readonly powerPurchase: Series | null,
private _powerDeliver: Series | null, readonly powerDeliver: Series | null,
private _powerProduce: Series | null, readonly powerProduce: Series | null,
private _outsideTemperature: Series | null, readonly outsideTemperature: Series | null,
private _outsideHumidityRelative: Series | null, readonly outsideHumidityRelative: Series | null,
private _outsideHumidityAbsolute: Series | null, readonly outsideHumidityAbsolute: Series | null,
private _powerConsume: Value = Value.NULL,
) { ) {
this.updateConsume(); //
} }
readonly updateSeries = (series: Series) => {
if (series.equals(this._energyPurchase)) {
this._energyPurchase = series;
}
if (series.equals(this._energyDeliver)) {
this._energyDeliver = series;
}
if (series.equals(this._energyProduce)) {
this._energyProduce = series;
}
if (series.equals(this._powerProduce)) {
this._powerProduce = series;
this.updateConsume();
}
if (series.equals(this._powerPurchase)) {
this._powerPurchase = series;
this.updateConsume();
}
if (series.equals(this._powerDeliver)) {
this._powerDeliver = series;
this.updateConsume();
}
};
static fromJson(json: any): Location { static fromJson(json: any): Location {
return new Location( return new Location(
validateNumber(json.id), validateNumber(json.id),
@ -79,55 +39,18 @@ export class Location {
); );
} }
private updateConsume() { getSeries(): Series[] {
this._powerConsume = Value.ZERO.plus(this._powerPurchase?.value, true).plus(this._powerProduce?.value, true).minus(this._powerDeliver?.value, true); return [
this.powerSelf = Value.ZERO.plus(this.powerProduce?.value.minus(this.powerDeliver?.value, true), true); this.energyPurchase,
this.powerPurchasePercentConsume = Value.ZERO.plus(this.powerPurchase?.value.percent(this.powerConsume, "%", 0), true); this.energyDeliver,
this.powerProducePercentConsume = Value.ZERO.plus(this.powerProduce?.value.percent(this.powerConsume, "%", 0), true); this.energyProduce,
this.powerDeliveryPercentConsume = Value.ZERO.plus(this.powerDeliver?.value.percent(this.powerConsume, "%", 0), true); this.powerPurchase,
this.powerDeliveryPercentProduce = Value.ZERO.plus(this.powerDeliver?.value.percent(this.powerProduce?.value, "%", 0), true); this.powerDeliver,
this.powerSelfPercentConsume = Value.ZERO.plus(this.powerSelf.percent(this.powerConsume, "%", 0), true); this.powerProduce,
this.powerSelfPercentProduce = Value.ZERO.plus(this.powerSelf.percent(this.powerProduce?.value, "%", 0), true); this.outsideTemperature,
} this.outsideHumidityRelative,
this.outsideHumidityAbsolute,
get energyPurchase(): Series | null { ].filter(s => s) as Series[];
return this._energyPurchase;
}
get energyDeliver(): Series | null {
return this._energyDeliver;
}
get energyProduce(): Series | null {
return this._energyProduce;
}
get powerPurchase(): Series | null {
return this._powerPurchase;
}
get powerDeliver(): Series | null {
return this._powerDeliver;
}
get powerProduce(): Series | null {
return this._powerProduce;
}
get powerConsume(): Value | null {
return this._powerConsume;
}
get outsideTemperature(): Series | null {
return this._outsideTemperature;
}
get outsideHumidityRelative(): Series | null {
return this._outsideHumidityRelative;
}
get outsideHumidityAbsolute(): Series | null {
return this._outsideHumidityAbsolute;
} }
} }

View File

@ -2,190 +2,192 @@
<app-location-power [location]="location"></app-location-power> <app-location-power [location]="location"></app-location-power>
<app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offsetDay"> <div class="Section3">
<ng-content #SeriesHistoryHeading> <div class="SectionHeading">
<div style="display: flex; width: 100%"> <div class="SectionHeadingText">
&nbsp; Wettervorhersage
<div (click)="offsetDay += 1">&larr;</div>
&nbsp;
<div (click)="offsetDay = Math.max(0, offsetDay -1)">&rarr;</div>
&nbsp;
<div style="flex: 1">{{ offsetDayTitle() }}</div>
</div> </div>
</ng-content> </div>
<app-weather-component [location]="location"></app-weather-component>
</div>
<app-location-energy [location]="location" [interval]="Interval.DAY" [offset]="offsetDay" unit="⌀W" [factor]="12 * 1000" [maxY]="850" [minY]="-850">
<div style="display: flex; width: 100%; gap: 0.25em;">
<div (click)="offsetDayAdd(+1)">&larr;</div>
<div (click)="offsetDayAdd(-1)">&rarr;</div>
<div>{{ offsetDayTitle() }}</div>
</div>
</app-location-energy> </app-location-energy>
<app-location-energy [location]="location" [interval]="Interval.MONTH" [offset]="offsetMonth"> <app-location-energy [location]="location" [interval]="Interval.MONTH" [offset]="offsetMonth">
<ng-content #SeriesHistoryHeading> <div style="display: flex; width: 100%; gap: 0.25em;">
<div style="display: flex; width: 100%"> <div (click)="offsetMonthAdd(+1)">&larr;</div>
&nbsp; <div (click)="offsetMonthAdd(-1)">&rarr;</div>
<div (click)="offsetMonth += 1">&larr;</div> <div>{{ offsetMonthTitle() }}</div>
&nbsp; </div>
<div (click)="offsetMonth = Math.max(0, offsetMonth -1)">&rarr;</div>
&nbsp;
<div style="flex: 1">{{ offsetMonthTitle() }}</div>
</div>
</ng-content>
</app-location-energy> </app-location-energy>
<div class="Section"> @if (configService.locationConfig) {
<div class="SectionHeading"> <div class="Section">
<div class="SectionHeadingText"> <div class="SectionHeading">
Ort <div class="SectionHeadingText">
</div> Ort
</div>
<div class="SectionBody">
<div class="Section2">
<div class="SectionHeading">
<div class="SectionHeadingText">
Name
</div>
</div>
<div class="SectionBody">
<app-text [initial]="location.name" (onChange)="locationService.name(location, $event)"></app-text>
</div> </div>
</div> </div>
<div class="Section2"> <div class="SectionBody">
<div class="SectionHeading"> <div class="Section2">
<div class="SectionHeadingText"> <div class="SectionHeading">
Breitengrad <div class="SectionHeadingText">
Name
</div>
</div>
<div class="SectionBody">
<app-text [initial]="location.name" (onChange)="locationService.name(location, $event)"></app-text>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-number [initial]="location.latitude" (onChange)="locationService.latitude(location, $event)" unit="°"></app-number> <div class="SectionHeading">
</div> <div class="SectionHeadingText">
</div> Breitengrad
<div class="Section2"> </div>
<div class="SectionHeading"> </div>
<div class="SectionHeadingText"> <div class="SectionBody">
Längengrad <app-number [initial]="location.latitude" (onChange)="locationService.latitude(location, $event)" unit="°"></app-number>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-number [initial]="location.longitude" (onChange)="locationService.longitude(location, $event)" unit="°"></app-number> <div class="SectionHeading">
<div class="SectionHeadingText">
Längengrad
</div>
</div>
<div class="SectionBody">
<app-number [initial]="location.longitude" (onChange)="locationService.longitude(location, $event)" unit="°"></app-number>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="Section"> <div class="Section">
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Energie Energie
</div>
</div>
<div class="SectionBody">
<div class="Section2">
<div class="SectionHeading">
<div class="SectionHeadingText">
Bezug
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.energyPurchase" (onChange)="locationService.energyPurchase(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="SectionBody">
<div class="SectionHeading"> <div class="Section2">
<div class="SectionHeadingText"> <div class="SectionHeading">
Einspeisung <div class="SectionHeadingText">
Bezug
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.energyPurchase" (onChange)="locationService.energyPurchase(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.energyDeliver" (onChange)="locationService.energyDeliver(location, $event)" [filter]="filterEnergy"></app-series-select> <div class="SectionHeading">
</div> <div class="SectionHeadingText">
</div> Einspeisung
<div class="Section2"> </div>
<div class="SectionHeading"> </div>
<div class="SectionHeadingText"> <div class="SectionBody">
Erzeugung <app-series-select [initial]="location.energyDeliver" (onChange)="locationService.energyDeliver(location, $event)" [filter]="filterEnergy"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.energyProduce" (onChange)="locationService.energyProduce(location, $event)" [filter]="filterEnergy"></app-series-select> <div class="SectionHeading">
<div class="SectionHeadingText">
Erzeugung
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.energyProduce" (onChange)="locationService.energyProduce(location, $event)" [filter]="filterEnergy"></app-series-select>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="Section"> <div class="Section">
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Leistung Leistung
</div>
</div>
<div class="SectionBody">
<div class="Section2">
<div class="SectionHeading">
<div class="SectionHeadingText">
Bezug
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.powerPurchase" (onChange)="locationService.powerPurchase(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="SectionBody">
<div class="SectionHeading"> <div class="Section2">
<div class="SectionHeadingText"> <div class="SectionHeading">
Einspeisung <div class="SectionHeadingText">
Bezug
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.powerPurchase" (onChange)="locationService.powerPurchase(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.powerDeliver" (onChange)="locationService.powerDeliver(location, $event)" [filter]="filterPower"></app-series-select> <div class="SectionHeading">
</div> <div class="SectionHeadingText">
</div> Einspeisung
<div class="Section2"> </div>
<div class="SectionHeading"> </div>
<div class="SectionHeadingText"> <div class="SectionBody">
Erzeugung <app-series-select [initial]="location.powerDeliver" (onChange)="locationService.powerDeliver(location, $event)" [filter]="filterPower"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.powerProduce" (onChange)="locationService.powerProduce(location, $event)" [filter]="filterPower"></app-series-select> <div class="SectionHeading">
<div class="SectionHeadingText">
Erzeugung
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.powerProduce" (onChange)="locationService.powerProduce(location, $event)" [filter]="filterPower"></app-series-select>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="Section"> <div class="Section">
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Außen Außen
</div>
</div>
<div class="SectionBody">
<div class="Section2">
<div class="SectionHeading">
<div class="SectionHeadingText">
Temperatur
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.outsideTemperature" (onChange)="locationService.outsideTemperature(location, $event)" [filter]="filterTemperature"></app-series-select>
</div> </div>
</div> </div>
<div class="Section2"> <div class="SectionBody">
<div class="SectionHeading"> <div class="Section2">
<div class="SectionHeadingText"> <div class="SectionHeading">
Relative Luftfeuchte <div class="SectionHeadingText">
Temperatur
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.outsideTemperature" (onChange)="locationService.outsideTemperature(location, $event)" [filter]="filterTemperature"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.outsideHumidityRelative" (onChange)="locationService.outsideHumidityRelative(location, $event)" [filter]="filterHumidityRelative"></app-series-select> <div class="SectionHeading">
</div> <div class="SectionHeadingText">
</div> Relative Luftfeuchte
<div class="Section2"> </div>
<div class="SectionHeading"> </div>
<div class="SectionHeadingText"> <div class="SectionBody">
Absolute Luftfeuchte <app-series-select [initial]="location.outsideHumidityRelative" (onChange)="locationService.outsideHumidityRelative(location, $event)" [filter]="filterHumidityRelative"></app-series-select>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="Section2">
<app-series-select [initial]="location.outsideHumidityAbsolute" (onChange)="locationService.outsideHumidityAbsolute(location, $event)" [filter]="filterHumidityAbsolute"></app-series-select> <div class="SectionHeading">
<div class="SectionHeadingText">
Absolute Luftfeuchte
</div>
</div>
<div class="SectionBody">
<app-series-select [initial]="location.outsideHumidityAbsolute" (onChange)="locationService.outsideHumidityAbsolute(location, $event)" [filter]="filterHumidityAbsolute"></app-series-select>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
}
} }

View File

@ -1,6 +1,6 @@
import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core'; import {Component, Inject, LOCALE_ID, OnDestroy, OnInit} from '@angular/core';
import {LocationService} from '../location-service'; import {LocationService} from '../location-service';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute, Params, Router} from '@angular/router';
import {Location} from '../Location'; import {Location} from '../Location';
import {Text} from '../../shared/text/text'; import {Text} from '../../shared/text/text';
import {Number} from '../../shared/number/number'; import {Number} from '../../shared/number/number';
@ -14,6 +14,20 @@ import {Series} from '../../series/Series';
import {SeriesType} from '../../series/SeriesType'; import {SeriesType} from '../../series/SeriesType';
import {DateService} from '../../date.service'; import {DateService} from '../../date.service';
import {LocationPower} from '../power/location-power'; import {LocationPower} from '../power/location-power';
import {ConfigService} from '../../config.service';
import {WeatherComponent} from '../../weather/plot/weather-component';
export function paramNumberOrNull(params: Params, key: string): number | null {
const param = params[key];
if (param === null || param === undefined) {
return null;
}
const value = parseInt(param);
if (isNaN(value)) {
return null;
}
return value;
}
@Component({ @Component({
selector: 'app-location-detail', selector: 'app-location-detail',
@ -22,7 +36,8 @@ import {LocationPower} from '../power/location-power';
Number, Number,
SeriesSelect, SeriesSelect,
LocationEnergy, LocationEnergy,
LocationPower LocationPower,
WeatherComponent
], ],
templateUrl: './location-detail.html', templateUrl: './location-detail.html',
styleUrl: './location-detail.less', styleUrl: './location-detail.less',
@ -43,6 +58,8 @@ export class LocationDetail implements OnInit, OnDestroy {
protected readonly Math = Math; protected readonly Math = Math;
private locationId: number | null = null;
protected location: Location | null = null; protected location: Location | null = null;
private readonly subs: Subscription [] = []; private readonly subs: Subscription [] = [];
@ -58,27 +75,36 @@ export class LocationDetail implements OnInit, OnDestroy {
readonly activatedRoute: ActivatedRoute, readonly activatedRoute: ActivatedRoute,
readonly menuService: MenuService, readonly menuService: MenuService,
readonly dateService: DateService, readonly dateService: DateService,
readonly configService: ConfigService,
readonly router: Router,
@Inject(LOCALE_ID) readonly locale: string, @Inject(LOCALE_ID) readonly locale: string,
) { ) {
this.datePipe = new DatePipe(locale); this.datePipe = new DatePipe(locale);
} }
ngOnInit(): void { ngOnInit(): void {
this.locationService.id = null; this.subs.push(this.activatedRoute.params.subscribe(params => {
this.subs.push(this.activatedRoute.params.subscribe(params => this.locationService.id = params['id'] || null)); const id = paramNumberOrNull(params, "id");
this.subs.push(this.locationService.location$.subscribe(this.onLocationChange)); if (id === null && this.configService.locationId !== null) {
this.router.navigate(["Location/" + this.configService.locationId]);
return;
}
this.locationId = id;
if (this.locationId) {
this.locationService.getById(this.locationId, this.onLocationChange);
}
}));
this.subs.push(this.locationService.subscribe(this.onLocationChange));
} }
private readonly onLocationChange = (location: Location | null): void => { private readonly onLocationChange = (location: Location | null): void => {
this.location = location; if (this.locationId === location?.id) {
if (this.location) { this.location = location;
this.menuService.title = this.location.name; this.menuService.setLocation(location);
} }
}; };
ngOnDestroy(): void { ngOnDestroy(): void {
this.location = null;
this.menuService.title = "";
this.subs.forEach(sub => sub.unsubscribe()); this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0; this.subs.length = 0;
} }
@ -101,4 +127,12 @@ export class LocationDetail implements OnInit, OnDestroy {
return this.datePipe.transform(d, 'yyyy MMMM') || ''; return this.datePipe.transform(d, 'yyyy MMMM') || '';
} }
protected offsetDayAdd(delta: number) {
this.offsetDay = Math.max(0, this.offsetDay + delta);
}
protected offsetMonthAdd(delta: number) {
this.offsetMonth = Math.max(0, this.offsetMonth + delta);
}
} }

View File

@ -1,4 +1,4 @@
import {Component, Input, ViewChild} from '@angular/core'; import {Component, Inject, Input, LOCALE_ID, OnChanges, ViewChild} from '@angular/core';
import {Interval} from '../../../series/Interval'; import {Interval} from '../../../series/Interval';
import {PointService} from '../../../point/point-service'; import {PointService} from '../../../point/point-service';
import {Location} from '../../Location'; import {Location} from '../../Location';
@ -10,8 +10,10 @@ import {formatNumber} from '@angular/common';
const COLOR_BACK_PURCHASE = "#ffb9b9"; const COLOR_BACK_PURCHASE = "#ffb9b9";
const COLOR_BACK_DELIVER = "#ff59ff"; const COLOR_BACK_DELIVER = "#ff59ff";
// noinspection JSUnusedLocalSymbols
const COLOR_BACK_PRODUCE = "#5cbcff"; const COLOR_BACK_PRODUCE = "#5cbcff";
const COLOR_BACK_SELF = "#60ff8c"; const COLOR_BACK_SELF = "#60ff8c";
// noinspection JSUnusedLocalSymbols
const COLOR_BACK_CONSUME = "#ffc07a"; const COLOR_BACK_CONSUME = "#ffc07a";
@Component({ @Component({
@ -22,83 +24,31 @@ const COLOR_BACK_CONSUME = "#ffc07a";
templateUrl: './energy-charts.html', templateUrl: './energy-charts.html',
styleUrl: './energy-charts.less', styleUrl: './energy-charts.less',
}) })
class EnergyCharts { export class EnergyCharts implements OnChanges {
private _offset!: number | null;
@Input()
set offset(value: number | null) {
this._offset = value;
this.fetch();
}
@ViewChild(BaseChartDirective) @ViewChild(BaseChartDirective)
chart?: BaseChartDirective; chart?: BaseChartDirective;
private _interval!: Interval | null; @Input()
unit: string = "";
@Input() @Input()
set interval(value: Interval | null) { factor: number = 1;
this._interval = value;
this.fetch();
}
private _location!: Location | null;
@Input() @Input()
set location(value: Location | null) { maxY: number | undefined = undefined;
this._location = value;
this.fetch();
}
constructor( @Input()
readonly pointService: PointService, minY: number | undefined = undefined;
) {
//
}
fetch(): void { @Input()
if (!this._location || !this._interval || this._offset == null) { interval!: Interval;
return;
}
const series = [
this._location.energyPurchase,
this._location.energyDeliver,
this._location.energyProduce,
];
const location = this._location;
const interval = this._interval;
const offset = this._offset;
this.pointService.relative(series, interval, offset, 1, interval.inner, result => {
const energyPurchase = result.series.filter(s => s.series.id === location.energyPurchase?.id)[0] || null;
const energyDeliver = result.series.filter(s => s.series.id === location.energyDeliver?.id)[0] || null;
const energyProduce = result.series.filter(s => s.series.id === location.energyProduce?.id)[0] || null;
const energySelf = energyProduce?.merge(energyDeliver, "Energie Selbst", (a, b) => a - b) || null;
this.data.datasets.length = 0;
this.add(energyDeliver, COLOR_BACK_DELIVER, -1, "a");
this.add(energySelf, COLOR_BACK_SELF, 1, "a");
this.add(energyPurchase, COLOR_BACK_PURCHASE, 1, "a");
this.chart?.update();
});
}
private add(pointSeries: PointSeries | null, color: string, factor: number, stack: string) { @Input()
if (!pointSeries) { location!: Location;
return;
} @Input()
this.data.datasets.push({ offset: number = 0;
type: 'bar',
categoryPercentage: 1.0,
barPercentage: 1.0,
data: pointSeries.points.map(p => {
return {x: p[0] * 1000, y: p[1] * factor};
}),
label: `${pointSeries.series.name} [${pointSeries.series.unit}]`,
borderColor: color,
backgroundColor: color,
stack: stack ? stack : undefined,
});
}
protected data: ChartConfiguration['data'] = { protected data: ChartConfiguration['data'] = {
datasets: [], datasets: [],
@ -124,10 +74,7 @@ class EnergyCharts {
}, },
}, },
}, },
y: { y: {},
suggestedMax: 0.5,
suggestedMin: -0.1,
}
}, },
interaction: { interaction: {
mode: 'index', mode: 'index',
@ -142,18 +89,89 @@ class EnergyCharts {
intersect: false, intersect: false,
itemSort: (a, b) => b.datasetIndex - a.datasetIndex, itemSort: (a, b) => b.datasetIndex - a.datasetIndex,
callbacks: { callbacks: {
label: (ctx) => { title: (items) => {
const groups = /^Energie (?<name>.+)\[(?<unit>.+)]$/.exec(ctx.dataset.label || '')?.groups; const date = new Date(items[0].parsed.x || 0);
if (groups) { const d = date.toLocaleString(this.locale, {
return `${groups['name']}: ${ctx.parsed.y === null ? '-' : formatNumber(ctx.parsed.y, 'de-DE', '0.2-2')} ${groups['unit']}`; dateStyle: 'long',
} });
return ctx.label; const t = date.toLocaleString(this.locale, {
timeStyle: 'long',
});
return `${d}\n${t}`;
}, },
label: ((ctx: any) => {
const groups = /^.*Energie (?<name>.+)\[(?<unit>.+)]$/.exec(ctx.dataset.label || '')?.groups;
if (groups) {
const value = ctx.parsed.y === null ? '-' : formatNumber(Math.abs(ctx.parsed.y), this.locale, '0.2-2');
return `${value} ${groups['unit']} ${groups['name']}`;
}
return ctx.dataset.label;
}) as any,
}, },
}, },
}, },
}; };
} private refreshTimeout: number | undefined;
export default EnergyCharts constructor(
readonly pointService: PointService,
@Inject(LOCALE_ID) readonly locale: string,
) {
//
}
ngOnChanges(): void {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = setTimeout(() => this.ngOnChanges(), 60 * 1000);
const series = [
this.location.energyPurchase,
this.location.energyDeliver,
this.location.energyProduce,
];
const location = this.location;
const interval = this.interval;
const offset = this.offset;
this.pointService.relative(series, interval, offset, 1, interval.inner, result => {
this.data.datasets.length = 0;
const xScale = this.chart?.chart?.options?.scales?.['x'];
if (xScale) {
xScale.min = result.begin.getTime();
xScale.max = result.end.getTime() - (this.interval?.inner?.millis || 0);
}
const yScale = this.chart?.chart?.options?.scales?.['y'];
if (yScale) {
yScale.max = this.maxY;
yScale.min = this.minY;
}
const energyPurchase = result.series.filter(s => s.series.id === location.energyPurchase?.id)[0] || null;
const energyDeliver = result.series.filter(s => s.series.id === location.energyDeliver?.id)[0] || null;
const energyProduce = result.series.filter(s => s.series.id === location.energyProduce?.id)[0] || null;
const energySelf = energyProduce?.merge(energyDeliver, "Energie Selbst", (a, b) => a - b) || null;
this.add(energyDeliver, COLOR_BACK_DELIVER, -1, "a");
this.add(energySelf, COLOR_BACK_SELF, 1, "a");
this.add(energyPurchase, COLOR_BACK_PURCHASE, 1, "a");
this.chart?.chart?.update();
});
}
private add(pointSeries: PointSeries | null, color: string, factor: number, stack: string) {
if (!pointSeries) {
return;
}
this.data.datasets.push({
type: 'bar',
categoryPercentage: 1.0,
barPercentage: 0.95,
data: pointSeries.points.map(p => {
return {x: p[0] * 1000, y: p[1] * factor * this.factor};
}),
label: `${pointSeries.series.name} [${this.unit || pointSeries.series.unit}]`,
borderColor: color,
backgroundColor: color,
stack: stack ? stack : undefined,
});
}
}

View File

@ -2,7 +2,7 @@
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
{{ heading }} {{ heading }}
<ng-content #SeriesHistoryHeading></ng-content> <ng-content></ng-content>
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
@ -14,10 +14,12 @@
<div class="SectionBody COLOR_FONT_PURCHASE"> <div class="SectionBody COLOR_FONT_PURCHASE">
{{ purchase.toValueString(null) }} {{ purchase.toValueString(null) }}
</div> </div>
<div class="SectionBody COLOR_FONT_PURCHASE percent"> @if (configService.energyPercent) {
{{ purchasePercentConsume.toValueString(null) }} <div class="SectionBody COLOR_FONT_PURCHASE percent">
<sub class="subscript">Verbrauch</sub> {{ purchasePercentConsume.toValueString(null) }}
</div> <sub class="subscript">Verbrauch</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -27,10 +29,12 @@
<div class="SectionBody COLOR_FONT_PRODUCE"> <div class="SectionBody COLOR_FONT_PRODUCE">
{{ produce.toValueString(null) }} {{ produce.toValueString(null) }}
</div> </div>
<div class="SectionBody COLOR_FONT_PRODUCE percent"> @if (configService.energyPercent) {
{{ producePercentConsume.toValueString(null) }} <div class="SectionBody COLOR_FONT_PRODUCE percent">
<sub class="subscript">Verbrauch</sub> {{ producePercentConsume.toValueString(null) }}
</div> <sub class="subscript">Verbrauch</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -40,14 +44,16 @@
<div class="SectionBody COLOR_FONT_SELF"> <div class="SectionBody COLOR_FONT_SELF">
{{ self.toValueString(null) }} {{ self.toValueString(null) }}
</div> </div>
<div class="SectionBody COLOR_FONT_SELF percent"> @if (configService.energyPercent) {
{{ selfPercentConsume.toValueString(null) }} <div class="SectionBody COLOR_FONT_SELF percent">
<sub class="subscript">Verbrauch</sub> {{ selfPercentConsume.toValueString(null) }}
</div> <sub class="subscript">Verbrauch</sub>
<div class="SectionBody COLOR_FONT_SELF percent"> </div>
{{ selfPercentProduce.toValueString(null) }} <div class="SectionBody COLOR_FONT_SELF percent">
<sub class="subscript">Produktion</sub> {{ selfPercentProduce.toValueString(null) }}
</div> <sub class="subscript">Produktion</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -66,18 +72,20 @@
<div class="SectionBody COLOR_FONT_DELIVER"> <div class="SectionBody COLOR_FONT_DELIVER">
{{ deliver.toValueString(null) }} {{ deliver.toValueString(null) }}
</div> </div>
<div class="SectionBody COLOR_FONT_DELIVER percent"> @if (configService.energyPercent) {
{{ deliveryPercentConsume.toValueString(null) }} <div class="SectionBody COLOR_FONT_DELIVER percent">
<sub class="subscript">Verbrauch</sub> {{ deliveryPercentConsume.toValueString(null) }}
</div> <sub class="subscript">Verbrauch</sub>
<div class="SectionBody COLOR_FONT_DELIVER percent"> </div>
{{ deliveryPercentProduce.toValueString(null) }} <div class="SectionBody COLOR_FONT_DELIVER percent">
<sub class="subscript">Produktion</sub> {{ deliveryPercentProduce.toValueString(null) }}
</div> <sub class="subscript">Produktion</sub>
</div>
}
</div> </div>
</div> </div>
<app-energy-charts [location]="location" [interval]="interval" [offset]="offset"></app-energy-charts> <app-energy-charts #chart [location]="location" [interval]="interval" [offset]="offset" [unit]="unit" [factor]="factor" [maxY]="maxY" [minY]="minY"></app-energy-charts>
</div> </div>

View File

@ -1,4 +1,4 @@
import {AfterViewInit, Component, Input, OnDestroy, OnInit, signal} from '@angular/core'; import {Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges} from '@angular/core';
import {Location} from '../Location'; import {Location} from '../Location';
import {Series} from '../../series/Series'; import {Series} from '../../series/Series';
import {Next} from '../../common'; import {Next} from '../../common';
@ -7,20 +7,18 @@ import {PointService} from '../../point/point-service';
import {SeriesService} from '../../series/series-service'; import {SeriesService} from '../../series/series-service';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {Value} from '../../series/Value'; import {Value} from '../../series/Value';
import EnergyCharts from './charts/energy-charts'; import {ConfigService} from '../../config.service';
import {EnergyCharts} from './charts/energy-charts';
@Component({ @Component({
selector: 'app-location-energy', selector: 'app-location-energy',
imports: [ imports: [
EnergyCharts, EnergyCharts,
EnergyCharts
], ],
templateUrl: './location-energy.html', templateUrl: './location-energy.html',
styleUrl: './location-energy.less', styleUrl: './location-energy.less',
}) })
export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy { export class LocationEnergy implements OnInit, OnChanges, OnDestroy {
protected readonly signal = signal;
protected readonly Interval = Interval; protected readonly Interval = Interval;
@ -51,17 +49,20 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
@Input() @Input()
heading!: string; heading!: string;
private _o_: number = 0; @Input()
offset: number = 0;
@Input() @Input()
set offset(value: number) { unit: string = "";
this._o_ = value;
this.ngAfterViewInit();
}
get offset(): number { @Input()
return this._o_; factor: number = 1;
}
@Input()
maxY: number | undefined = undefined;
@Input()
minY: number | undefined = undefined;
@Input() @Input()
interval!: Interval; interval!: Interval;
@ -75,6 +76,7 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
constructor( constructor(
readonly pointService: PointService, readonly pointService: PointService,
readonly serieService: SeriesService, readonly serieService: SeriesService,
readonly configService: ConfigService,
) { ) {
// //
} }
@ -83,7 +85,7 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
this.subs.push(this.serieService.subscribe(this.update)); this.subs.push(this.serieService.subscribe(this.update));
} }
ngAfterViewInit(): void { ngOnChanges(changes: SimpleChanges): void {
this.fetch(null, this.location?.energyPurchase, history => this.purchase = history); this.fetch(null, this.location?.energyPurchase, history => this.purchase = history);
this.fetch(null, this.location?.energyDeliver, history => this.deliver = history); this.fetch(null, this.location?.energyDeliver, history => this.deliver = history);
this.fetch(null, this.location?.energyProduce, history => this.produce = history); this.fetch(null, this.location?.energyProduce, history => this.produce = history);
@ -91,6 +93,7 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe()); this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
} }
protected readonly update = (fresh: Series): void => { protected readonly update = (fresh: Series): void => {
@ -107,12 +110,12 @@ export class LocationEnergy implements OnInit, AfterViewInit, OnDestroy {
next(value); next(value);
this.consume = this.purchase.plus(this.produce, true).minus(this.deliver, true); this.consume = this.purchase.plus(this.produce, true).minus(this.deliver, true);
this.self = this.produce.minus(this.deliver, true); this.self = this.produce.minus(this.deliver, true);
this.purchasePercentConsume = this.purchase.percent(this.consume, "%", 0); this.purchasePercentConsume = this.purchase.percent(this.consume);
this.producePercentConsume = this.produce.percent(this.consume, "%", 0); this.producePercentConsume = this.produce.percent(this.consume);
this.deliveryPercentConsume = this.deliver.percent(this.consume, "%", 0); this.deliveryPercentConsume = this.deliver.percent(this.consume);
this.deliveryPercentProduce = this.deliver.percent(this.produce, "%", 0); this.deliveryPercentProduce = this.deliver.percent(this.produce);
this.selfPercentConsume = this.self.percent(this.consume, "%", 0); this.selfPercentConsume = this.self.percent(this.consume);
this.selfPercentProduce = this.self.percent(this.produce, "%", 0); this.selfPercentProduce = this.self.percent(this.produce);
}; };
if (fresh !== null && fresh !== undefined) { if (fresh !== null && fresh !== undefined) {
if (fresh.id !== series?.id) { if (fresh.id !== series?.id) {

View File

@ -1,7 +0,0 @@
<div class="List LocationList NoUserSelect">
@for (location of list; track location.id) {
<div class="ListItem Location" routerLink="/Location/{{location.id}}">
{{ location.name }}
</div>
}
</div>

View File

@ -1,35 +0,0 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {LocationService} from '../location-service';
import {Location} from '../Location';
import {RouterLink} from '@angular/router';
import {MenuService} from '../../menu-service';
@Component({
selector: 'app-location-list',
imports: [
RouterLink
],
templateUrl: './location-list.html',
styleUrl: './location-list.less',
})
export class LocationList implements OnInit, OnDestroy {
protected list: Location[] = [];
constructor(
readonly locationService: LocationService,
readonly menuService: MenuService,
) {
//
}
ngOnInit(): void {
this.locationService.findAll(list => this.list = list);
this.menuService.title = "Orte";
}
ngOnDestroy(): void {
this.menuService.title = "";
}
}

View File

@ -1,44 +1,17 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common'; import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {Location} from './Location' import {Location} from './Location'
import {SeriesService} from '../series/series-service';
import {BehaviorSubject, Observable} from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class LocationService extends CrudService<Location> { export class LocationService extends CrudService<Location> {
private readonly _location = new BehaviorSubject<Location | null>(null);
private _id: number | null = null;
constructor( constructor(
api: ApiService, api: ApiService,
ws: WebsocketService, ws: WebsocketService,
readonly seriesService: SeriesService,
) { ) {
super(api, ws, ['Location'], Location.fromJson); super(api, ws, ['Location'], Location.fromJson);
this.seriesService.subscribe(series => this._location.value?.updateSeries(series));
this.ws.onConnect(this.fetch);
this.ws.onDisconnect(() => this._location.next(null));
}
set id(id: number | null) {
this._id = id;
this.fetch();
}
private readonly fetch = () => {
if (this._id === null) {
this._location.next(null);
} else {
this.getById(this._id, location => this._location.next(location));
}
};
get location$(): Observable<Location | null> {
return this._location.asObservable();
} }
findAll(next: Next<Location[]>) { findAll(next: Next<Location[]>) {

View File

@ -1,7 +1,7 @@
<div class="Section3"> <div class="Section3">
<div class="SectionHeading"> <div class="SectionHeading">
<div class="SectionHeadingText"> <div class="SectionHeadingText">
Aktuelle Leistung Aktuell
</div> </div>
</div> </div>
<div class="SectionBody"> <div class="SectionBody">
@ -11,12 +11,14 @@
Bezug Bezug
</div> </div>
<div class="SectionBody COLOR_FONT_PURCHASE"> <div class="SectionBody COLOR_FONT_PURCHASE">
{{ location.powerPurchase?.value?.toValueString(dateService.now) }} {{ powerPurchase.toValueString(dateService.now) }}
</div>
<div class="SectionBody COLOR_FONT_PURCHASE percent">
{{ location.powerPurchasePercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div> </div>
@if (configService.energyPercent) {
<div class="SectionBody COLOR_FONT_PURCHASE percent">
{{ powerPurchasePercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -24,12 +26,14 @@
Solar Solar
</div> </div>
<div class="SectionBody COLOR_FONT_PRODUCE"> <div class="SectionBody COLOR_FONT_PRODUCE">
{{ location.powerProduce?.value?.toValueString(dateService.now) }} {{ powerProduce.toValueString(dateService.now) }}
</div>
<div class="SectionBody COLOR_FONT_PRODUCE percent">
{{ location.powerProducePercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div> </div>
@if (configService.energyPercent) {
<div class="SectionBody COLOR_FONT_PRODUCE percent">
{{ powerProducePercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -37,16 +41,18 @@
Selbst Selbst
</div> </div>
<div class="SectionBody COLOR_FONT_SELF"> <div class="SectionBody COLOR_FONT_SELF">
{{ location.powerSelf.toValueString(dateService.now) }} {{ powerSelf.toValueString(dateService.now) }}
</div>
<div class="SectionBody COLOR_FONT_SELF percent">
{{ location.powerSelfPercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
<div class="SectionBody COLOR_FONT_SELF percent">
{{ location.powerSelfPercentProduce.toValueString(dateService.now) }}
<sub class="subscript">Produktion</sub>
</div> </div>
@if (configService.energyPercent) {
<div class="SectionBody COLOR_FONT_SELF percent">
{{ powerSelfPercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
<div class="SectionBody COLOR_FONT_SELF percent">
{{ powerSelfPercentProduce.toValueString(dateService.now) }}
<sub class="subscript">Produktion</sub>
</div>
}
</div> </div>
<div class="Section4"> <div class="Section4">
@ -54,7 +60,7 @@
Verbrauch Verbrauch
</div> </div>
<div class="SectionBody COLOR_FONT_CONSUME"> <div class="SectionBody COLOR_FONT_CONSUME">
{{ location.powerConsume?.toValueString(dateService.now) }} {{ powerConsume.toValueString(dateService.now) }}
</div> </div>
</div> </div>
@ -63,16 +69,18 @@
Einspeisung Einspeisung
</div> </div>
<div class="SectionBody COLOR_FONT_DELIVER"> <div class="SectionBody COLOR_FONT_DELIVER">
{{ location.powerDeliver?.value?.toValueString(dateService.now) }} {{ powerDeliver.toValueString(dateService.now) }}
</div>
<div class="SectionBody COLOR_FONT_DELIVER percent">
{{ location.powerDeliveryPercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
<div class="SectionBody COLOR_FONT_DELIVER percent">
{{ location.powerDeliveryPercentProduce.toValueString(dateService.now) }}
<sub class="subscript">Produktion</sub>
</div> </div>
@if (configService.energyPercent) {
<div class="SectionBody COLOR_FONT_DELIVER percent">
{{ powerDeliverPercentConsume.toValueString(dateService.now) }}
<sub class="subscript">Verbrauch</sub>
</div>
<div class="SectionBody COLOR_FONT_DELIVER percent">
{{ powerDeliverPercentProduce.toValueString(dateService.now) }}
<sub class="subscript">Produktion</sub>
</div>
}
</div> </div>
</div> </div>

View File

@ -1,6 +1,11 @@
import {Component, Input} from '@angular/core'; import {Component, Input, OnChanges, OnDestroy, OnInit} from '@angular/core';
import {Location} from '../Location'; import {Location} from '../Location';
import {DateService} from '../../date.service'; import {DateService} from '../../date.service';
import {ConfigService} from '../../config.service';
import {Value} from '../../series/Value';
import {SeriesService} from '../../series/series-service';
import {Subscription} from 'rxjs';
import {Series} from '../../series/Series';
@Component({ @Component({
selector: 'app-location-power', selector: 'app-location-power',
@ -8,15 +13,86 @@ import {DateService} from '../../date.service';
templateUrl: './location-power.html', templateUrl: './location-power.html',
styleUrl: './location-power.less', styleUrl: './location-power.less',
}) })
export class LocationPower { export class LocationPower implements OnInit, OnChanges, OnDestroy {
@Input() @Input()
location!: Location; location!: Location;
private readonly subs: Subscription[] = [];
protected energyPurchase: Value = Value.NULL;
protected energyDeliver: Value = Value.NULL;
protected energyProduce: Value = Value.NULL;
protected powerPurchase: Value = Value.NULL;
protected powerDeliver: Value = Value.NULL;
protected powerProduce: Value = Value.NULL;
protected outsideTemperature: Value = Value.NULL;
protected outsideHumidityRelative: Value = Value.NULL;
protected outsideHumidityAbsolute: Value = Value.NULL;
protected powerConsume: Value = Value.NULL;
protected powerSelf: Value = Value.NULL;
protected powerPurchasePercentConsume: Value = Value.NULL;
protected powerProducePercentConsume: Value = Value.NULL;
protected powerDeliverPercentConsume: Value = Value.NULL;
protected powerDeliverPercentProduce: Value = Value.NULL;
protected powerSelfPercentConsume: Value = Value.NULL;
protected powerSelfPercentProduce: Value = Value.NULL;
constructor( constructor(
readonly dateService: DateService, readonly dateService: DateService,
readonly configService: ConfigService,
readonly seriesService: SeriesService,
) { ) {
// //
} }
ngOnInit(): void {
this.subs.push(this.seriesService.subscribe(this.seriesUpdate));
}
ngOnChanges(): void {
this.location.getSeries().forEach(this.seriesUpdate);
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
this.subs.length = 0;
}
private readonly seriesUpdate = (series: Series): void => {
if (series.id === this.location.energyPurchase?.id) this.energyPurchase = series.value;
if (series.id === this.location.energyDeliver?.id) this.energyDeliver = series.value;
if (series.id === this.location.energyProduce?.id) this.energyProduce = series.value;
if (series.id === this.location.powerPurchase?.id) this.powerPurchase = series.value;
if (series.id === this.location.powerDeliver?.id) this.powerDeliver = series.value;
if (series.id === this.location.powerProduce?.id) this.powerProduce = series.value;
if (series.id === this.location.outsideTemperature?.id) this.outsideTemperature = series.value;
if (series.id === this.location.outsideHumidityRelative?.id) this.outsideHumidityRelative = series.value;
if (series.id === this.location.outsideHumidityAbsolute?.id) this.outsideHumidityAbsolute = series.value;
this.powerSelf = Value.ZERO.plus(this.powerProduce).minus(this.powerDeliver);
this.powerConsume = Value.ZERO.plus(this.powerPurchase).plus(this.powerSelf);
this.powerPurchasePercentConsume = this.powerPurchase.percent(this.powerConsume);
this.powerProducePercentConsume = this.powerProduce.percent(this.powerConsume);
this.powerDeliverPercentConsume = this.powerDeliver.percent(this.powerConsume)
this.powerDeliverPercentProduce = this.powerDeliver.percent(this.powerProduce)
this.powerSelfPercentConsume = this.powerSelf.percent(this.powerConsume);
this.powerSelfPercentProduce = this.powerSelf.percent(this.powerProduce);
};
} }

View File

@ -0,0 +1,18 @@
<div
class="container NoUserSelect"
[ngClass]="classes()"
(mouseenter)="showPen = true"
(mouseleave)="showPen = false"
>
<select [(ngModel)]="model" (ngModelChange)="changed($event)">
<option [ngValue]="null">-</option>
@for (location of locations; track location.id) {
<option [ngValue]="location.id">
{{ location.name }}
</option>
}
</select>
@if (showPen) {
<fa-icon [icon]="faPen"></fa-icon>
}
</div>

View File

@ -0,0 +1,25 @@
.container {
display: flex;
.value {
flex: 1;
}
}
.container:hover {
background-color: #0002;
}
select {
all: unset;
width: 100%;
}
.invalid {
background-color: red !important;
}
.changed {
background-color: yellow !important;
}

View File

@ -0,0 +1,80 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {FormsModule} from '@angular/forms';
import {NgClass} from '@angular/common';
import {faPen} from '@fortawesome/free-solid-svg-icons';
import {Location} from '../Location';
import {LocationService} from '../location-service';
import {DateService} from '../../date.service';
@Component({
selector: 'app-location-select',
imports: [
FaIconComponent,
FormsModule,
NgClass
],
templateUrl: './location-select.html',
styleUrl: './location-select.less',
})
export class LocationSelect implements OnInit {
protected readonly faPen = faPen;
private _initial: number | null = null;
@Input()
locations!: Location[];
@Input()
allowEmpty: boolean = true;
@Input()
filter: (location: Location) => boolean = () => true;
@Output()
readonly onChange = new EventEmitter<number | null>();
protected showPen: boolean = false;
protected model: number | null = null;
protected readonly Location = Location;
constructor(
readonly locationService: LocationService,
readonly dateService: DateService,
) {
//
}
ngOnInit(): void {
this.locationService.findAll(list => this.locations = list);
}
@Input()
set initial(value: number | null) {
this._initial = value;
this.reset();
}
private readonly reset = (): void => {
this.model = this._initial;
};
protected classes(): {} {
return {
"changed": this.model !== this._initial,
"invalid": !this.allowEmpty && this.model === null,
};
}
protected changed(id: number | null) {
if (this.allowEmpty || id !== null) {
this.onChange.emit(id);
} else {
this.reset();
}
}
}

View File

@ -1,10 +1,31 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {Location} from './location/Location';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class MenuService { export class MenuService {
title: string = ""; private _locationId: number | null = null;
private _title: string = "";
get locationId(): number | null {
return this._locationId;
}
get title(): string {
return this._title;
}
setLocation(location: Location): void {
this._locationId = location.id;
this._title = location.name;
}
setNonLocation(title: string) {
this._title = title;
this._locationId = null;
}
} }

View File

@ -1,19 +1,20 @@
export class Interval { export class Interval {
static readonly FIVE = new Interval('FIVE'); static readonly FIVE = new Interval('FIVE', 5 * 60 * 1000);
static readonly HOUR = new Interval('HOUR', this.FIVE); static readonly HOUR = new Interval('HOUR', 60 * 60 * 1000, this.FIVE);
static readonly DAY = new Interval('DAY', this.FIVE); static readonly DAY = new Interval('DAY', 24 * 60 * 60 * 1000, this.FIVE);
static readonly WEEK = new Interval('WEEK', this.HOUR); static readonly WEEK = new Interval('WEEK', 7 * 24 * 60 * 60 * 1000, this.HOUR);
static readonly MONTH = new Interval('MONTH', this.DAY); static readonly MONTH = new Interval('MONTH', 30 * 24 * 60 * 60 * 1000, this.DAY);
static readonly YEAR = new Interval('YEAR', this.DAY); static readonly YEAR = new Interval('YEAR', 365 * 24 * 60 * 60 * 1000, this.DAY);
constructor( constructor(
readonly name: string, readonly name: string,
readonly millis: number,
readonly inner: Interval = this, readonly inner: Interval = this,
) { ) {
// //

View File

@ -0,0 +1,22 @@
import {Series} from "./Series";
import {validateList} from "../common";
export class SeriesListResponse {
constructor(
readonly series: Series[]
) {
//
}
static fromJson(json: any): SeriesListResponse {
return new SeriesListResponse(
validateList(json, Series.fromJson),
);
}
findSeries(series: Series | null): Series | null {
return series ? this.series.filter(s => s.id === series.id)[0] || null : null;
}
}

View File

@ -29,7 +29,7 @@ export class Value {
return `--- ${this.unit}` return `--- ${this.unit}`
} }
const scale = Math.floor(Math.log10(this.value)); const scale = Math.floor(Math.log10(this.value));
if(isNaN(scale)) { if (isNaN(scale)) {
return '0'; return '0';
} }
const rest = scale - this.precision + 1; const rest = scale - this.precision + 1;
@ -39,14 +39,14 @@ export class Value {
return formatNumber(this.value, "de-DE", `0.${-rest}-${-rest}`) + ' ' + this.unit; return formatNumber(this.value, "de-DE", `0.${-rest}-${-rest}`) + ' ' + this.unit;
} }
plus(other: Value | null | undefined, nullToZero: boolean): Value { plus(other: Value | null | undefined, nullToZero: boolean = true): Value {
if (!nullToZero && (other === null || other === undefined)) { if (!nullToZero && (other === null || other === undefined)) {
return Value.NULL; return Value.NULL;
} }
return new BiValue(this, other || Value.ZERO, (a, b) => a + b); return new BiValue(this, other || Value.ZERO, (a, b) => a + b);
} }
minus(other: Value | null | undefined, nullToZero: boolean): Value { minus(other: Value | null | undefined, nullToZero: boolean = true): Value {
if (!nullToZero && (other === null || other === undefined)) { if (!nullToZero && (other === null || other === undefined)) {
return Value.NULL; return Value.NULL;
} }
@ -86,11 +86,11 @@ export class Value {
return ageSeconds > this.seconds * 2.1; return ageSeconds > this.seconds * 2.1;
} }
percent(other: Value | null | undefined, unit: string | null = null, precision: number | null = null): Value { percent(other: Value | null | undefined): Value {
if (other === null || other === undefined) { if (other === null || other === undefined) {
return Value.NULL; return Value.NULL;
} }
return new BiValue(this, other, (a, b) => a / b * 100, unit, precision); return new BiValue(this, other, (a, b) => a / b * 100, '%', 0);
} }
} }

View File

@ -16,10 +16,6 @@ select {
width: 100%; width: 100%;
} }
.unchanged {
background-color: lightgreen !important;
}
.invalid { .invalid {
background-color: red !important; background-color: red !important;
} }

View File

@ -78,7 +78,6 @@ export class SeriesSelect implements OnInit, OnDestroy {
protected classes(): {} { protected classes(): {} {
return { return {
"unchanged": this.model === this._initial,
"changed": this.model !== or(this._initial, i => i.id, null), "changed": this.model !== or(this._initial, i => i.id, null),
"invalid": !this.allowEmpty && this.model === null, "invalid": !this.allowEmpty && this.model === null,
}; };

View File

@ -0,0 +1,22 @@
<div class="Section3">
<div class="SectionHeading">
<div class="SectionHeadingText">
Favorit
</div>
</div>
<div>
<app-location-select [initial]="configService.locationId" (onChange)="configService.locationId = $event"></app-location-select>
</div>
</div>
<div class="Section3">
<div class="SectionHeading">
<div class="SectionHeadingText">
Anzeige
</div>
</div>
<div>
<app-checkbox [initial]="configService.energyPercent" (onChange)="configService.energyPercent = $event" label="Energie Prozentsätze"></app-checkbox>
<app-checkbox [initial]="configService.locationConfig" (onChange)="configService.locationConfig = $event" label="Ort Konfiguration"></app-checkbox>
</div>
</div>

View File

@ -0,0 +1,34 @@
import {Component, OnInit} from '@angular/core';
import {LocationSelect} from '../location/select/location-select';
import {Location} from '../location/Location'
import {ConfigService} from '../config.service';
import {FormsModule} from '@angular/forms';
import {Checkbox} from '../shared/checkbox/checkbox';
import {MenuService} from '../menu-service';
@Component({
selector: 'app-settings-component',
imports: [
LocationSelect,
FormsModule,
Checkbox
],
templateUrl: './settings-component.html',
styleUrl: './settings-component.less',
})
export class SettingsComponent implements OnInit {
protected location: Location | null = null;
constructor(
readonly configService: ConfigService,
readonly menuService: MenuService,
) {
//
}
ngOnInit(): void {
this.menuService.setNonLocation("Einstellungen");
}
}

View File

@ -0,0 +1,12 @@
<div class="all" (click)="onChange.emit(!initial)">
<div class="box">
@if (initial) {
<div class="inside">
&nbsp;
</div>
}
</div>
<div class="label">
{{ label }}
</div>
</div>

View File

@ -0,0 +1,21 @@
.all {
display: flex;
.box {
height: 1em;
aspect-ratio: 1;
border: 1px solid lightgray;
padding: 0.1em;
margin: 0.2em 0.2em 0.2em 0;
.inside {
width: 100%;
height: 100%;
background-color: steelblue;
}
}
.label {
flex: 1;
}
}

View File

@ -0,0 +1,20 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
@Component({
selector: 'app-checkbox',
imports: [],
templateUrl: './checkbox.html',
styleUrl: './checkbox.less',
})
export class Checkbox {
@Input()
initial: boolean = false;
@Input()
label: string = "";
@Output()
readonly onChange: EventEmitter<boolean> = new EventEmitter();
}

View File

@ -0,0 +1,25 @@
import {validateDate, validateNumber} from '../common';
export class WeatherHour {
constructor(
readonly date: Date,
readonly clouds: number,
readonly irradiation: number,
readonly precipitation: number,
readonly temperature: number,
) {
//
}
static fromJson(json: any): WeatherHour {
return new WeatherHour(
validateDate(json.date),
validateNumber(json.clouds),
validateNumber(json.irradiation),
validateNumber(json.precipitation),
validateNumber(json.temperature),
);
}
}

View File

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

View File

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

View File

@ -0,0 +1,238 @@
import {AfterViewInit, Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild} from '@angular/core';
import {WeatherService} from '../weather-service';
import {WeatherHour} from '../WeatherHour';
import {Chart} from 'chart.js';
import {de} from 'date-fns/locale';
import {format} from 'date-fns';
import {Location} from '../../location/Location';
import {formatNumber} from '@angular/common';
export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } {
return (hour: WeatherHour) => {
return {
x: hour.date.getTime(),
y: f(hour),
};
};
}
export function temperatureColor(value: number | null | undefined) {
if (!value) {
return "black";
} else if (value < 0) {
return 'blue';
} else if (value < 20) {
return '#c1b100';
} else if (value < 30) {
return '#ff8100';
}
return TEMPERATURE_HIGH_COLOR;
}
const TEMPERATURE_HIGH_COLOR = '#FF0000';
const PRECIPITATION_COLOR = '#0000FF';
const CLOUDS_COLOR = '#cccccc';
const SUN_COLOR = '#ffc400';
@Component({
selector: 'app-weather-component',
imports: [],
templateUrl: './weather-component.html',
styleUrl: './weather-component.less'
})
export class WeatherComponent implements AfterViewInit {
@ViewChild('chartCanvas')
protected canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('container')
protected chartContainer!: ElementRef<HTMLDivElement>;
private chart!: Chart;
@Input()
location!: Location;
constructor(
@Inject(LOCALE_ID) readonly locale: string,
readonly weatherService: WeatherService,
) {
//
}
ngAfterViewInit(): void {
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
itemSort: (a, b) => b.datasetIndex - a.datasetIndex,
usePointStyle: true,
callbacks: {
title: function (items) {
const date = items[0].parsed.x as unknown as Date;
return format(date, 'EE dd.MM. HH:mm', {locale: de});
},
label: ((ctx: any) => {
const groups = /^(?<name>.+)\[(?<unit>.+)]$/.exec(ctx.dataset.label || '')?.groups;
if (groups) {
const value = ctx.parsed.y === null ? '-' : formatNumber(Math.abs(ctx.parsed.y), this.locale, '0.2-2');
return `${value} ${groups['unit']} ${groups['name']}`;
}
return ctx.dataset.label;
}) as any,
},
},
title: {
display: true,
text: "Wetter",
}
},
scales: {
x: {
type: 'time',
time: {
unit: "day",
displayFormats: {
day: "EE dd.MM"
}
},
adapters: {
date: {
locale: de,
}
},
},
y_temperature: {
display: true,
position: "right",
title: {
display: false,
text: "Temperatur [°C]",
color: TEMPERATURE_HIGH_COLOR,
},
ticks: {
color: context => temperatureColor(context.tick.value),
},
min: -10,
max: 35,
},
y_precipitation: {
display: false,
grid: {
display: false,
},
title: {
display: false,
text: "Niederschlag [mm]",
color: PRECIPITATION_COLOR,
},
ticks: {
color: PRECIPITATION_COLOR,
},
min: 0,
max: 15,
},
y_sun: {
display: false,
grid: {
display: false,
},
position: "right",
title: {
display: false,
text: "Sonne [kWh/m²]",
color: SUN_COLOR,
},
ticks: {
color: SUN_COLOR,
},
min: 0,
max: 1000,
},
y_clouds: {
display: false,
title: {
display: true,
text: "Bewölkung [%]",
color: CLOUDS_COLOR,
},
ticks: {
color: CLOUDS_COLOR,
},
min: 0,
max: 100,
},
},
},
});
this.weatherService.forLocation(this.location, hours => {
const now = Date.now();
const filtered = hours.filter(h => h.date.getTime() >= now);
this.chart.data.datasets.push({
label: "Niederschlag [mm]",
categoryPercentage: 1.0,
barPercentage: 1.0,
type: "bar",
yAxisID: "y_precipitation",
data: filtered.map(toPoint(h => h.precipitation)),
borderColor: PRECIPITATION_COLOR,
backgroundColor: PRECIPITATION_COLOR + '66',
borderWidth: 0,
pointStyle: "rect",
});
this.chart.data.datasets.push({
label: "Sonne [W/m²]",
type: "line",
fill: "origin",
yAxisID: "y_sun",
data: filtered.map(toPoint(h => h.irradiation)),
borderColor: SUN_COLOR,
backgroundColor: SUN_COLOR + '88',
borderWidth: 0,
pointRadius: 0,
});
this.chart.data.datasets.push({
label: "Temperatur [°C]",
type: "line",
yAxisID: "y_temperature",
data: filtered.map(toPoint(h => h.temperature)),
backgroundColor: TEMPERATURE_HIGH_COLOR + '66',
borderWidth: 0,
pointRadius: 1,
pointBackgroundColor: context => temperatureColor(context.parsed.y),
pointStyle: "point",
});
this.chart.data.datasets.push({
label: "Bewölkung [%]",
type: "line",
fill: "origin",
yAxisID: "y_clouds",
data: filtered.map(toPoint(h => h.clouds)),
borderColor: CLOUDS_COLOR,
backgroundColor: CLOUDS_COLOR + '88',
borderWidth: 0,
pointRadius: 0,
});
this.chart.update();
});
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {WeatherHour} from './WeatherHour';
import {Location} from '../location/Location';
@Injectable({
providedIn: 'root'
})
export class WeatherService extends CrudService<WeatherHour> {
constructor(
api: ApiService,
ws: WebsocketService,
) {
super(api, ws, ['Weather'], WeatherHour.fromJson);
}
forLocation(location: Location, next: Next<WeatherHour[]>) {
this.getList(['forLocation', location.id], next);
}
}

View File

@ -62,7 +62,8 @@ div {
} }
.Section3 { .Section3 {
border: 1px solid gray; border: 1px solid lightgray;
border-radius: 0.25em;
margin: 1em 0.5em 0.5em; margin: 1em 0.5em 0.5em;
padding: 0.5em; padding: 0.5em;
overflow: visible; overflow: visible;
@ -70,6 +71,7 @@ div {
> .SectionHeading { > .SectionHeading {
display: flex; display: flex;
color: dimgray; color: dimgray;
font-size: 80%;
margin-top: -1.25em; margin-top: -1.25em;
> .SectionHeadingText { > .SectionHeadingText {

View File

@ -11,10 +11,6 @@ public class WeatherConfig {
private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd"; private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd";
private double latitude = 49.320789191091194;
private double longitude = 7.102111982262271;
private int pastDays = 9; private int pastDays = 9;
private int futureDays = 9; private int futureDays = 9;

View File

@ -3,6 +3,7 @@ package de.ph87.data.weather;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -16,9 +17,9 @@ public class WeatherController {
private final WeatherService weatherService; private final WeatherService weatherService;
@GetMapping("all") @GetMapping("forLocation/{id}")
public List<WeatherHour> all() { public List<WeatherHour> forLocation(@PathVariable final long id) {
return weatherService.all(); return weatherService.forLocation(id);
} }
} }

View File

@ -1,13 +1,17 @@
package de.ph87.data.weather; package de.ph87.data.weather;
import de.ph87.data.location.LocationDto;
import de.ph87.data.location.LocationRepository;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectMapper;
import java.io.IOException; import java.io.IOException;
@ -20,7 +24,9 @@ import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@Slf4j @Slf4j
@Service @Service
@ -28,43 +34,53 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class WeatherService { public class WeatherService {
private List<WeatherHour> hours = new ArrayList<>(); private final LocationRepository locationRepository;
private final Map<Long, List<WeatherHour>> locationHours = new HashMap<>();
private final WeatherConfig weatherConfig; private final WeatherConfig weatherConfig;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@NonNull @NonNull
public List<WeatherHour> all() { public List<WeatherHour> forLocation(final long id) {
final List<WeatherHour> hours = this.locationHours.get(id);
if (hours == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return new ArrayList<>(hours); return new ArrayList<>(hours);
} }
@Scheduled(cron = "0 0 * * * *") @Scheduled(cron = "0 0 * * * *")
@EventListener(ApplicationStartedEvent.class) @EventListener(ApplicationStartedEvent.class)
public void update() { public void updateAll() {
locationRepository.findAllDto().forEach(this::update);
}
private void update(@NonNull final LocationDto location) {
try { try {
final LocalDate today = LocalDate.now(); final LocalDate today = LocalDate.now();
final LocalDate first = today.minusDays(weatherConfig.getPastDays()); final LocalDate first = today.minusDays(weatherConfig.getPastDays());
final LocalDate end = today.plusDays(weatherConfig.getFutureDays()); final LocalDate end = today.plusDays(weatherConfig.getFutureDays());
final List<WeatherHour> hours = new ArrayList<>(); final List<WeatherHour> hours = new ArrayList<>();
log.debug("Updating Weather..."); log.debug("Updating Weather for Location: {}", location.name);
for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) { for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) {
fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add); fetchDay(location, day).getWeather().stream().map(WeatherHour::new).forEach(hours::add);
} }
this.hours = hours; this.locationHours.put(location.id, hours);
log.info("Weather update complete"); log.info("Weather update complete for Location: {}", location.name);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed fetching Weather data: {}", e.toString()); log.error("Failed fetching Weather data for Location: {}: {}", location.name, e.toString());
} }
} }
@NonNull @NonNull
public BrightSkyDto fetchDay(@NonNull final LocalDate day) throws IOException { public BrightSkyDto fetchDay(@NonNull final LocationDto location, @NonNull final LocalDate day) throws IOException {
final String url = weatherConfig final String url = weatherConfig
.getUrlPattern() .getUrlPattern()
.replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) .replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
.replace("{latitude}", weatherConfig.getLatitude() + "") .replace("{latitude}", location.getLatitude() + "")
.replace("{longitude}", weatherConfig.getLongitude() + ""); .replace("{longitude}", location.getLongitude() + "");
final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
final int responseCode = connection.getResponseCode(); final int responseCode = connection.getResponseCode();
final byte[] bytes = connection.getInputStream().readAllBytes(); final byte[] bytes = connection.getInputStream().readAllBytes();