This commit is contained in:
Patrick Haßel 2025-09-28 19:59:50 +02:00
parent 940e7c465b
commit e88ff035cf
15 changed files with 504 additions and 3 deletions

View File

@ -1,6 +1,8 @@
import {ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import 'chartjs-adapter-date-fns';
import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';
import {stompServiceFactory} from './COMMON';

View File

@ -1,4 +1,7 @@
<div class="plots">
<div class="plot">
<app-weather-component></app-weather-component>
</div>
@for (plot of plots; track plot.id) {
<div class="plot">
<app-plot [plot]="plot"></app-plot>

View File

@ -2,11 +2,13 @@ import {Component} from '@angular/core';
import {PlotService} from '../plot/plot.service';
import {Plot} from '../plot/Plot';
import {PlotComponent} from '../plot/plot/plot.component';
import {WeatherComponent} from '../weather/plot/weather-component';
@Component({
selector: 'app-dashboard',
imports: [
PlotComponent
PlotComponent,
WeatherComponent
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.less'

View File

@ -1,7 +1,6 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
import {SeriesService} from "../../series/series.service";
import 'chartjs-adapter-date-fns';
import {SeriesType} from '../../series/SeriesType';
import {PlotService} from '../plot.service';
import {Plot} from '../Plot';

View File

@ -1,6 +1,5 @@
import {AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
import 'chartjs-adapter-date-fns';
import {toAvg, toBool, toDelta, toMax, toMin} from '../../series/MinMaxAvg';
import {SeriesType} from '../../series/SeriesType';
import {Plot} from '../Plot';

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,4 @@
.container {
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,204 @@
import {AfterViewInit, Component, ElementRef, 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';
export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } {
return (hour: WeatherHour) => {
return {
x: hour.date.getTime(),
y: f(hour),
};
};
}
const TEMPERATURE_HIGH_COLOR = '#FF0000';
const TEMPERATURE_LOW_COLOR = '#0000FF';
const TEMPERATURE_LOW_THRESHOLD = 0;
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;
constructor(
readonly weatherService: WeatherService,
) {
//
}
ngAfterViewInit(): void {
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: true,
},
tooltip: {
position: 'nearest',
mode: 'index',
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});
},
},
},
title: {
display: true,
text: "Wetter",
}
},
scales: {
x: {
type: 'time',
time: {
unit: "day",
displayFormats: {
day: "EE dd.MM"
}
},
adapters: {
date: {
locale: de,
}
},
},
y_temperature: {
position: "right",
title: {
display: true,
text: "Temperatur [°C]",
color: TEMPERATURE_HIGH_COLOR,
},
ticks: {
color: TEMPERATURE_HIGH_COLOR,
},
min: 0,
max: 35,
},
y_precipitation: {
title: {
display: true,
text: "Niederschlag [mm]",
color: PRECIPITATION_COLOR,
},
ticks: {
color: PRECIPITATION_COLOR,
},
min: 0,
max: 15,
},
y_sun: {
position: "right",
title: {
display: true,
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.all(hours => {
const now = Date.now();
const filtered = hours.filter(h => h.date.getTime() >= now);
this.chart.data.datasets.push({
label: "Niederschlag",
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",
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",
type: "line",
yAxisID: "y_temperature",
data: filtered.map(toPoint(h => h.temperature)),
borderColor: function (context) {
const value = context.dataset.data[context.dataIndex];
return value !== null && typeof value === 'object' && value.hasOwnProperty("y") && value.y >= TEMPERATURE_LOW_THRESHOLD ? TEMPERATURE_HIGH_COLOR : TEMPERATURE_LOW_COLOR;
},
backgroundColor: TEMPERATURE_HIGH_COLOR + '66',
fill: {
target: {value: TEMPERATURE_LOW_THRESHOLD},
above: TEMPERATURE_HIGH_COLOR + '66',
below: TEMPERATURE_LOW_COLOR + '66',
},
borderWidth: 0,
pointRadius: 5,
pointStyle: "cross",
});
this.chart.data.datasets.push({
label: "Wolken",
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,20 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next} from '../COMMON';
import {WeatherHour} from './WeatherHour';
@Injectable({
providedIn: 'root'
})
export class WeatherService extends CrudService<WeatherHour> {
constructor(
api: ApiService,
) {
super(api, ['Weather'], WeatherHour.fromJson);
}
all(next: Next<WeatherHour[]>) {
this.getList(['all'], next);
}
}

View File

@ -0,0 +1,81 @@
package de.ph87.data.weather;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.TimeZone;
@Data
@SuppressWarnings("unused")
@JsonIgnoreProperties(ignoreUnknown = true)
public class BrightSkyDto {
private List<Hour> weather;
@Getter
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Hour {
private ZonedDateTime timestamp;
private int source_id;
private Double precipitation;
private Double pressure_msl;
private Double sunshine;
private Double temperature;
private Double wind_direction;
private Double wind_speed;
private Integer cloud_cover;
private Double dew_point;
private Double relative_humidity;
private Double visibility;
private Double wind_gust_direction;
private Double wind_gust_speed;
private String condition;
private Double precipitation_probability;
private Double precipitation_probability_6h;
private Double solar;
private String icon;
public void setTimestamp(@NonNull final ZonedDateTime timestamp) {
this.timestamp = timestamp.withZoneSameInstant(TimeZone.getDefault().toZoneId());
}
public double getSolar() {
return solar == null ? 0.0 : solar;
}
public double getCloud_cover() {
return cloud_cover == null ? 0.0 : cloud_cover;
}
public double getPrecipitation() {
return precipitation == null ? 0.0 : precipitation;
}
}
}

View File

@ -0,0 +1,22 @@
package de.ph87.data.weather;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "de.ph87.data.weather")
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;
}

View File

@ -0,0 +1,24 @@
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Weather")
public class WeatherController {
private final WeatherService weatherService;
@GetMapping("all")
public List<WeatherHour> all() {
return weatherService.all();
}
}

View File

@ -0,0 +1,36 @@
package de.ph87.data.weather;
import lombok.Data;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Data
@ToString(includeFieldNames = false)
public class WeatherHour {
@NonNull
public final ZonedDateTime date;
@NonNull
public final double clouds;
@NonNull
public final double irradiation;
@NonNull
public final double precipitation;
@NonNull
public final double temperature;
public WeatherHour(@NonNull final BrightSkyDto.Hour dto) {
date = dto.getTimestamp();
clouds = dto.getCloud_cover();
irradiation = dto.getSolar() * 1000;
precipitation = dto.getPrecipitation();
temperature = (dto.getTemperature());
}
}

View File

@ -0,0 +1,77 @@
package de.ph87.data.weather;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@EnableScheduling
@RequiredArgsConstructor
public class WeatherService {
private List<WeatherHour> hours = new ArrayList<>();
private final WeatherConfig weatherConfig;
private final ObjectMapper objectMapper;
@NonNull
public List<WeatherHour> all() {
return new ArrayList<>(hours);
}
@Scheduled(cron = "0 0 * * * *")
@EventListener(ApplicationStartedEvent.class)
public void update() {
try {
final LocalDate today = LocalDate.now();
final LocalDate first = today.minusDays(weatherConfig.getPastDays());
final LocalDate end = today.plusDays(weatherConfig.getFutureDays());
final List<WeatherHour> hours = new ArrayList<>();
log.debug("Updating Weather...");
for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) {
fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add);
}
this.hours = hours;
log.info("Weather update complete");
} catch (Exception e) {
log.error("Failed fetching Weather data: {}", e.toString());
}
}
@NonNull
public BrightSkyDto fetchDay(@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() + "");
final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
final int responseCode = connection.getResponseCode();
final byte[] bytes = connection.getInputStream().readAllBytes();
if (responseCode / 100 != 2) {
throw new IOException("responseCode=%d, message: %s".formatted(responseCode, new String(bytes, StandardCharsets.UTF_8)));
}
return objectMapper.readValue(bytes, BrightSkyDto.class);
}
}