From e2b0476aaac5133e4af4b7a9ad5cb6d7ae354908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Ha=C3=9Fel?= Date: Fri, 21 Nov 2025 14:38:45 +0100 Subject: [PATCH] added weather again --- src/main/angular/src/app/app.config.ts | 4 +- .../app/location/detail/location-detail.html | 9 + .../app/location/detail/location-detail.ts | 4 +- .../location/energy/charts/energy-charts.ts | 3 +- .../angular/src/app/weather/WeatherHour.ts | 25 ++ .../app/weather/plot/weather-component.html | 3 + .../app/weather/plot/weather-component.less | 4 + .../src/app/weather/plot/weather-component.ts | 238 ++++++++++++++++++ .../src/app/weather/weather-service.ts | 22 ++ src/main/angular/src/styles.less | 1 + .../de/ph87/data/weather/WeatherConfig.java | 4 - .../ph87/data/weather/WeatherController.java | 7 +- .../de/ph87/data/weather/WeatherService.java | 38 ++- 13 files changed, 340 insertions(+), 22 deletions(-) create mode 100644 src/main/angular/src/app/weather/WeatherHour.ts create mode 100644 src/main/angular/src/app/weather/plot/weather-component.html create mode 100644 src/main/angular/src/app/weather/plot/weather-component.less create mode 100644 src/main/angular/src/app/weather/plot/weather-component.ts create mode 100644 src/main/angular/src/app/weather/weather-service.ts diff --git a/src/main/angular/src/app/app.config.ts b/src/main/angular/src/app/app.config.ts index 1363f71..372ee8c 100644 --- a/src/main/angular/src/app/app.config.ts +++ b/src/main/angular/src/app/app.config.ts @@ -9,12 +9,12 @@ import {registerLocaleData} from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeDeExtra from '@angular/common/locales/extra/de'; import {stompServiceFactory} from './common'; -import {BarController, BarElement, Chart, Legend, LinearScale, TimeScale, Tooltip} from 'chart.js'; +import {BarController, BarElement, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip} from 'chart.js'; import 'chartjs-adapter-date-fns'; registerLocaleData(localeDe, 'de-DE', localeDeExtra); -Chart.register(TimeScale, LinearScale, BarController, BarElement, Tooltip, Legend); +Chart.register(TimeScale, LinearScale, BarController, BarElement, Tooltip, Legend, LineController, PointElement, LineElement, Filler); export const appConfig: ApplicationConfig = { providers: [ diff --git a/src/main/angular/src/app/location/detail/location-detail.html b/src/main/angular/src/app/location/detail/location-detail.html index fac948c..ea7cb71 100644 --- a/src/main/angular/src/app/location/detail/location-detail.html +++ b/src/main/angular/src/app/location/detail/location-detail.html @@ -2,6 +2,15 @@ +
+
+
+ Wettervorhersage +
+
+ +
+
diff --git a/src/main/angular/src/app/location/detail/location-detail.ts b/src/main/angular/src/app/location/detail/location-detail.ts index 5844cbf..e0fb83e 100644 --- a/src/main/angular/src/app/location/detail/location-detail.ts +++ b/src/main/angular/src/app/location/detail/location-detail.ts @@ -15,6 +15,7 @@ import {SeriesType} from '../../series/SeriesType'; import {DateService} from '../../date.service'; 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]; @@ -35,7 +36,8 @@ export function paramNumberOrNull(params: Params, key: string): number | null { Number, SeriesSelect, LocationEnergy, - LocationPower + LocationPower, + WeatherComponent ], templateUrl: './location-detail.html', styleUrl: './location-detail.less', diff --git a/src/main/angular/src/app/location/energy/charts/energy-charts.ts b/src/main/angular/src/app/location/energy/charts/energy-charts.ts index 97ec0f8..071b221 100644 --- a/src/main/angular/src/app/location/energy/charts/energy-charts.ts +++ b/src/main/angular/src/app/location/energy/charts/energy-charts.ts @@ -10,8 +10,10 @@ import {formatNumber} from '@angular/common'; const COLOR_BACK_PURCHASE = "#ffb9b9"; const COLOR_BACK_DELIVER = "#ff59ff"; +// noinspection JSUnusedLocalSymbols const COLOR_BACK_PRODUCE = "#5cbcff"; const COLOR_BACK_SELF = "#60ff8c"; +// noinspection JSUnusedLocalSymbols const COLOR_BACK_CONSUME = "#ffc07a"; @Component({ @@ -71,7 +73,6 @@ export class EnergyCharts implements OnChanges { locale: de }, }, - bounds: 'ticks', }, y: {}, }, diff --git a/src/main/angular/src/app/weather/WeatherHour.ts b/src/main/angular/src/app/weather/WeatherHour.ts new file mode 100644 index 0000000..0ab1172 --- /dev/null +++ b/src/main/angular/src/app/weather/WeatherHour.ts @@ -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), + ); + } + +} diff --git a/src/main/angular/src/app/weather/plot/weather-component.html b/src/main/angular/src/app/weather/plot/weather-component.html new file mode 100644 index 0000000..4585a0f --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/main/angular/src/app/weather/plot/weather-component.less b/src/main/angular/src/app/weather/plot/weather-component.less new file mode 100644 index 0000000..cbe226c --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.less @@ -0,0 +1,4 @@ +.container { + width: 100%; + height: 100%; +} diff --git a/src/main/angular/src/app/weather/plot/weather-component.ts b/src/main/angular/src/app/weather/plot/weather-component.ts new file mode 100644 index 0000000..7c26d51 --- /dev/null +++ b/src/main/angular/src/app/weather/plot/weather-component.ts @@ -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; + + @ViewChild('container') + protected chartContainer!: ElementRef; + + 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 = /^(?.+)\[(?.+)]$/.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: "Wolkenbedeckung [%]", + 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(); + }); + } + +} diff --git a/src/main/angular/src/app/weather/weather-service.ts b/src/main/angular/src/app/weather/weather-service.ts new file mode 100644 index 0000000..66c87a8 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-service.ts @@ -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 { + + constructor( + api: ApiService, + ws: WebsocketService, + ) { + super(api, ws, ['Weather'], WeatherHour.fromJson); + } + + forLocation(location: Location, next: Next) { + this.getList(['forLocation', location.id], next); + } + +} diff --git a/src/main/angular/src/styles.less b/src/main/angular/src/styles.less index 7f3341b..3cc15f7 100644 --- a/src/main/angular/src/styles.less +++ b/src/main/angular/src/styles.less @@ -71,6 +71,7 @@ div { > .SectionHeading { display: flex; color: dimgray; + font-size: 80%; margin-top: -1.25em; > .SectionHeadingText { diff --git a/src/main/java/de/ph87/data/weather/WeatherConfig.java b/src/main/java/de/ph87/data/weather/WeatherConfig.java index 8a05b25..8989332 100644 --- a/src/main/java/de/ph87/data/weather/WeatherConfig.java +++ b/src/main/java/de/ph87/data/weather/WeatherConfig.java @@ -11,10 +11,6 @@ public class WeatherConfig { 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 futureDays = 9; diff --git a/src/main/java/de/ph87/data/weather/WeatherController.java b/src/main/java/de/ph87/data/weather/WeatherController.java index 1b59384..432981e 100644 --- a/src/main/java/de/ph87/data/weather/WeatherController.java +++ b/src/main/java/de/ph87/data/weather/WeatherController.java @@ -3,6 +3,7 @@ package de.ph87.data.weather; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,9 +17,9 @@ public class WeatherController { private final WeatherService weatherService; - @GetMapping("all") - public List all() { - return weatherService.all(); + @GetMapping("forLocation/{id}") + public List forLocation(@PathVariable final long id) { + return weatherService.forLocation(id); } } diff --git a/src/main/java/de/ph87/data/weather/WeatherService.java b/src/main/java/de/ph87/data/weather/WeatherService.java index 0ba744f..c7098ec 100644 --- a/src/main/java/de/ph87/data/weather/WeatherService.java +++ b/src/main/java/de/ph87/data/weather/WeatherService.java @@ -1,13 +1,17 @@ package de.ph87.data.weather; +import de.ph87.data.location.LocationDto; +import de.ph87.data.location.LocationRepository; 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.http.HttpStatus; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; import tools.jackson.databind.ObjectMapper; import java.io.IOException; @@ -20,7 +24,9 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -28,43 +34,53 @@ import java.util.List; @RequiredArgsConstructor public class WeatherService { - private List hours = new ArrayList<>(); + private final LocationRepository locationRepository; + + private final Map> locationHours = new HashMap<>(); private final WeatherConfig weatherConfig; private final ObjectMapper objectMapper; @NonNull - public List all() { + public List forLocation(final long id) { + final List hours = this.locationHours.get(id); + if (hours == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } return new ArrayList<>(hours); } @Scheduled(cron = "0 0 * * * *") @EventListener(ApplicationStartedEvent.class) - public void update() { + public void updateAll() { + locationRepository.findAllDto().forEach(this::update); + } + + private void update(@NonNull final LocationDto location) { try { final LocalDate today = LocalDate.now(); final LocalDate first = today.minusDays(weatherConfig.getPastDays()); final LocalDate end = today.plusDays(weatherConfig.getFutureDays()); final List 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)) { - fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add); + fetchDay(location, day).getWeather().stream().map(WeatherHour::new).forEach(hours::add); } - this.hours = hours; - log.info("Weather update complete"); + this.locationHours.put(location.id, hours); + log.info("Weather update complete for Location: {}", location.name); } catch (Exception e) { - log.error("Failed fetching Weather data: {}", e.toString()); + log.error("Failed fetching Weather data for Location: {}: {}", location.name, e.toString()); } } @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 .getUrlPattern() .replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) - .replace("{latitude}", weatherConfig.getLatitude() + "") - .replace("{longitude}", weatherConfig.getLongitude() + ""); + .replace("{latitude}", location.getLatitude() + "") + .replace("{longitude}", location.getLongitude() + ""); final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); final int responseCode = connection.getResponseCode(); final byte[] bytes = connection.getInputStream().readAllBytes();